mirror of
				https://github.com/microsoft/playwright.git
				synced 2025-06-26 21:40:17 +00:00 
			
		
		
		
	chore(ui): decorate pending, add time spent (#21821)
This commit is contained in:
		
							parent
							
								
									2dee3c4fc7
								
							
						
					
					
						commit
						a33cf10696
					
				| @ -26,7 +26,7 @@ | ||||
|   flex: none; | ||||
|   align-items: center; | ||||
|   margin: 0 4px; | ||||
|   color: var(--gray); | ||||
|   color: var(--vscode-editorCodeLens-foreground); | ||||
| } | ||||
| 
 | ||||
| .action-icon { | ||||
|  | ||||
| @ -127,7 +127,7 @@ | ||||
| 
 | ||||
| .timeline-bar.frame_waitforeventinfo, | ||||
| .timeline-bar.page_waitforeventinfo { | ||||
|   --action-color: var(--gray); | ||||
|   --action-color: var(--vscode-editorCodeLens-foreground); | ||||
| } | ||||
| 
 | ||||
| .timeline-label { | ||||
|  | ||||
| @ -37,7 +37,18 @@ | ||||
| .watch-mode-list-item-title { | ||||
|   flex: auto; | ||||
|   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 { | ||||
| @ -56,7 +67,7 @@ | ||||
| 
 | ||||
| .watch-mode-sidebar img { | ||||
|   flex: none; | ||||
|   margin: 0 4px; | ||||
|   margin-left: 4px; | ||||
|   width: 24px; | ||||
|   height: 24px; | ||||
| } | ||||
|  | ||||
| @ -34,7 +34,7 @@ import { XtermWrapper } from '@web/components/xtermWrapper'; | ||||
| import { Expandable } from '@web/components/expandable'; | ||||
| import { toggleTheme } from '@web/theme'; | ||||
| 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 runWatchedTests = (fileNames: string[]) => {}; | ||||
| @ -73,12 +73,14 @@ export const WatchModeView: React.FC<{}> = ({ | ||||
|   const [isLoading, setIsLoading] = React.useState<boolean>(false); | ||||
|   const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>(); | ||||
|   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 inputRef = React.useRef<HTMLInputElement>(null); | ||||
| 
 | ||||
|   const reloadTests = () => { | ||||
|     setIsLoading(true); | ||||
|     setWatchedTreeIds({ value: new Set() }); | ||||
|     updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), undefined); | ||||
|     refreshRootSuite(true).then(() => { | ||||
|       setIsLoading(false); | ||||
| @ -165,7 +167,6 @@ export const WatchModeView: React.FC<{}> = ({ | ||||
|           <div className='section-title'>Playwright</div> | ||||
|           <ToolbarButton icon='color-mode' title='Toggle color mode' onClick={() => toggleTheme()} /> | ||||
|           <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); }} /> | ||||
|         </Toolbar> | ||||
|         <FiltersView | ||||
| @ -187,6 +188,7 @@ export const WatchModeView: React.FC<{}> = ({ | ||||
|           </div>} | ||||
|           <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='eye' title='Watch all' toggled={watchAll} onClick={() => setWatchAll(!watchAll)}></ToolbarButton> | ||||
|         </Toolbar> | ||||
|         <TestList | ||||
|           statusFilters={statusFilters} | ||||
| @ -198,6 +200,8 @@ export const WatchModeView: React.FC<{}> = ({ | ||||
|           onItemSelected={setSelectedItem} | ||||
|           setVisibleTestIds={setVisibleTestIds} | ||||
|           watchAll={watchAll} | ||||
|           watchedTreeIds={watchedTreeIds} | ||||
|           setWatchedTreeIds={setWatchedTreeIds} | ||||
|           isLoading={isLoading} /> | ||||
|       </div> | ||||
|     </SplitView> | ||||
| @ -281,19 +285,20 @@ const TestList: React.FC<{ | ||||
|   testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined }, | ||||
|   runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set<string>) => void, | ||||
|   runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean }, | ||||
|   watchAll?: boolean, | ||||
|   watchAll: boolean, | ||||
|   watchedTreeIds: { value: Set<string> }, | ||||
|   setWatchedTreeIds: (ids: { value: Set<string> }) => void, | ||||
|   isLoading?: boolean, | ||||
|   setVisibleTestIds: (testIds: Set<string>) => 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 [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>(); | ||||
|   const [watchedTreeIds, setWatchedTreeIds] = React.useState<{ value: Set<string> }>({ value: new Set() }); | ||||
| 
 | ||||
|   // Build the test tree.
 | ||||
|   const { rootItem, treeItemMap, fileNames } = React.useMemo(() => { | ||||
|     const rootItem = createTree(testModel.rootSuite, projectFilters); | ||||
|     filterTree(rootItem, filterText, statusFilters); | ||||
|     filterTree(rootItem, filterText, statusFilters, runningState?.testIds); | ||||
|     hideOnlyTests(rootItem); | ||||
|     const treeItemMap = new Map<string, TreeItem>(); | ||||
|     const visibleTestIds = new Set<string>(); | ||||
| @ -309,7 +314,7 @@ const TestList: React.FC<{ | ||||
|     visit(rootItem); | ||||
|     setVisibleTestIds(visibleTestIds); | ||||
|     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.
 | ||||
|   React.useEffect(() => { | ||||
| @ -398,6 +403,7 @@ const TestList: React.FC<{ | ||||
|     render={treeItem => { | ||||
|       return <div className='hbox watch-mode-list-item'> | ||||
|         <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='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton> | ||||
|         {!watchAll && <ToolbarButton icon='eye' title='Watch' onClick={() => { | ||||
| @ -410,6 +416,8 @@ const TestList: React.FC<{ | ||||
|       </div>; | ||||
|     }} | ||||
|     icon={treeItem => { | ||||
|       if (treeItem.status === 'scheduled') | ||||
|         return 'codicon-clock'; | ||||
|       if (treeItem.status === 'running') | ||||
|         return 'codicon-loading'; | ||||
|       if (treeItem.status === 'failed') | ||||
| @ -638,9 +646,10 @@ type TreeItemBase = { | ||||
|   id: string; | ||||
|   title: string; | ||||
|   location: Location, | ||||
|   duration: number; | ||||
|   parent: TreeItem | undefined; | ||||
|   children: TreeItem[]; | ||||
|   status: 'none' | 'running' | 'passed' | 'failed' | 'skipped'; | ||||
|   status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped'; | ||||
| }; | ||||
| 
 | ||||
| type GroupItem = TreeItemBase & { | ||||
| @ -677,6 +686,7 @@ function getFileItem(rootItem: GroupItem, filePath: string[], isFile: boolean, f | ||||
|     id: fileName, | ||||
|     title: filePath[filePath.length - 1], | ||||
|     location: { file: fileName, line: 0, column: 0 }, | ||||
|     duration: 0, | ||||
|     parent: parentFileItem, | ||||
|     children: [], | ||||
|     status: 'none', | ||||
| @ -694,6 +704,7 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo | ||||
|     id: 'root', | ||||
|     title: '', | ||||
|     location: { file: '', line: 0, column: 0 }, | ||||
|     duration: 0, | ||||
|     parent: undefined, | ||||
|     children: [], | ||||
|     status: 'none', | ||||
| @ -710,6 +721,7 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo | ||||
|           id: parentGroup.id + '\x1e' + title, | ||||
|           title, | ||||
|           location: suite.location!, | ||||
|           duration: 0, | ||||
|           parent: parentGroup, | ||||
|           children: [], | ||||
|           status: 'none', | ||||
| @ -731,13 +743,16 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo | ||||
|           children: [], | ||||
|           tests: [], | ||||
|           location: test.location, | ||||
|           duration: 0, | ||||
|           status: 'none', | ||||
|         }; | ||||
|         parentGroup.children.push(testCaseItem); | ||||
|       } | ||||
| 
 | ||||
|       let status: 'none' | 'running' | 'passed' | 'failed' | 'skipped' = 'none'; | ||||
|       if (test.results.some(r => r.duration === -1)) | ||||
|       let status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped' = 'none'; | ||||
|       if (test.results.some(r => r.workerIndex === -1)) | ||||
|         status = 'scheduled'; | ||||
|       else if (test.results.some(r => r.duration === -1)) | ||||
|         status = 'running'; | ||||
|       else if (test.results.length && test.results[0].status === 'skipped') | ||||
|         status = 'skipped'; | ||||
| @ -758,8 +773,10 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo | ||||
|         parent: testCaseItem, | ||||
|         children: [], | ||||
|         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 hasFailed = false; | ||||
|     let hasRunning = false; | ||||
|     let hasScheduled = 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'; | ||||
|       hasScheduled = hasScheduled || child.status === 'scheduled'; | ||||
|     } | ||||
| 
 | ||||
|     if (hasRunning) | ||||
|       treeItem.status = 'running'; | ||||
|     else if (hasScheduled) | ||||
|       treeItem.status = 'scheduled'; | ||||
|     else if (hasFailed) | ||||
|       treeItem.status = 'failed'; | ||||
|     else if (allSkipped) | ||||
| @ -814,15 +835,17 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo | ||||
|   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 filtersStatuses = [...statusFilters.values()].some(Boolean); | ||||
| 
 | ||||
|   const filter = (testCase: TestCaseItem) => { | ||||
|     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; | ||||
|     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); | ||||
|     return !!testCase.children.length; | ||||
|   }; | ||||
|  | ||||
| @ -122,8 +122,11 @@ body.dark-mode .CodeMirror span.cm-type { | ||||
|   border-right: none; | ||||
| } | ||||
| 
 | ||||
| .CodeMirror .CodeMirror-gutter-elt { | ||||
|   background-color: var(--vscode-editorGutter-background); | ||||
| } | ||||
| 
 | ||||
| .CodeMirror .CodeMirror-gutterwrapper { | ||||
|   background: var(--vscode-editor-background); | ||||
|   border-right: 1px solid var(--vscode-editorGroup-border); | ||||
|   color: var(--vscode-editorLineNumber-foreground); | ||||
| } | ||||
|  | ||||
| @ -26,6 +26,7 @@ import { createGuid } from '../../packages/playwright-core/src/utils/crypto'; | ||||
| type Latch = { | ||||
|   blockingCode: string; | ||||
|   open: () => void; | ||||
|   close: () => void; | ||||
| }; | ||||
| 
 | ||||
| type Fixtures = { | ||||
| @ -33,8 +34,8 @@ type Fixtures = { | ||||
|   createLatch: () => Latch; | ||||
| }; | ||||
| 
 | ||||
| export function dumpTestTree(page: Page): () => Promise<string> { | ||||
|   return () => page.getByTestId('test-tree').evaluate(async treeElement => { | ||||
| export function dumpTestTree(page: Page, options: { time?: boolean } = {}): () => Promise<string> { | ||||
|   return () => page.getByTestId('test-tree').evaluate(async (treeElement, options) => { | ||||
|     function iconName(iconElement: Element): string { | ||||
|       const icon = iconElement.className.replace('codicon codicon-', ''); | ||||
|       if (icon === 'chevron-right') | ||||
| @ -55,6 +56,8 @@ export function dumpTestTree(page: Page): () => Promise<string> { | ||||
|         return '👁'; | ||||
|       if (icon === 'loading') | ||||
|         return '↻'; | ||||
|       if (icon === 'clock') | ||||
|         return '🕦'; | ||||
|       return icon; | ||||
|     } | ||||
| 
 | ||||
| @ -67,10 +70,13 @@ export function dumpTestTree(page: Page): () => Promise<string> { | ||||
|       const indent = listItem.querySelectorAll('.list-view-indent').length; | ||||
|       const watch = listItem.querySelector('.toolbar-button.eye.toggled') ? ' 👁' : ''; | ||||
|       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  '; | ||||
|   }); | ||||
|   }, options); | ||||
| } | ||||
| 
 | ||||
| export const test = base | ||||
| @ -112,6 +118,7 @@ export const test = base | ||||
|           return { | ||||
|             blockingCode: `await ((${waitForLatch})(${JSON.stringify(latchFile)}))`, | ||||
|             open: () => fs.writeFileSync(latchFile, 'ok'), | ||||
|             close: () => fs.unlinkSync(latchFile), | ||||
|           }; | ||||
|         }); | ||||
|       }, | ||||
|  | ||||
| @ -148,3 +148,33 @@ test('should filter by project', async ({ runUITest }) => { | ||||
| 
 | ||||
|   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 <= | ||||
|   `);
 | ||||
| }); | ||||
|  | ||||
| @ -238,7 +238,7 @@ test('should stop', async ({ runUITest }) => { | ||||
|         ⊘ test 0 | ||||
|         ✅ test 1 | ||||
|         ↻ test 2 | ||||
|         ↻ test 3 | ||||
|         🕦 test 3 | ||||
|   `);
 | ||||
| 
 | ||||
|   await expect(page.getByTitle('Run all')).toBeDisabled(); | ||||
| @ -282,3 +282,27 @@ test('should run folder', async ({ runUITest }) => { | ||||
|         ◯ 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%)'); | ||||
| }); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Pavel Feldman
						Pavel Feldman