chore(ui): decorate pending, add time spent (#21821)

This commit is contained in:
Pavel Feldman 2023-03-20 17:12:02 -07:00 committed by GitHub
parent 2dee3c4fc7
commit a33cf10696
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 122 additions and 24 deletions

View File

@ -26,7 +26,7 @@
flex: none; flex: none;
align-items: center; align-items: center;
margin: 0 4px; margin: 0 4px;
color: var(--gray); color: var(--vscode-editorCodeLens-foreground);
} }
.action-icon { .action-icon {

View File

@ -127,7 +127,7 @@
.timeline-bar.frame_waitforeventinfo, .timeline-bar.frame_waitforeventinfo,
.timeline-bar.page_waitforeventinfo { .timeline-bar.page_waitforeventinfo {
--action-color: var(--gray); --action-color: var(--vscode-editorCodeLens-foreground);
} }
.timeline-label { .timeline-label {

View File

@ -37,7 +37,18 @@
.watch-mode-list-item-title { .watch-mode-list-item-title {
flex: auto; flex: auto;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden overflow: hidden;
}
.watch-mode-list-item-time {
flex: none;
color: var(--vscode-editorCodeLens-foreground);
margin: 0 4px;
user-select: none;
}
.list-view-entry.highlighted .watch-mode-list-item-time {
display: none;
} }
.watch-mode .section-title { .watch-mode .section-title {
@ -56,7 +67,7 @@
.watch-mode-sidebar img { .watch-mode-sidebar img {
flex: none; flex: none;
margin: 0 4px; margin-left: 4px;
width: 24px; width: 24px;
height: 24px; height: 24px;
} }

View File

@ -34,7 +34,7 @@ import { XtermWrapper } from '@web/components/xtermWrapper';
import { Expandable } from '@web/components/expandable'; import { Expandable } from '@web/components/expandable';
import { toggleTheme } from '@web/theme'; import { toggleTheme } from '@web/theme';
import { artifactsFolderName } from '@testIsomorphic/folders'; import { artifactsFolderName } from '@testIsomorphic/folders';
import { settings, useSetting } from '@web/uiUtils'; import { msToString, settings, useSetting } from '@web/uiUtils';
let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress | undefined) => void = () => {}; let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress | undefined) => void = () => {};
let runWatchedTests = (fileNames: string[]) => {}; let runWatchedTests = (fileNames: string[]) => {};
@ -73,12 +73,14 @@ export const WatchModeView: React.FC<{}> = ({
const [isLoading, setIsLoading] = React.useState<boolean>(false); const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>(); const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>();
const [watchAll, setWatchAll] = useSetting<boolean>('watch-all', false); const [watchAll, setWatchAll] = useSetting<boolean>('watch-all', false);
const [watchedTreeIds, setWatchedTreeIds] = React.useState<{ value: Set<string> }>({ value: new Set() });
const runTestPromiseChain = React.useRef(Promise.resolve()); const runTestPromiseChain = React.useRef(Promise.resolve());
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const reloadTests = () => { const reloadTests = () => {
setIsLoading(true); setIsLoading(true);
setWatchedTreeIds({ value: new Set() });
updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), undefined); updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), undefined);
refreshRootSuite(true).then(() => { refreshRootSuite(true).then(() => {
setIsLoading(false); setIsLoading(false);
@ -165,7 +167,6 @@ export const WatchModeView: React.FC<{}> = ({
<div className='section-title'>Playwright</div> <div className='section-title'>Playwright</div>
<ToolbarButton icon='color-mode' title='Toggle color mode' onClick={() => toggleTheme()} /> <ToolbarButton icon='color-mode' title='Toggle color mode' onClick={() => toggleTheme()} />
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton> <ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='eye' title='Watch all' toggled={watchAll} onClick={() => setWatchAll(!watchAll)}></ToolbarButton>
<ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} /> <ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
</Toolbar> </Toolbar>
<FiltersView <FiltersView
@ -187,6 +188,7 @@ export const WatchModeView: React.FC<{}> = ({
</div>} </div>}
<ToolbarButton icon='play' title='Run all' onClick={() => runTests('bounce-if-busy', visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton> <ToolbarButton icon='play' title='Run all' onClick={() => runTests('bounce-if-busy', visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest || isLoading}></ToolbarButton> <ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='eye' title='Watch all' toggled={watchAll} onClick={() => setWatchAll(!watchAll)}></ToolbarButton>
</Toolbar> </Toolbar>
<TestList <TestList
statusFilters={statusFilters} statusFilters={statusFilters}
@ -198,6 +200,8 @@ export const WatchModeView: React.FC<{}> = ({
onItemSelected={setSelectedItem} onItemSelected={setSelectedItem}
setVisibleTestIds={setVisibleTestIds} setVisibleTestIds={setVisibleTestIds}
watchAll={watchAll} watchAll={watchAll}
watchedTreeIds={watchedTreeIds}
setWatchedTreeIds={setWatchedTreeIds}
isLoading={isLoading} /> isLoading={isLoading} />
</div> </div>
</SplitView> </SplitView>
@ -281,19 +285,20 @@ const TestList: React.FC<{
testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined }, testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined },
runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set<string>) => void, runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set<string>) => void,
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean }, runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean },
watchAll?: boolean, watchAll: boolean,
watchedTreeIds: { value: Set<string> },
setWatchedTreeIds: (ids: { value: Set<string> }) => void,
isLoading?: boolean, isLoading?: boolean,
setVisibleTestIds: (testIds: Set<string>) => void, setVisibleTestIds: (testIds: Set<string>) => void,
onItemSelected: (item: { testCase?: TestCase, location?: Location }) => void, onItemSelected: (item: { testCase?: TestCase, location?: Location }) => void,
}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, isLoading, onItemSelected, setVisibleTestIds }) => { }> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() }); const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>(); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [watchedTreeIds, setWatchedTreeIds] = React.useState<{ value: Set<string> }>({ value: new Set() });
// Build the test tree. // Build the test tree.
const { rootItem, treeItemMap, fileNames } = React.useMemo(() => { const { rootItem, treeItemMap, fileNames } = React.useMemo(() => {
const rootItem = createTree(testModel.rootSuite, projectFilters); const rootItem = createTree(testModel.rootSuite, projectFilters);
filterTree(rootItem, filterText, statusFilters); filterTree(rootItem, filterText, statusFilters, runningState?.testIds);
hideOnlyTests(rootItem); hideOnlyTests(rootItem);
const treeItemMap = new Map<string, TreeItem>(); const treeItemMap = new Map<string, TreeItem>();
const visibleTestIds = new Set<string>(); const visibleTestIds = new Set<string>();
@ -309,7 +314,7 @@ const TestList: React.FC<{
visit(rootItem); visit(rootItem);
setVisibleTestIds(visibleTestIds); setVisibleTestIds(visibleTestIds);
return { rootItem, treeItemMap, fileNames }; return { rootItem, treeItemMap, fileNames };
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds]); }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]);
// Look for a first failure within the run batch to select it. // Look for a first failure within the run batch to select it.
React.useEffect(() => { React.useEffect(() => {
@ -398,6 +403,7 @@ const TestList: React.FC<{
render={treeItem => { render={treeItem => {
return <div className='hbox watch-mode-list-item'> return <div className='hbox watch-mode-list-item'>
<div className='watch-mode-list-item-title'>{treeItem.title}</div> <div className='watch-mode-list-item-title'>{treeItem.title}</div>
{!!treeItem.duration && <div className='watch-mode-list-item-time'>{msToString(treeItem.duration)}</div>}
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton> <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='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
{!watchAll && <ToolbarButton icon='eye' title='Watch' onClick={() => { {!watchAll && <ToolbarButton icon='eye' title='Watch' onClick={() => {
@ -410,6 +416,8 @@ const TestList: React.FC<{
</div>; </div>;
}} }}
icon={treeItem => { icon={treeItem => {
if (treeItem.status === 'scheduled')
return 'codicon-clock';
if (treeItem.status === 'running') if (treeItem.status === 'running')
return 'codicon-loading'; return 'codicon-loading';
if (treeItem.status === 'failed') if (treeItem.status === 'failed')
@ -638,9 +646,10 @@ type TreeItemBase = {
id: string; id: string;
title: string; title: string;
location: Location, location: Location,
duration: number;
parent: TreeItem | undefined; parent: TreeItem | undefined;
children: TreeItem[]; children: TreeItem[];
status: 'none' | 'running' | 'passed' | 'failed' | 'skipped'; status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped';
}; };
type GroupItem = TreeItemBase & { type GroupItem = TreeItemBase & {
@ -677,6 +686,7 @@ function getFileItem(rootItem: GroupItem, filePath: string[], isFile: boolean, f
id: fileName, id: fileName,
title: filePath[filePath.length - 1], title: filePath[filePath.length - 1],
location: { file: fileName, line: 0, column: 0 }, location: { file: fileName, line: 0, column: 0 },
duration: 0,
parent: parentFileItem, parent: parentFileItem,
children: [], children: [],
status: 'none', status: 'none',
@ -694,6 +704,7 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
id: 'root', id: 'root',
title: '', title: '',
location: { file: '', line: 0, column: 0 }, location: { file: '', line: 0, column: 0 },
duration: 0,
parent: undefined, parent: undefined,
children: [], children: [],
status: 'none', status: 'none',
@ -710,6 +721,7 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
id: parentGroup.id + '\x1e' + title, id: parentGroup.id + '\x1e' + title,
title, title,
location: suite.location!, location: suite.location!,
duration: 0,
parent: parentGroup, parent: parentGroup,
children: [], children: [],
status: 'none', status: 'none',
@ -731,13 +743,16 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
children: [], children: [],
tests: [], tests: [],
location: test.location, location: test.location,
duration: 0,
status: 'none', status: 'none',
}; };
parentGroup.children.push(testCaseItem); parentGroup.children.push(testCaseItem);
} }
let status: 'none' | 'running' | 'passed' | 'failed' | 'skipped' = 'none'; let status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped' = 'none';
if (test.results.some(r => r.duration === -1)) if (test.results.some(r => r.workerIndex === -1))
status = 'scheduled';
else if (test.results.some(r => r.duration === -1))
status = 'running'; status = 'running';
else if (test.results.length && test.results[0].status === 'skipped') else if (test.results.length && test.results[0].status === 'skipped')
status = 'skipped'; status = 'skipped';
@ -758,8 +773,10 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
parent: testCaseItem, parent: testCaseItem,
children: [], children: [],
status, status,
project: projectName duration: test.results.length ? Math.max(0, test.results[0].duration) : 0,
project: projectName,
}); });
testCaseItem.duration = (testCaseItem.children as TestItem[]).reduce((a, b) => a + b.duration, 0);
} }
}; };
@ -788,16 +805,20 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
let allSkipped = treeItem.children.length > 0; let allSkipped = treeItem.children.length > 0;
let hasFailed = false; let hasFailed = false;
let hasRunning = false; let hasRunning = false;
let hasScheduled = false;
for (const child of treeItem.children) { for (const child of treeItem.children) {
allSkipped = allSkipped && child.status === 'skipped'; allSkipped = allSkipped && child.status === 'skipped';
allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped'); allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped');
hasFailed = hasFailed || child.status === 'failed'; hasFailed = hasFailed || child.status === 'failed';
hasRunning = hasRunning || child.status === 'running'; hasRunning = hasRunning || child.status === 'running';
hasScheduled = hasScheduled || child.status === 'scheduled';
} }
if (hasRunning) if (hasRunning)
treeItem.status = 'running'; treeItem.status = 'running';
else if (hasScheduled)
treeItem.status = 'scheduled';
else if (hasFailed) else if (hasFailed)
treeItem.status = 'failed'; treeItem.status = 'failed';
else if (allSkipped) else if (allSkipped)
@ -814,15 +835,17 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
return shortRoot; return shortRoot;
} }
function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<string, boolean>) { function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<string, boolean>, runningTestIds: Set<string> | undefined) {
const tokens = filterText.trim().toLowerCase().split(' '); const tokens = filterText.trim().toLowerCase().split(' ');
const filtersStatuses = [...statusFilters.values()].some(Boolean); const filtersStatuses = [...statusFilters.values()].some(Boolean);
const filter = (testCase: TestCaseItem) => { const filter = (testCase: TestCaseItem) => {
const title = testCase.tests[0].titlePath().join(' ').toLowerCase(); const title = testCase.tests[0].titlePath().join(' ').toLowerCase();
if (!tokens.every(token => title.includes(token))) if (!tokens.every(token => title.includes(token)) && !testCase.tests.some(t => runningTestIds?.has(t.id)))
return false; return false;
testCase.children = (testCase.children as TestItem[]).filter(test => !filtersStatuses || statusFilters.get(test.status)); testCase.children = (testCase.children as TestItem[]).filter(test => {
return !filtersStatuses || runningTestIds?.has(test.id) || statusFilters.get(test.status);
});
testCase.tests = (testCase.children as TestItem[]).map(c => c.test); testCase.tests = (testCase.children as TestItem[]).map(c => c.test);
return !!testCase.children.length; return !!testCase.children.length;
}; };

View File

@ -122,8 +122,11 @@ body.dark-mode .CodeMirror span.cm-type {
border-right: none; border-right: none;
} }
.CodeMirror .CodeMirror-gutter-elt {
background-color: var(--vscode-editorGutter-background);
}
.CodeMirror .CodeMirror-gutterwrapper { .CodeMirror .CodeMirror-gutterwrapper {
background: var(--vscode-editor-background);
border-right: 1px solid var(--vscode-editorGroup-border); border-right: 1px solid var(--vscode-editorGroup-border);
color: var(--vscode-editorLineNumber-foreground); color: var(--vscode-editorLineNumber-foreground);
} }

View File

@ -26,6 +26,7 @@ import { createGuid } from '../../packages/playwright-core/src/utils/crypto';
type Latch = { type Latch = {
blockingCode: string; blockingCode: string;
open: () => void; open: () => void;
close: () => void;
}; };
type Fixtures = { type Fixtures = {
@ -33,8 +34,8 @@ type Fixtures = {
createLatch: () => Latch; createLatch: () => Latch;
}; };
export function dumpTestTree(page: Page): () => Promise<string> { export function dumpTestTree(page: Page, options: { time?: boolean } = {}): () => Promise<string> {
return () => page.getByTestId('test-tree').evaluate(async treeElement => { return () => page.getByTestId('test-tree').evaluate(async (treeElement, options) => {
function iconName(iconElement: Element): string { function iconName(iconElement: Element): string {
const icon = iconElement.className.replace('codicon codicon-', ''); const icon = iconElement.className.replace('codicon codicon-', '');
if (icon === 'chevron-right') if (icon === 'chevron-right')
@ -55,6 +56,8 @@ export function dumpTestTree(page: Page): () => Promise<string> {
return '👁'; return '👁';
if (icon === 'loading') if (icon === 'loading')
return '↻'; return '↻';
if (icon === 'clock')
return '🕦';
return icon; return icon;
} }
@ -67,10 +70,13 @@ export function dumpTestTree(page: Page): () => Promise<string> {
const indent = listItem.querySelectorAll('.list-view-indent').length; const indent = listItem.querySelectorAll('.list-view-indent').length;
const watch = listItem.querySelector('.toolbar-button.eye.toggled') ? ' 👁' : ''; const watch = listItem.querySelector('.toolbar-button.eye.toggled') ? ' 👁' : '';
const selected = listItem.classList.contains('selected') ? ' <=' : ''; const selected = listItem.classList.contains('selected') ? ' <=' : '';
result.push(' ' + ' '.repeat(indent) + treeIcon + ' ' + statusIcon + ' ' + listItem.textContent + watch + selected); const title = listItem.querySelector('.watch-mode-list-item-title').textContent;
const timeElement = options.time ? listItem.querySelector('.watch-mode-list-item-time') : undefined;
const time = timeElement ? ' ' + timeElement.textContent.replace(/\d+m?s/, 'XXms') : '';
result.push(' ' + ' '.repeat(indent) + treeIcon + ' ' + statusIcon + ' ' + title + time + watch + selected);
} }
return '\n' + result.join('\n') + '\n '; return '\n' + result.join('\n') + '\n ';
}); }, options);
} }
export const test = base export const test = base
@ -112,6 +118,7 @@ export const test = base
return { return {
blockingCode: `await ((${waitForLatch})(${JSON.stringify(latchFile)}))`, blockingCode: `await ((${waitForLatch})(${JSON.stringify(latchFile)}))`,
open: () => fs.writeFileSync(latchFile, 'ok'), open: () => fs.writeFileSync(latchFile, 'ok'),
close: () => fs.unlinkSync(latchFile),
}; };
}); });
}, },

View File

@ -148,3 +148,33 @@ test('should filter by project', async ({ runUITest }) => {
await expect(page.getByText('Projects: foo bar')).toBeVisible(); await expect(page.getByText('Projects: foo bar')).toBeVisible();
}); });
test('should not hide filtered while running', async ({ runUITest, createLatch }) => {
const latch = createLatch();
const page = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
test('fails', async () => {
${latch.blockingCode}
expect(1).toBe(2);
});
`,
});
await page.getByTitle('Run all').click();
latch.open();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails <=
`);
latch.close();
await page.getByText('Status:').click();
await page.getByLabel('failed').setChecked(true);
await page.getByTitle('Run all').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
fails <=
`);
});

View File

@ -238,7 +238,7 @@ test('should stop', async ({ runUITest }) => {
test 0 test 0
test 1 test 1
test 2 test 2
test 3 🕦 test 3
`); `);
await expect(page.getByTitle('Run all')).toBeDisabled(); await expect(page.getByTitle('Run all')).toBeDisabled();
@ -282,3 +282,27 @@ test('should run folder', async ({ runUITest }) => {
passes passes
`); `);
}); });
test('should show time', async ({ runUITest }) => {
const page = await runUITest(basicTestTree);
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
`);
await page.getByTitle('Run all').click();
await expect.poll(dumpTestTree(page, { time: true }), { timeout: 15000 }).toBe(`
a.test.ts
passes XXms
fails XXms <=
suite
b.test.ts
passes XXms
fails XXms
c.test.ts
passes XXms
skipped
`);
await expect(page.getByTestId('status-line')).toHaveText('4/8 passed (50%)');
});