758 lines
26 KiB
TypeScript
Raw Normal View History

2023-03-01 15:27:23 -08:00
/**
* 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 { Workbench } from './workbench';
2023-03-01 15:27:23 -08:00
import '@web/common.css';
import React from 'react';
2023-03-08 17:33:27 -08:00
import { TreeView } from '@web/components/treeView';
import type { TreeState } from '@web/components/treeView';
import { TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver';
import type { TeleTestCase } from '@testIsomorphic/teleReceiver';
2023-03-15 11:17:03 -07:00
import type { FullConfig, Suite, TestCase, TestResult, Location } from '../../../playwright-test/types/testReporter';
2023-03-01 15:27:23 -08:00
import { SplitView } from '@web/components/splitView';
import { MultiTraceModel } from './modelUtil';
2023-03-01 15:27:23 -08:00
import './watchMode.css';
import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
import type { ContextEntry } from '../entries';
2023-03-07 14:24:50 -08:00
import type { XtermDataSource } from '@web/components/xtermWrapper';
import { XtermWrapper } from '@web/components/xtermWrapper';
2023-03-10 12:41:00 -08:00
import { Expandable } from '@web/components/expandable';
2023-03-13 22:19:31 -07:00
import { toggleTheme } from '@web/theme';
import { artifactsFolderName } from '@testIsomorphic/folders';
2023-03-01 15:27:23 -08:00
let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {};
let runWatchedTests = (fileName: string) => {};
let xtermSize = { cols: 80, rows: 24 };
2023-03-01 15:27:23 -08:00
2023-03-07 14:24:50 -08:00
const xtermDataSource: XtermDataSource = {
pending: [],
clear: () => {},
write: data => xtermDataSource.pending.push(data),
resize: (cols: number, rows: number) => {
xtermSize = { cols, rows };
sendMessageNoReply('resizeTerminal', { cols, rows });
},
2023-03-07 14:24:50 -08:00
};
2023-03-01 15:27:23 -08:00
export const WatchModeView: React.FC<{}> = ({
}) => {
2023-03-13 22:19:31 -07:00
const [filterText, setFilterText] = React.useState<string>('');
const [isShowingOutput, setIsShowingOutput] = React.useState<boolean>(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 [rootSuite, setRootSuite] = React.useState<{ value: Suite | undefined }>({ value: undefined });
const [progress, setProgress] = React.useState<Progress>({ total: 0, passed: 0, failed: 0, skipped: 0 });
2023-03-08 17:33:27 -08:00
const [selectedTest, setSelectedTest] = React.useState<TestCase | undefined>(undefined);
const [visibleTestIds, setVisibleTestIds] = React.useState<string[]>([]);
2023-03-13 22:19:31 -07:00
const [isLoading, setIsLoading] = React.useState<boolean>(false);
2023-03-12 10:42:02 -07:00
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean }>();
const inputRef = React.useRef<HTMLInputElement>(null);
2023-03-13 22:19:31 -07:00
const reloadTests = () => {
setIsLoading(true);
updateRootSuite(new TeleSuite('', 'root'), { total: 0, passed: 0, failed: 0, skipped: 0 });
refreshRootSuite(true).then(() => {
setIsLoading(false);
});
};
React.useEffect(() => {
inputRef.current?.focus();
2023-03-13 22:19:31 -07:00
reloadTests();
}, []);
2023-03-07 12:43:16 -08:00
updateRootSuite = (rootSuite: Suite, newProgress: Progress) => {
2023-03-13 22:19:31 -07:00
for (const projectName of projectFilters.keys()) {
if (!rootSuite.suites.find(s => s.title === projectName))
2023-03-13 22:19:31 -07:00
projectFilters.delete(projectName);
}
for (const projectSuite of rootSuite.suites) {
2023-03-13 22:19:31 -07:00
if (!projectFilters.has(projectSuite.title))
projectFilters.set(projectSuite.title, false);
}
2023-03-13 22:19:31 -07:00
if (projectFilters.size && ![...projectFilters.values()].includes(true))
projectFilters.set(projectFilters.entries().next().value[0], true);
setRootSuite({ value: rootSuite });
2023-03-13 22:19:31 -07:00
setProjectFilters(new Map(projectFilters));
setProgress(newProgress);
};
2023-03-07 12:43:16 -08:00
2023-03-15 11:17:03 -07:00
const outputDir = React.useMemo(() => {
let outputDir = '';
for (const p of rootSuite.value?.suites || []) {
outputDir = p.project()?.outputDir || '';
break;
}
return outputDir;
}, [rootSuite]);
2023-03-07 12:43:16 -08:00
const runTests = (testIds: string[]) => {
// Clear test results.
{
const testIdSet = new Set(testIds);
for (const test of rootSuite.value?.allTests() || []) {
if (testIdSet.has(test.id))
2023-03-10 12:41:00 -08:00
(test as TeleTestCase)._createTestResult('pending');
}
setRootSuite({ ...rootSuite });
}
const time = ' [' + new Date().toLocaleTimeString() + ']';
xtermDataSource.write('\x1B[2m—'.repeat(Math.max(0, xtermSize.cols - time.length)) + time + '\x1B[22m');
setProgress({ total: testIds.length, passed: 0, failed: 0, skipped: 0 });
2023-03-12 10:42:02 -07:00
setRunningState({ testIds: new Set(testIds) });
2023-03-07 12:43:16 -08:00
sendMessage('run', { testIds }).then(() => {
2023-03-12 10:42:02 -07:00
setRunningState(undefined);
2023-03-07 12:43:16 -08:00
});
};
2023-03-12 10:42:02 -07:00
const isRunningTest = !!runningState;
const result = selectedTest?.results[0];
2023-03-15 11:17:03 -07:00
return <div className='vbox watch-mode'>
2023-03-07 14:24:50 -08:00
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<div className='vbox'>
<div className={'vbox' + (isShowingOutput ? '' : ' hidden')}>
<Toolbar>
<ToolbarButton icon='circle-slash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton>
<div className='spacer'></div>
<ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton>
</Toolbar>
<XtermWrapper source={xtermDataSource}></XtermWrapper>;
</div>
<div className={'vbox' + (isShowingOutput ? ' hidden' : '')}>
2023-03-15 11:17:03 -07:00
<TraceView outputDir={outputDir} testCase={selectedTest} result={result} />
</div>
</div>
2023-03-07 14:24:50 -08:00
<div className='vbox watch-mode-sidebar'>
<Toolbar>
2023-03-13 22:19:31 -07:00
<img src='icon-32x32.png' />
<div className='section-title'>Playwright</div>
2023-03-07 12:43:16 -08:00
<div className='spacer'></div>
2023-03-13 22:19:31 -07:00
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()} />
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
</Toolbar>
<FiltersView
filterText={filterText}
setFilterText={setFilterText}
statusFilters={statusFilters}
setStatusFilters={setStatusFilters}
projectFilters={projectFilters}
setProjectFilters={setProjectFilters}
runTests={() => runTests(visibleTestIds)} />
<Toolbar>
<div className='section-title'>Tests</div>
<div className='spacer'></div>
<ToolbarButton icon='play' title='Run all' onClick={() => runTests(visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest || isLoading}></ToolbarButton>
2023-03-07 14:24:50 -08:00
</Toolbar>
<TestList
2023-03-13 22:19:31 -07:00
statusFilters={statusFilters}
projectFilters={projectFilters}
filterText={filterText}
2023-03-07 14:24:50 -08:00
rootSuite={rootSuite}
2023-03-12 10:42:02 -07:00
runningState={runningState}
2023-03-07 14:24:50 -08:00
runTests={runTests}
2023-03-08 17:33:27 -08:00
onTestSelected={setSelectedTest}
setVisibleTestIds={setVisibleTestIds} />
2023-03-07 14:24:50 -08:00
</div>
</SplitView>
<div className='status-line'>
<div>Total: {progress.total}</div>
2023-03-13 22:19:31 -07:00
{isRunningTest && <div><span className='codicon codicon-loading'></span>{`Running ${visibleTestIds.length}\u2026`}</div>}
{isLoading && <div><span className='codicon codicon-loading'></span> {'Loading\u2026'}</div>}
{!isRunningTest && <div>Showing: {visibleTestIds.length}</div>}
<div>{progress.passed} passed</div>
<div>{progress.failed} failed</div>
<div>{progress.skipped} skipped</div>
2023-03-07 12:43:16 -08:00
</div>
2023-03-07 14:24:50 -08:00
</div>;
2023-03-07 12:43:16 -08:00
};
2023-03-13 22:19:31 -07:00
const FiltersView: React.FC<{
filterText: string;
setFilterText: (text: string) => void;
statusFilters: Map<string, boolean>;
setStatusFilters: (filters: Map<string, boolean>) => void;
projectFilters: Map<string, boolean>;
setProjectFilters: (filters: Map<string, boolean>) => void;
runTests: () => void;
}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, runTests }) => {
const [expanded, setExpanded] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
inputRef.current?.focus();
}, []);
const statusLine = [...statusFilters.entries()].filter(([_, v]) => v).map(([s]) => s).join(' ') || 'all';
const projectsLine = [...projectFilters.entries()].filter(([_, v]) => v).map(([p]) => p).join(' ') || 'all';
return <div className='filters'>
<Expandable
expanded={expanded}
setExpanded={setExpanded}
title={<input ref={inputRef} type='search' placeholder='Filter (e.g. text, @tag)' spellCheck={false} value={filterText}
onChange={e => {
setFilterText(e.target.value);
}}
onKeyDown={e => {
if (e.key === 'Enter')
runTests();
}} />}>
{<div className='filter-title' title={statusLine} onClick={() => setExpanded(false)}><span className='filter-label'>Status:</span> {statusLine}</div>}
{[...statusFilters.entries()].map(([status, value]) => {
return <div className='filter-entry'>
<label>
<input type='checkbox' checked={value} onClick={() => {
const copy = new Map(statusFilters);
copy.set(status, !copy.get(status));
setStatusFilters(copy);
}}/>
<div>{status}</div>
</label>
</div>;
})}
{<div className='filter-title' title={projectsLine}><span className='filter-label'>Projects:</span> {projectsLine}</div>}
{[...projectFilters.entries()].map(([projectName, value]) => {
return <div className='filter-entry'>
<label>
<input type='checkbox' checked={value} onClick={() => {
const copy = new Map(projectFilters);
copy.set(projectName, !copy.get(projectName));
setProjectFilters(copy);
}}/>
<div>{projectName}</div>
</label>
</div>;
})}
</Expandable>
{!expanded && <div className='filter-summary' title={'Status: ' + statusLine + '\nProjects: ' + projectsLine} onClick={() => setExpanded(true)}>
<span className='filter-label'>Status:</span> {statusLine}
<span className='filter-label'>Projects:</span> {projectsLine}
</div>}
</div>;
};
const TestTreeView = TreeView<TreeItem>;
2023-03-08 17:33:27 -08:00
2023-03-13 22:19:31 -07:00
const TestList: React.FC<{
statusFilters: Map<string, boolean>,
projectFilters: Map<string, boolean>,
filterText: string,
2023-03-07 12:43:16 -08:00
rootSuite: { value: Suite | undefined },
runTests: (testIds: string[]) => void,
2023-03-12 10:42:02 -07:00
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean },
setVisibleTestIds: (testIds: string[]) => void,
2023-03-08 17:33:27 -08:00
onTestSelected: (test: TestCase | undefined) => void,
2023-03-13 22:19:31 -07:00
}> = ({ statusFilters, projectFilters, filterText, rootSuite, runTests, runningState, onTestSelected, setVisibleTestIds }) => {
2023-03-08 17:33:27 -08:00
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
2023-03-07 12:43:16 -08:00
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [watchedTreeIds] = React.useState<Set<string>>(new Set());
2023-03-01 15:27:23 -08:00
const { rootItem, treeItemMap } = React.useMemo(() => {
2023-03-13 22:19:31 -07:00
const rootItem = createTree(rootSuite.value, projectFilters);
filterTree(rootItem, filterText, statusFilters);
hideOnlyTests(rootItem);
const treeItemMap = new Map<string, TreeItem>();
const visibleTestIds = new Set<string>();
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'case')
treeItem.tests.forEach(t => visibleTestIds.add(t.id));
treeItem.children.forEach(visit);
treeItemMap.set(treeItem.id, treeItem);
};
2023-03-08 17:33:27 -08:00
visit(rootItem);
setVisibleTestIds([...visibleTestIds]);
return { rootItem, treeItemMap };
2023-03-13 22:19:31 -07:00
}, [filterText, rootSuite, statusFilters, projectFilters, setVisibleTestIds]);
2023-03-12 10:42:02 -07:00
React.useEffect(() => {
// Look for a first failure within the run batch to select it.
if (!runningState || runningState.itemSelectedByUser)
return;
let selectedTreeItem: TreeItem | undefined;
const visit = (treeItem: TreeItem) => {
2023-03-13 22:19:31 -07:00
treeItem.children.forEach(visit);
2023-03-12 10:42:02 -07:00
if (selectedTreeItem)
return;
if (treeItem.status === 'failed') {
if (treeItem.kind === 'test' && runningState.testIds.has(treeItem.test.id))
selectedTreeItem = treeItem;
else if (treeItem.kind === 'case' && runningState.testIds.has(treeItem.tests[0]?.id))
selectedTreeItem = treeItem;
}
};
visit(rootItem);
if (selectedTreeItem)
setSelectedTreeItemId(selectedTreeItem.id);
}, [runningState, setSelectedTreeItemId, rootItem]);
2023-03-08 17:33:27 -08:00
const { selectedTreeItem } = React.useMemo(() => {
const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined;
2023-03-08 17:33:27 -08:00
let selectedTest: TestCase | undefined;
if (selectedTreeItem?.kind === 'test')
2023-03-08 17:33:27 -08:00
selectedTest = selectedTreeItem.test;
else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1)
selectedTest = selectedTreeItem.tests[0];
onTestSelected(selectedTest);
return { selectedTreeItem };
}, [onTestSelected, selectedTreeItemId, treeItemMap]);
const setWatchedTreeIds = (watchedTreeIds: Set<string>) => {
const fileNames = new Set<string>();
for (const itemId of watchedTreeIds) {
const treeItem = treeItemMap.get(itemId)!;
fileNames.add(fileNameForTreeItem(treeItem)!);
}
sendMessageNoReply('watch', { fileNames: [...fileNames] });
};
const runTreeItem = (treeItem: TreeItem) => {
setSelectedTreeItemId(treeItem.id);
runTests(collectTestIds(treeItem));
};
runWatchedTests = (fileName: string) => {
const testIds: string[] = [];
for (const treeId of watchedTreeIds) {
const treeItem = treeItemMap.get(treeId)!;
if (fileNameForTreeItem(treeItem) === fileName)
testIds.push(...collectTestIds(treeItem));
}
runTests(testIds);
};
return <TestTreeView
treeState={treeState}
setTreeState={setTreeState}
rootItem={rootItem}
dataTestId='test-tree'
render={treeItem => {
return <div className='hbox watch-mode-list-item'>
<div className='watch-mode-list-item-title'>{treeItem.title}</div>
2023-03-12 10:42:02 -07:00
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
<ToolbarButton icon='eye' title='Watch' onClick={() => {
if (watchedTreeIds.has(treeItem.id))
watchedTreeIds.delete(treeItem.id);
else
watchedTreeIds.add(treeItem.id);
setWatchedTreeIds(watchedTreeIds);
}} toggled={watchedTreeIds.has(treeItem.id)}></ToolbarButton>
</div>;
}}
icon={treeItem => {
if (treeItem.status === 'running')
return 'codicon-loading';
if (treeItem.status === 'failed')
return 'codicon-error';
if (treeItem.status === 'passed')
return 'codicon-check';
if (treeItem.status === 'skipped')
return 'codicon-circle-slash';
return 'codicon-circle-outline';
}}
selectedItem={selectedTreeItem}
onAccepted={runTreeItem}
onSelected={treeItem => {
2023-03-12 10:42:02 -07:00
if (runningState)
runningState.itemSelectedByUser = true;
setSelectedTreeItemId(treeItem.id);
}}
autoExpandDeep={!!filterText}
noItemsMessage='No tests' />;
2023-03-07 14:24:50 -08:00
};
2023-03-15 11:17:03 -07:00
const TraceView: React.FC<{
outputDir: string,
testCase: TestCase | undefined,
result: TestResult | undefined,
}> = ({ outputDir, testCase, result }) => {
2023-03-01 15:27:23 -08:00
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
const [counter, setCounter] = React.useState(0);
2023-03-15 11:17:03 -07:00
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
2023-03-01 15:27:23 -08:00
React.useEffect(() => {
2023-03-15 11:17:03 -07:00
if (pollTimer.current)
clearTimeout(pollTimer.current);
if (!result) {
setModel(undefined);
return;
}
// Test finished.
const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace');
if (attachment && attachment.path) {
loadSingleTraceFile(attachment.path).then(model => setModel(model));
2023-03-15 11:17:03 -07:00
return;
}
const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${testCase?.id}.json`;
2023-03-15 11:17:03 -07:00
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
const model = await loadSingleTraceFile(traceLocation);
setModel(model);
} catch {
setModel(undefined);
} finally {
setCounter(counter + 1);
}
2023-03-15 11:17:03 -07:00
}, 250);
return () => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [result, outputDir, testCase, setModel, counter, setCounter]);
return <Workbench key='workbench' model={model} hideTimelineBars={true} hideStackFrames={true} showSourcesFirst={true} />;
2023-03-01 15:27:23 -08:00
};
declare global {
interface Window {
binding(data: any): Promise<void>;
}
}
let receiver: TeleReporterReceiver | undefined;
let throttleTimer: NodeJS.Timeout | undefined;
let throttleData: { rootSuite: Suite, progress: Progress } | undefined;
const throttledAction = () => {
clearTimeout(throttleTimer);
throttleTimer = undefined;
updateRootSuite(throttleData!.rootSuite, throttleData!.progress);
};
const throttleUpdateRootSuite = (rootSuite: Suite, progress: Progress, immediate = false) => {
throttleData = { rootSuite, progress };
if (immediate)
throttledAction();
else if (!throttleTimer)
throttleTimer = setTimeout(throttledAction, 250);
};
2023-03-13 22:19:31 -07:00
const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
if (!eraseResults)
return sendMessage('list', {});
let rootSuite: Suite;
const progress: Progress = {
total: 0,
passed: 0,
failed: 0,
skipped: 0,
};
receiver = new TeleReporterReceiver({
onBegin: (config: FullConfig, suite: Suite) => {
if (!rootSuite)
rootSuite = suite;
progress.total = suite.allTests().length;
progress.passed = 0;
progress.failed = 0;
progress.skipped = 0;
throttleUpdateRootSuite(rootSuite, progress, true);
},
onEnd: () => {
throttleUpdateRootSuite(rootSuite, progress, true);
},
onTestBegin: () => {
throttleUpdateRootSuite(rootSuite, progress);
},
onTestEnd: (test: TestCase) => {
if (test.outcome() === 'skipped')
++progress.skipped;
else if (test.outcome() === 'unexpected')
++progress.failed;
else
++progress.passed;
throttleUpdateRootSuite(rootSuite, progress);
},
});
2023-03-13 22:19:31 -07:00
return sendMessage('list', {});
};
2023-03-01 15:27:23 -08:00
(window as any).dispatch = (message: any) => {
if (message.method === 'listChanged') {
2023-03-13 22:19:31 -07:00
refreshRootSuite(false).catch(() => {});
return;
}
2023-03-07 14:24:50 -08:00
if (message.method === 'fileChanged') {
runWatchedTests(message.params.fileName);
return;
}
if (message.method === 'stdio') {
2023-03-07 14:24:50 -08:00
if (message.params.buffer) {
const data = atob(message.params.buffer);
xtermDataSource.write(data);
} else {
xtermDataSource.write(message.params.text);
}
return;
2023-03-07 14:24:50 -08:00
}
receiver?.dispatch(message);
};
const sendMessage = async (method: string, params: any) => {
await (window as any).sendMessage({ method, params });
2023-03-01 15:27:23 -08:00
};
const sendMessageNoReply = (method: string, params?: any) => {
sendMessage(method, params).catch((e: Error) => {
// eslint-disable-next-line no-console
console.error(e);
2023-03-01 15:27:23 -08:00
});
};
const fileNameForTreeItem = (treeItem?: TreeItem): string | undefined => {
return treeItem?.location.file;
};
const locationToOpen = (treeItem?: TreeItem) => {
if (!treeItem)
return;
return treeItem.location.file + ':' + treeItem.location.line;
};
const collectTestIds = (treeItem?: TreeItem): string[] => {
if (!treeItem)
return [];
const testIds: string[] = [];
const visit = (treeItem: TreeItem) => {
2023-03-08 17:33:27 -08:00
if (treeItem.kind === 'case')
testIds.push(...treeItem.tests.map(t => t.id));
else if (treeItem.kind === 'test')
testIds.push(treeItem.id);
2023-03-08 17:33:27 -08:00
else
treeItem.children?.forEach(visit);
};
visit(treeItem);
return testIds;
};
type Progress = {
total: number;
passed: number;
failed: number;
skipped: number;
};
type TreeItemBase = {
kind: 'root' | 'group' | 'case' | 'test',
id: string;
title: string;
location: Location,
2023-03-12 10:42:02 -07:00
parent: TreeItem | undefined;
2023-03-08 17:33:27 -08:00
children: TreeItem[];
status: 'none' | 'running' | 'passed' | 'failed' | 'skipped';
};
type GroupItem = TreeItemBase & {
kind: 'group',
children: (TestCaseItem | GroupItem)[];
};
type TestCaseItem = TreeItemBase & {
kind: 'case',
2023-03-08 17:33:27 -08:00
tests: TestCase[];
2023-03-10 12:41:00 -08:00
children: TestItem[];
};
type TestItem = TreeItemBase & {
kind: 'test',
test: TestCase;
2023-03-10 12:41:00 -08:00
project: string;
};
type TreeItem = GroupItem | TestCaseItem | TestItem;
2023-03-13 22:19:31 -07:00
function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, boolean>): GroupItem {
const filterProjects = [...projectFilters.values()].some(Boolean);
const rootItem: GroupItem = {
kind: 'group',
2023-03-08 17:33:27 -08:00
id: 'root',
title: '',
location: { file: '', line: 0, column: 0 },
2023-03-12 10:42:02 -07:00
parent: undefined,
2023-03-08 17:33:27 -08:00
children: [],
status: 'none',
2023-03-08 17:33:27 -08:00
};
const visitSuite = (projectName: string, parentSuite: Suite, parentGroup: GroupItem) => {
for (const suite of parentSuite.suites) {
const title = suite.title;
let group = parentGroup.children.find(item => item.title === title) as GroupItem | undefined;
if (!group) {
group = {
kind: 'group',
id: parentGroup.id + '\x1e' + title,
title,
location: suite.location!,
2023-03-12 10:42:02 -07:00
parent: parentGroup,
children: [],
status: 'none',
};
parentGroup.children.push(group);
}
visitSuite(projectName, suite, group);
}
for (const test of parentSuite.tests) {
const title = test.title;
let testCaseItem = parentGroup.children.find(t => t.title === title) as TestCaseItem;
if (!testCaseItem) {
testCaseItem = {
kind: 'case',
id: parentGroup.id + '\x1e' + title,
title,
2023-03-12 10:42:02 -07:00
parent: parentGroup,
2023-03-08 17:33:27 -08:00
children: [],
tests: [],
location: test.location,
status: 'none',
};
parentGroup.children.push(testCaseItem);
}
let status: 'none' | 'running' | 'passed' | 'failed' | 'skipped' = 'none';
if (test.results.some(r => r.duration === -1))
status = 'running';
else if (test.results.length && test.outcome() === 'skipped')
status = 'skipped';
else if (test.results.length && test.outcome() !== 'expected')
status = 'failed';
else if (test.results.length && test.outcome() === 'expected')
status = 'passed';
testCaseItem.tests.push(test);
testCaseItem.children.push({
kind: 'test',
id: test.id,
title: projectName,
location: test.location!,
test,
2023-03-12 10:42:02 -07:00
parent: testCaseItem,
children: [],
status,
2023-03-10 12:41:00 -08:00
project: projectName
});
}
};
for (const projectSuite of rootSuite?.suites || []) {
2023-03-13 22:19:31 -07:00
if (filterProjects && !projectFilters.get(projectSuite.title))
continue;
visitSuite(projectSuite.title, projectSuite, rootItem);
}
const sortAndPropagateStatus = (treeItem: TreeItem) => {
for (const child of treeItem.children)
sortAndPropagateStatus(child);
if (treeItem.kind === 'group' && treeItem.parent)
treeItem.children.sort((a, b) => a.location.line - b.location.line);
let allPassed = treeItem.children.length > 0;
let allSkipped = treeItem.children.length > 0;
let hasFailed = false;
let hasRunning = false;
for (const child of treeItem.children) {
allSkipped = allSkipped && child.status === 'skipped';
allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped');
hasFailed = hasFailed || child.status === 'failed';
hasRunning = hasRunning || child.status === 'running';
}
if (hasRunning)
treeItem.status = 'running';
else if (hasFailed)
treeItem.status = 'failed';
else if (allSkipped)
treeItem.status = 'skipped';
else if (allPassed)
treeItem.status = 'passed';
};
sortAndPropagateStatus(rootItem);
2023-03-08 17:33:27 -08:00
return rootItem;
}
2023-03-13 22:19:31 -07:00
function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<string, boolean>) {
const tokens = filterText.trim().toLowerCase().split(' ');
const filtersStatuses = [...statusFilters.values()].some(Boolean);
2023-03-10 12:41:00 -08:00
const filter = (testCase: TestCaseItem) => {
const title = testCase.tests[0].titlePath().join(' ').toLowerCase();
2023-03-13 22:19:31 -07:00
if (!tokens.every(token => title.includes(token)))
2023-03-10 12:41:00 -08:00
return false;
2023-03-13 22:19:31 -07:00
testCase.children = (testCase.children as TestItem[]).filter(test => !filtersStatuses || statusFilters.get(test.status));
2023-03-10 12:41:00 -08:00
testCase.tests = (testCase.children as TestItem[]).map(c => c.test);
return !!testCase.children.length;
};
const visit = (treeItem: GroupItem) => {
const newChildren: (GroupItem | TestCaseItem)[] = [];
for (const child of treeItem.children) {
if (child.kind === 'case') {
2023-03-10 12:41:00 -08:00
if (filter(child))
newChildren.push(child);
} else {
visit(child);
if (child.children.length)
newChildren.push(child);
}
}
treeItem.children = newChildren;
};
visit(rootItem);
}
function hideOnlyTests(rootItem: GroupItem) {
2023-03-08 17:33:27 -08:00
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'case' && treeItem.children.length === 1)
treeItem.children = [];
else
treeItem.children.forEach(visit);
};
visit(rootItem);
}
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries);
}