playwright/packages/trace-viewer/src/ui/uiModeView.tsx

529 lines
23 KiB
TypeScript

/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@web/third_party/vscode/codicon.css';
import '@web/common.css';
import React from 'react';
import { TeleSuite } from '@testIsomorphic/teleReceiver';
import { TeleSuiteUpdater, type TeleSuiteUpdaterProgress, type TeleSuiteUpdaterTestModel } from '@testIsomorphic/teleSuiteUpdater';
import type { TeleTestCase } from '@testIsomorphic/teleReceiver';
import type * as reporterTypes from 'playwright/types/testReporter';
import { SplitView } from '@web/components/splitView';
import type { SourceLocation } from './modelUtil';
import './uiModeView.css';
import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
import type { XtermDataSource } from '@web/components/xtermWrapper';
import { XtermWrapper } from '@web/components/xtermWrapper';
import { clsx, settings, useSetting } from '@web/uiUtils';
import { statusEx, TestTree } from '@testIsomorphic/testTree';
import type { TreeItem } from '@testIsomorphic/testTree';
import { TestServerConnection, WebSocketTestServerTransport } from '@testIsomorphic/testServerConnection';
import { FiltersView } from './uiModeFiltersView';
import { TestListView } from './uiModeTestListView';
import { TraceView } from './uiModeTraceView';
import { SettingsView } from './settingsView';
import { DefaultSettingsView } from './defaultSettingsView';
let xtermSize = { cols: 80, rows: 24 };
const xtermDataSource: XtermDataSource = {
pending: [],
clear: () => {},
write: data => xtermDataSource.pending.push(data),
resize: () => {},
};
const searchParams = new URLSearchParams(window.location.search);
const testServerBaseUrl = new URL(searchParams.get('server') ?? '../', window.location.href);
const wsURL = new URL(searchParams.get('ws')!, testServerBaseUrl);
wsURL.protocol = (wsURL.protocol === 'https:' ? 'wss:' : 'ws:');
const queryParams = {
args: searchParams.getAll('arg'),
grep: searchParams.get('grep') || undefined,
grepInvert: searchParams.get('grepInvert') || undefined,
projects: searchParams.getAll('project'),
workers: searchParams.get('workers') || undefined,
headed: searchParams.has('headed'),
updateSnapshots: (searchParams.get('updateSnapshots') as 'all' | 'none' | 'missing' | undefined) || undefined,
reporters: searchParams.has('reporter') ? searchParams.getAll('reporter') : undefined,
pathSeparator: searchParams.get('pathSeparator') || '/',
};
if (queryParams.updateSnapshots && !['all', 'none', 'missing'].includes(queryParams.updateSnapshots))
queryParams.updateSnapshots = undefined;
const isMac = navigator.platform === 'MacIntel';
export const UIModeView: React.FC<{}> = ({
}) => {
const [filterText, setFilterText] = React.useState<string>('');
const [isShowingOutput, setIsShowingOutput] = React.useState<boolean>(false);
const [outputContainsError, setOutputContainsError] = React.useState(false);
const [statusFilters, setStatusFilters] = React.useState<Map<string, boolean>>(new Map([
['passed', false],
['failed', false],
['skipped', false],
]));
const [projectFilters, setProjectFilters] = React.useState<Map<string, boolean>>(new Map());
const [testModel, setTestModel] = React.useState<TeleSuiteUpdaterTestModel>();
const [progress, setProgress] = React.useState<TeleSuiteUpdaterProgress & { total: number } | undefined>();
const [selectedItem, setSelectedItem] = React.useState<{ treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }>({});
const [visibleTestIds, setVisibleTestIds] = React.useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean, completed?: boolean } | undefined>();
const isRunningTest = runningState && !runningState.completed;
const [watchAll, setWatchAll] = useSetting<boolean>('watch-all', false);
const [watchedTreeIds, setWatchedTreeIds] = React.useState<{ value: Set<string> }>({ value: new Set() });
const commandQueue = React.useRef(Promise.resolve());
const runTestBacklog = React.useRef<Set<string>>(new Set());
const [collapseAllCount, setCollapseAllCount] = React.useState(0);
const [expandAllCount, setExpandAllCount] = React.useState(0);
const [isDisconnected, setIsDisconnected] = React.useState(false);
const [hasBrowsers, setHasBrowsers] = React.useState(true);
const [testServerConnection, setTestServerConnection] = React.useState<TestServerConnection>();
const [teleSuiteUpdater, setTeleSuiteUpdater] = React.useState<TeleSuiteUpdater>();
const [settingsVisible, setSettingsVisible] = React.useState(false);
const [testingOptionsVisible, setTestingOptionsVisible] = React.useState(false);
const [revealSource, setRevealSource] = React.useState(false);
const onRevealSource = React.useCallback(() => setRevealSource(true), [setRevealSource]);
const showTestingOptions = false;
const [singleWorker, setSingleWorker] = React.useState(false);
const [showBrowser, setShowBrowser] = React.useState(false);
const [updateSnapshots, setUpdateSnapshots] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const reloadTests = React.useCallback(() => {
setTestServerConnection(prevConnection => {
prevConnection?.close();
return new TestServerConnection(new WebSocketTestServerTransport(wsURL));
});
}, []);
// Load tests on startup.
React.useEffect(() => {
inputRef.current?.focus();
setIsLoading(true);
reloadTests();
}, [reloadTests]);
// Wire server connection to the auxiliary UI features.
React.useEffect(() => {
if (!testServerConnection)
return;
const disposables = [
testServerConnection.onStdio(params => {
if (params.buffer) {
const data = atob(params.buffer);
xtermDataSource.write(data);
} else {
xtermDataSource.write(params.text!);
}
if (params.type === 'stderr')
setOutputContainsError(true);
}),
testServerConnection.onClose(() => setIsDisconnected(true))
];
xtermDataSource.resize = (cols, rows) => {
xtermSize = { cols, rows };
testServerConnection.resizeTerminalNoReply({ cols, rows });
};
return () => {
for (const disposable of disposables)
disposable.dispose();
};
}, [testServerConnection]);
// This is the main routine, every time connection updates it starts the
// whole workflow.
React.useEffect(() => {
if (!testServerConnection)
return;
let throttleTimer: NodeJS.Timeout | undefined;
const teleSuiteUpdater = new TeleSuiteUpdater({
onUpdate: immediate => {
clearTimeout(throttleTimer);
throttleTimer = undefined;
if (immediate) {
setTestModel(teleSuiteUpdater.asModel());
} else if (!throttleTimer) {
throttleTimer = setTimeout(() => {
setTestModel(teleSuiteUpdater.asModel());
}, 250);
}
},
onError: error => {
xtermDataSource.write((error.stack || error.value || '') + '\n');
setOutputContainsError(true);
},
pathSeparator: queryParams.pathSeparator,
});
setTeleSuiteUpdater(teleSuiteUpdater);
setTestModel(undefined);
setIsLoading(true);
setWatchedTreeIds({ value: new Set() });
(async () => {
try {
await testServerConnection.initialize({
interceptStdio: true,
watchTestDirs: true
});
const { status, report } = await testServerConnection.runGlobalSetup({});
teleSuiteUpdater.processGlobalReport(report);
if (status !== 'passed')
return;
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert });
teleSuiteUpdater.processListReport(result.report);
testServerConnection.onReport(params => {
teleSuiteUpdater.processTestReportEvent(params);
});
const { hasBrowsers } = await testServerConnection.checkBrowsers({});
setHasBrowsers(hasBrowsers);
} finally {
setIsLoading(false);
}
})();
return () => {
clearTimeout(throttleTimer);
};
}, [testServerConnection]);
// Update project filter default values.
React.useEffect(() => {
if (!testModel)
return;
const { config, rootSuite } = testModel;
const selectedProjects = config.configFile ? settings.getObject<string[] | undefined>(config.configFile + ':projects', undefined) : undefined;
const newFilter = new Map(projectFilters);
for (const projectName of newFilter.keys()) {
if (!rootSuite.suites.find(s => s.title === projectName))
newFilter.delete(projectName);
}
for (const projectSuite of rootSuite.suites) {
if (!newFilter.has(projectSuite.title))
newFilter.set(projectSuite.title, !!selectedProjects?.includes(projectSuite.title));
}
if (!selectedProjects && newFilter.size && ![...newFilter.values()].includes(true))
newFilter.set(newFilter.entries().next().value![0], true);
if (projectFilters.size !== newFilter.size || [...projectFilters].some(([k, v]) => newFilter.get(k) !== v))
setProjectFilters(newFilter);
}, [projectFilters, testModel]);
// Update progress.
React.useEffect(() => {
if (isRunningTest && testModel?.progress)
setProgress(testModel.progress);
else if (!testModel)
setProgress(undefined);
}, [testModel, isRunningTest]);
// Test tree is built from the model and filters.
const { testTree } = React.useMemo(() => {
if (!testModel)
return { testTree: new TestTree('', new TeleSuite('', 'root'), [], projectFilters, queryParams.pathSeparator) };
const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, queryParams.pathSeparator);
testTree.filterTree(filterText, statusFilters, isRunningTest ? runningState?.testIds : undefined);
testTree.sortAndPropagateStatus();
testTree.shortenRoot();
testTree.flattenForSingleProject();
setVisibleTestIds(testTree.testIds());
return { testTree };
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState, isRunningTest]);
const runTests = React.useCallback((mode: 'queue-if-busy' | 'bounce-if-busy', testIds: Set<string>) => {
if (!testServerConnection || !testModel)
return;
if (mode === 'bounce-if-busy' && isRunningTest)
return;
runTestBacklog.current = new Set([...runTestBacklog.current, ...testIds]);
commandQueue.current = commandQueue.current.then(async () => {
const testIds = runTestBacklog.current;
runTestBacklog.current = new Set();
if (!testIds.size)
return;
// Clear test results.
{
for (const test of testModel.rootSuite?.allTests() || []) {
if (testIds.has(test.id)) {
test.results = [];
const result = (test as TeleTestCase)._createTestResult('pending');
(result as any)[statusEx] = 'scheduled';
}
}
setTestModel({ ...testModel });
}
const time = ' [' + new Date().toLocaleTimeString() + ']';
xtermDataSource.write('\x1B[2m—'.repeat(Math.max(0, xtermSize.cols - time.length)) + time + '\x1B[22m');
setProgress({ total: 0, passed: 0, failed: 0, skipped: 0 });
setRunningState({ testIds });
await testServerConnection.runTests({
locations: queryParams.args,
grep: queryParams.grep,
grepInvert: queryParams.grepInvert,
testIds: [...testIds],
projects: [...projectFilters].filter(([_, v]) => v).map(([p]) => p),
...(singleWorker ? { workers: '1' } : {}),
...(showBrowser ? { headed: true } : {}),
...(updateSnapshots ? { updateSnapshots: 'all' } : {}),
reporters: queryParams.reporters,
trace: 'on',
});
// Clear pending tests in case of interrupt.
for (const test of testModel.rootSuite?.allTests() || []) {
if (test.results[0]?.duration === -1)
test.results = [];
}
setTestModel({ ...testModel });
setRunningState(oldState => oldState ? ({ ...oldState, completed: true }) : undefined);
});
}, [projectFilters, isRunningTest, testModel, testServerConnection, singleWorker, showBrowser, updateSnapshots]);
React.useEffect(() => {
if (!testServerConnection || !teleSuiteUpdater)
return;
const disposable = testServerConnection.onTestFilesChanged(async params => {
// fetch the new list of tests
commandQueue.current = commandQueue.current.then(async () => {
setIsLoading(true);
try {
const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert });
teleSuiteUpdater.processListReport(result.report);
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
} finally {
setIsLoading(false);
}
});
await commandQueue.current;
if (params.testFiles.length === 0)
return;
// run affected watched tests
const testModel = teleSuiteUpdater.asModel();
const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, queryParams.pathSeparator);
const testIds: string[] = [];
const set = new Set(params.testFiles);
if (watchAll) {
const visit = (treeItem: TreeItem) => {
const fileName = treeItem.location.file;
if (fileName && set.has(fileName))
testIds.push(...testTree.collectTestIds(treeItem));
if (treeItem.kind === 'group' && treeItem.subKind === 'folder')
treeItem.children.forEach(visit);
};
visit(testTree.rootItem);
} else {
for (const treeId of watchedTreeIds.value) {
const treeItem = testTree.treeItemById(treeId);
const fileName = treeItem?.location.file;
if (fileName && set.has(fileName))
testIds.push(...testTree.collectTestIds(treeItem));
}
}
runTests('queue-if-busy', new Set(testIds));
});
return () => disposable.dispose();
}, [runTests, testServerConnection, watchAll, watchedTreeIds, teleSuiteUpdater, projectFilters]);
// Shortcuts.
React.useEffect(() => {
if (!testServerConnection)
return;
const onShortcutEvent = (e: KeyboardEvent) => {
if (e.code === 'Backquote' && e.ctrlKey) {
e.preventDefault();
setIsShowingOutput(!isShowingOutput);
} else if (e.code === 'F5' && e.shiftKey) {
e.preventDefault();
testServerConnection?.stopTestsNoReply({});
} else if (e.code === 'F5') {
e.preventDefault();
runTests('bounce-if-busy', visibleTestIds);
}
};
addEventListener('keydown', onShortcutEvent);
return () => {
removeEventListener('keydown', onShortcutEvent);
};
}, [runTests, reloadTests, testServerConnection, visibleTestIds, isShowingOutput]);
const dialogRef = React.useRef<HTMLDialogElement>(null);
const openInstallDialog = React.useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
dialogRef.current?.showModal();
}, []);
const closeInstallDialog = React.useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
dialogRef.current?.close();
}, []);
const installBrowsers = React.useCallback((e: React.MouseEvent) => {
closeInstallDialog(e);
setIsShowingOutput(true);
testServerConnection?.installBrowsers({}).then(async () => {
setIsShowingOutput(false);
const { hasBrowsers } = await testServerConnection?.checkBrowsers({});
setHasBrowsers(hasBrowsers);
});
}, [closeInstallDialog, testServerConnection]);
return <div className='vbox ui-mode'>
{!hasBrowsers && <dialog ref={dialogRef}>
<div className='title'><span className='codicon codicon-lightbulb'></span>Install browsers</div>
<div className='body'>
Playwright did not find installed browsers.
<br></br>
Would you like to run `playwright install`?
<br></br>
<button className='button' onClick={installBrowsers}>Install</button>
<button className='button secondary' onClick={closeInstallDialog}>Dismiss</button>
</div>
</dialog>}
{isDisconnected && <div className='disconnected'>
<div className='title'>UI Mode disconnected</div>
<div><a href='#' onClick={() => window.location.href = '/'}>Reload the page</a> to reconnect</div>
</div>}
<SplitView
sidebarSize={250}
minSidebarSize={150}
orientation='horizontal'
sidebarIsFirst={true}
settingName='testListSidebar'
main={<div className='vbox'>
<div className={clsx('vbox', !isShowingOutput && 'hidden')}>
<Toolbar>
<div className='section-title' style={{ flex: 'none' }}>Output</div>
<ToolbarButton icon='circle-slash' title='Clear output' onClick={() => { xtermDataSource.clear(); setOutputContainsError(false); }}></ToolbarButton>
<div className='spacer'></div>
<ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton>
</Toolbar>
<XtermWrapper source={xtermDataSource}></XtermWrapper>
</div>
<div className={clsx('vbox', isShowingOutput && 'hidden')}>
<TraceView
pathSeparator={queryParams.pathSeparator}
item={selectedItem}
rootDir={testModel?.config?.rootDir}
revealSource={revealSource}
onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })}
/>
</div>
</div>}
sidebar={<div className='vbox ui-mode-sidebar'>
<Toolbar noShadow={true} noMinHeight={true}>
<img src='playwright-logo.svg' alt='Playwright logo' />
<div className='section-title'>Playwright</div>
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
<div style={{ position: 'relative' }}>
<ToolbarButton icon={'terminal'} title={'Toggle output — ' + (isMac ? '⌃`' : 'Ctrl + `')} toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
{outputContainsError && <div title='Output contains error' style={{ position: 'absolute', top: 2, right: 2, width: 7, height: 7, borderRadius: '50%', backgroundColor: 'var(--vscode-notificationsErrorIcon-foreground)' }} />}
</div>
{!hasBrowsers && <ToolbarButton icon='lightbulb-autofix' style={{ color: 'var(--vscode-list-warningForeground)' }} title='Playwright browsers are missing' onClick={openInstallDialog} />}
</Toolbar>
<FiltersView
filterText={filterText}
setFilterText={setFilterText}
statusFilters={statusFilters}
setStatusFilters={setStatusFilters}
projectFilters={projectFilters}
setProjectFilters={setProjectFilters}
testModel={testModel}
runTests={() => runTests('bounce-if-busy', visibleTestIds)} />
<Toolbar noMinHeight={true}>
{!isRunningTest && !progress && <div className='section-title'>Tests</div>}
{!isRunningTest && progress && <div data-testid='status-line' className='status-line'>
<div>{progress.passed}/{progress.total} passed ({(progress.passed / progress.total) * 100 | 0}%)</div>
</div>}
{isRunningTest && progress && <div data-testid='status-line' className='status-line'>
<div>Running {progress.passed}/{runningState.testIds.size} passed ({(progress.passed / runningState.testIds.size) * 100 | 0}%)</div>
</div>}
<ToolbarButton icon='play' title='Run all — F5' onClick={() => runTests('bounce-if-busy', visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='debug-stop' title={'Stop — ' + (isMac ? '⇧F5' : 'Shift + F5')} onClick={() => testServerConnection?.stopTests({})} disabled={!isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='eye' title='Watch all' toggled={watchAll} onClick={() => {
setWatchedTreeIds({ value: new Set() });
setWatchAll(!watchAll);
}}></ToolbarButton>
<ToolbarButton icon='collapse-all' title='Collapse all' onClick={() => {
setCollapseAllCount(collapseAllCount + 1);
}} />
<ToolbarButton icon='expand-all' title='Expand all' onClick={() => {
setExpandAllCount(expandAllCount + 1);
}} />
</Toolbar>
<TestListView
filterText={filterText}
testModel={testModel}
testTree={testTree}
testServerConnection={testServerConnection}
runningState={runningState}
runTests={runTests}
onItemSelected={setSelectedItem}
watchAll={watchAll}
watchedTreeIds={watchedTreeIds}
setWatchedTreeIds={setWatchedTreeIds}
isLoading={isLoading}
requestedCollapseAllCount={collapseAllCount}
requestedExpandAllCount={expandAllCount}
setFilterText={setFilterText}
onRevealSource={onRevealSource}
/>
{showTestingOptions && <>
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setTestingOptionsVisible(!testingOptionsVisible)}>
<span
className={`codicon codicon-${testingOptionsVisible ? 'chevron-down' : 'chevron-right'}`}
style={{ marginLeft: 5 }}
title={testingOptionsVisible ? 'Hide Testing Options' : 'Show Testing Options'}
/>
<div className='section-title'>Testing Options</div>
</Toolbar>
{testingOptionsVisible && <SettingsView settings={[
{ value: singleWorker, set: setSingleWorker, name: 'Single worker' },
{ value: showBrowser, set: setShowBrowser, name: 'Show browser' },
{ value: updateSnapshots, set: setUpdateSnapshots, name: 'Update snapshots' },
]} />}
</>}
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setSettingsVisible(!settingsVisible)}>
<span
className={`codicon codicon-${settingsVisible ? 'chevron-down' : 'chevron-right'}`}
style={{ marginLeft: 5 }}
title={settingsVisible ? 'Hide Settings' : 'Show Settings'}
/>
<div className='section-title'>Settings</div>
</Toolbar>
{settingsVisible && <DefaultSettingsView />}
</div>
}
/>
</div>;
};