chore(ui): start adding ui mode tests (#21601)
This commit is contained in:
		
							parent
							
								
									493171cb6b
								
							
						
					
					
						commit
						a12e909a40
					
				|  | @ -63,8 +63,6 @@ export async function showTraceViewer(traceUrls: string[], browserName: string, | |||
|     '--window-size=1280,800', | ||||
|     '--test-type=', | ||||
|   ] : []; | ||||
|   if (isUnderTest()) | ||||
|     args.push(`--remote-debugging-port=0`); | ||||
| 
 | ||||
|   const context = await traceViewerPlaywright[traceViewerBrowser as 'chromium'].launchPersistentContext(serverSideCallMetadata(), '', { | ||||
|     // TODO: store language in the trace.
 | ||||
|  | @ -74,7 +72,7 @@ export async function showTraceViewer(traceUrls: string[], browserName: string, | |||
|     ignoreDefaultArgs: ['--enable-automation'], | ||||
|     headless, | ||||
|     colorScheme: 'no-override', | ||||
|     useWebSocket: isUnderTest() | ||||
|     useWebSocket: isUnderTest(), | ||||
|   }); | ||||
| 
 | ||||
|   const controller = new ProgressController(serverSideCallMetadata(), context._browser); | ||||
|  | @ -84,6 +82,9 @@ export async function showTraceViewer(traceUrls: string[], browserName: string, | |||
|   await context.extendInjectedScript(consoleApiSource.source); | ||||
|   const [page] = context.pages(); | ||||
| 
 | ||||
|   if (isUnderTest()) | ||||
|     process.stderr.write('DevTools listening on: ' + context._browser.options.wsEndpoint + '\n'); | ||||
| 
 | ||||
|   if (traceViewerBrowser === 'chromium') | ||||
|     await installAppIcon(page); | ||||
|   await syncLocalStorageWithSettings(page, 'traceviewer'); | ||||
|  |  | |||
|  | @ -35,7 +35,7 @@ export function debugMode() { | |||
|   return debugEnv ? 'inspector' : ''; | ||||
| } | ||||
| 
 | ||||
| let _isUnderTest = false; | ||||
| let _isUnderTest = !!process.env.PWTEST_UNDER_TEST; | ||||
| export function setUnderTest() { | ||||
|   _isUnderTest = true; | ||||
| } | ||||
|  |  | |||
|  | @ -16,7 +16,7 @@ | |||
| 
 | ||||
| import { showTraceViewer } from 'playwright-core/lib/server'; | ||||
| import type { Page } from 'playwright-core/lib/server/page'; | ||||
| import { ManualPromise } from 'playwright-core/lib/utils'; | ||||
| import { isUnderTest, ManualPromise } from 'playwright-core/lib/utils'; | ||||
| import type { FullResult } from '../../reporter'; | ||||
| import { clearCompilationCache, dependenciesForTestFile } from '../common/compilationCache'; | ||||
| import type { FullConfigInternal } from '../common/types'; | ||||
|  | @ -53,15 +53,6 @@ class UIMode { | |||
|     config._internal.configCLIOverrides.use.trace = { mode: 'on', sources: false }; | ||||
| 
 | ||||
|     this._originalStderr = process.stderr.write.bind(process.stderr); | ||||
|     process.stdout.write = (chunk: string | Buffer) => { | ||||
|       this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) }); | ||||
|       return true; | ||||
|     }; | ||||
|     process.stderr.write = (chunk: string | Buffer) => { | ||||
|       this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) }); | ||||
|       return true; | ||||
|     }; | ||||
| 
 | ||||
|     this._installGlobalWatcher(); | ||||
|   } | ||||
| 
 | ||||
|  | @ -101,7 +92,15 @@ class UIMode { | |||
|   } | ||||
| 
 | ||||
|   async showUI() { | ||||
|     this._page = await showTraceViewer([], 'chromium', { app: 'watch.html' }); | ||||
|     this._page = await showTraceViewer([], 'chromium', { app: 'watch.html', headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1' }); | ||||
|     process.stdout.write = (chunk: string | Buffer) => { | ||||
|       this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) }); | ||||
|       return true; | ||||
|     }; | ||||
|     process.stderr.write = (chunk: string | Buffer) => { | ||||
|       this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) }); | ||||
|       return true; | ||||
|     }; | ||||
|     const exitPromise = new ManualPromise(); | ||||
|     this._page.on('close', () => exitPromise.resolve()); | ||||
|     let queue = Promise.resolve(); | ||||
|  |  | |||
|  | @ -303,6 +303,7 @@ export const TestList: React.FC<{ | |||
|     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> | ||||
|  | @ -653,9 +654,12 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean> | |||
|     visitSuite(projectSuite.title, projectSuite, rootItem); | ||||
|   } | ||||
| 
 | ||||
|   const propagateStatus = (treeItem: TreeItem) => { | ||||
|   const sortAndPropagateStatus = (treeItem: TreeItem) => { | ||||
|     for (const child of treeItem.children) | ||||
|       propagateStatus(child); | ||||
|       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; | ||||
|  | @ -678,7 +682,7 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean> | |||
|     else if (allPassed) | ||||
|       treeItem.status = 'passed'; | ||||
|   }; | ||||
|   propagateStatus(rootItem); | ||||
|   sortAndPropagateStatus(rootItem); | ||||
|   return rootItem; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -42,6 +42,10 @@ | |||
|   z-index: 10; | ||||
| } | ||||
| 
 | ||||
| .list-view-indent { | ||||
|   min-width: 16px; | ||||
| } | ||||
| 
 | ||||
| .list-view-content:focus .list-view-entry.selected { | ||||
|   background-color: var(--vscode-list-activeSelectionBackground); | ||||
|   color: var(--vscode-list-activeSelectionForeground); | ||||
|  |  | |||
|  | @ -59,7 +59,7 @@ export function ListView<T>({ | |||
|     onHighlighted?.(highlightedItem); | ||||
|   }, [onHighlighted, highlightedItem]); | ||||
| 
 | ||||
|   return <div className='list-view vbox' data-testid={dataTestId}> | ||||
|   return <div className='list-view vbox' role='list' data-testid={dataTestId}> | ||||
|     <div | ||||
|       className='list-view-content' | ||||
|       tabIndex={0} | ||||
|  | @ -115,14 +115,15 @@ export function ListView<T>({ | |||
|         const rendered = render(item); | ||||
|         return <div | ||||
|           key={id?.(item) || index} | ||||
|           role='listitem' | ||||
|           className={'list-view-entry' + selectedSuffix + highlightedSuffix + errorSuffix} | ||||
|           onClick={() => onSelected?.(item)} | ||||
|           onMouseEnter={() => setHighlightedItem(item)} | ||||
|           onMouseLeave={() => setHighlightedItem(undefined)} | ||||
|         > | ||||
|           {indentation ? <div style={{ minWidth: indentation * 16 }}></div> : undefined} | ||||
|           {indentation ? new Array(indentation).fill(0).map(() => <div className='list-view-indent'></div>) : undefined} | ||||
|           {icon && <div | ||||
|             className={'codicon ' + (icon(item) || 'blank')} | ||||
|             className={'codicon ' + (icon(item) || 'codicon-blank')} | ||||
|             style={{ minWidth: 16, marginRight: 4 }} | ||||
|             onDoubleClick={e => { | ||||
|               e.preventDefault(); | ||||
|  |  | |||
|  | @ -56,6 +56,7 @@ export function TreeView<T extends TreeItem>({ | |||
|   treeState, | ||||
|   setTreeState, | ||||
|   noItemsMessage, | ||||
|   dataTestId, | ||||
| }: TreeViewProps<T>) { | ||||
|   const treeItems = React.useMemo(() => { | ||||
|     for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent) | ||||
|  | @ -66,6 +67,7 @@ export function TreeView<T extends TreeItem>({ | |||
|   return <TreeListView | ||||
|     items={[...treeItems.keys()]} | ||||
|     id={item => item.id} | ||||
|     dataTestId={dataTestId} | ||||
|     render={item => { | ||||
|       const rendered = render(item as T); | ||||
|       return <> | ||||
|  |  | |||
|  | @ -83,18 +83,24 @@ export class TestChildProcess { | |||
| 
 | ||||
|   async close() { | ||||
|     if (!this.process.killed) | ||||
|       this._killProcessGroup(); | ||||
|       this._killProcessGroup('SIGINT'); | ||||
|     return this.exited; | ||||
|   } | ||||
| 
 | ||||
|   private _killProcessGroup() { | ||||
|   async kill() { | ||||
|     if (!this.process.killed) | ||||
|       this._killProcessGroup('SIGKILL'); | ||||
|     return this.exited; | ||||
|   } | ||||
| 
 | ||||
|   private _killProcessGroup(signal: 'SIGINT' | 'SIGKILL') { | ||||
|     if (!this.process.pid || this.process.killed) | ||||
|       return; | ||||
|     try { | ||||
|       if (process.platform === 'win32') | ||||
|         execSync(`taskkill /pid ${this.process.pid} /T /F /FI "MEMUSAGE gt 0"`, { stdio: 'ignore' }); | ||||
|       else | ||||
|         process.kill(-this.process.pid, 'SIGKILL'); | ||||
|         process.kill(-this.process.pid, signal); | ||||
|     } catch (e) { | ||||
|       // the process might have already stopped
 | ||||
|     } | ||||
|  |  | |||
|  | @ -28,7 +28,7 @@ import type { TestInfo } from './stable-test-runner'; | |||
| import { expect } from './stable-test-runner'; | ||||
| import { test as base } from './stable-test-runner'; | ||||
| 
 | ||||
| const removeFolderAsync = promisify(rimraf); | ||||
| export const removeFolderAsync = promisify(rimraf); | ||||
| 
 | ||||
| export type CliRunResult = { | ||||
|   exitCode: number, | ||||
|  | @ -54,10 +54,10 @@ type TSCResult = { | |||
|   exitCode: number; | ||||
| }; | ||||
| 
 | ||||
| type Files = { [key: string]: string | Buffer }; | ||||
| export type Files = { [key: string]: string | Buffer }; | ||||
| type Params = { [key: string]: string | number | boolean | string[] }; | ||||
| 
 | ||||
| async function writeFiles(testInfo: TestInfo, files: Files, initial: boolean) { | ||||
| export async function writeFiles(testInfo: TestInfo, files: Files, initial: boolean) { | ||||
|   const baseDir = testInfo.outputPath(); | ||||
| 
 | ||||
|   if (initial && !Object.keys(files).some(name => name.includes('package.json'))) { | ||||
|  | @ -76,7 +76,7 @@ async function writeFiles(testInfo: TestInfo, files: Files, initial: boolean) { | |||
|   return baseDir; | ||||
| } | ||||
| 
 | ||||
| const cliEntrypoint = path.join(__dirname, '../../packages/playwright-core/cli.js'); | ||||
| export const cliEntrypoint = path.join(__dirname, '../../packages/playwright-core/cli.js'); | ||||
| 
 | ||||
| async function runPlaywrightTest(childProcess: CommonFixtures['childProcess'], baseDir: string, params: any, env: NodeJS.ProcessEnv, options: RunOptions): Promise<RunResult> { | ||||
|   const paramList: string[] = []; | ||||
|  | @ -190,7 +190,7 @@ async function runPlaywrightCommand(childProcess: CommonFixtures['childProcess'] | |||
|   return { exitCode, output: testProcess.output.toString() }; | ||||
| } | ||||
| 
 | ||||
| function cleanEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { | ||||
| export function cleanEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { | ||||
|   return { | ||||
|     ...process.env, | ||||
|     // BEGIN: Reserved CI
 | ||||
|  | @ -216,7 +216,7 @@ function cleanEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { | |||
|   }; | ||||
| } | ||||
| 
 | ||||
| type RunOptions = { | ||||
| export type RunOptions = { | ||||
|   sendSIGINTAfter?: number; | ||||
|   additionalArgs?: string[]; | ||||
|   cwd?: string, | ||||
|  | @ -254,7 +254,7 @@ export const test = base | |||
|           testProcess = watchPlaywrightTest(childProcess, baseDir, { ...env, PWTEST_CACHE_DIR: cacheDir }, options); | ||||
|           return testProcess; | ||||
|         }); | ||||
|         await testProcess?.close(); | ||||
|         await testProcess?.kill(); | ||||
|         await removeFolderAsync(cacheDir); | ||||
|       }, | ||||
| 
 | ||||
|  |  | |||
|  | @ -0,0 +1,94 @@ | |||
| /** | ||||
|  * 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 * as fs from 'fs'; | ||||
| import * as os from 'os'; | ||||
| import * as path from 'path'; | ||||
| import type { TestChildProcess } from '../config/commonFixtures'; | ||||
| import { cleanEnv, cliEntrypoint, removeFolderAsync, test as base, writeFiles } from './playwright-test-fixtures'; | ||||
| import type { Files, RunOptions } from './playwright-test-fixtures'; | ||||
| import type { Browser, Page, TestInfo } from './stable-test-runner'; | ||||
| 
 | ||||
| type Fixtures = { | ||||
|   runUITest: (files: Files, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise<Page>; | ||||
| }; | ||||
| 
 | ||||
| export function dumpTestTree(page: Page): () => Promise<string> { | ||||
|   return () => page.getByTestId('test-tree').evaluate(async treeElement => { | ||||
|     function iconName(iconElement: Element): string { | ||||
|       const icon = iconElement.className.replace('codicon codicon-', ''); | ||||
|       if (icon === 'chevron-right') | ||||
|         return '►'; | ||||
|       if (icon === 'chevron-down') | ||||
|         return '▼'; | ||||
|       if (icon === 'blank') | ||||
|         return ' '; | ||||
|       if (icon === 'circle-outline') | ||||
|         return '◯'; | ||||
|       if (icon === 'check') | ||||
|         return '✅'; | ||||
|       if (icon === 'error') | ||||
|         return '❌'; | ||||
|       return icon; | ||||
|     } | ||||
| 
 | ||||
|     const result: string[] = []; | ||||
|     const listItems = treeElement.querySelectorAll('[role=listitem]'); | ||||
|     for (const listItem of listItems) { | ||||
|       const iconElements = listItem.querySelectorAll('.codicon'); | ||||
|       const treeIcon = iconName(iconElements[0]); | ||||
|       const statusIcon = iconName(iconElements[1]); | ||||
|       const indent = listItem.querySelectorAll('.list-view-indent').length; | ||||
|       const selected = listItem.classList.contains('selected') ? ' <=' : ''; | ||||
|       result.push('    ' + '  '.repeat(indent) + treeIcon + ' ' + statusIcon + ' ' + listItem.textContent + selected); | ||||
|     } | ||||
|     return '\n' + result.join('\n') + '\n  '; | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export const test = base | ||||
|     .extend<Fixtures>({ | ||||
|       runUITest: async ({ childProcess, playwright, headless }, use, testInfo: TestInfo) => { | ||||
|         const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-')); | ||||
|         let testProcess: TestChildProcess | undefined; | ||||
|         let browser: Browser | undefined; | ||||
|         await use(async (files: Files, env: NodeJS.ProcessEnv = {}, options: RunOptions = {}) => { | ||||
|           const baseDir = await writeFiles(testInfo, files, true); | ||||
|           testProcess = childProcess({ | ||||
|             command: ['node', cliEntrypoint, 'ui', ...(options.additionalArgs || [])], | ||||
|             env: { | ||||
|               ...cleanEnv(env), | ||||
|               PWTEST_UNDER_TEST: '1', | ||||
|               PWTEST_CACHE_DIR: cacheDir, | ||||
|               PWTEST_HEADED_FOR_TEST: headless ? '0' : '1', | ||||
|             }, | ||||
|             cwd: options.cwd ? path.resolve(baseDir, options.cwd) : baseDir, | ||||
|           }); | ||||
|           await testProcess.waitForOutput('DevTools listening on'); | ||||
|           const line = testProcess.output.split('\n').find(l => l.includes('DevTools listening on')); | ||||
|           const wsEndpoint = line!.split(' ')[3]; | ||||
|           browser = await playwright.chromium.connectOverCDP(wsEndpoint); | ||||
|           const [context] = browser.contexts(); | ||||
|           const [page] = context.pages(); | ||||
|           return page; | ||||
|         }); | ||||
|         await browser?.close(); | ||||
|         await testProcess?.close(); | ||||
|         await removeFolderAsync(cacheDir); | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
| export { expect } from './stable-test-runner'; | ||||
|  | @ -0,0 +1,119 @@ | |||
| /** | ||||
|  * 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 { test, expect, dumpTestTree } from './ui-mode-fixtures'; | ||||
| 
 | ||||
| test.describe.configure({ mode: 'parallel' }); | ||||
| 
 | ||||
| const basicTestTree = { | ||||
|   'a.test.ts': ` | ||||
|     import { test, expect } from '@playwright/test'; | ||||
|     test('passes', () => {}); | ||||
|     test('fails', () => {}); | ||||
|     test.describe('suite', () => { | ||||
|       test('inner passes', () => {}); | ||||
|       test('inner fails', () => {}); | ||||
|     }); | ||||
|   `,
 | ||||
|   'b.test.ts': ` | ||||
|     import { test, expect } from '@playwright/test'; | ||||
|     test('passes', () => {}); | ||||
|     test('fails', () => {}); | ||||
|   `,
 | ||||
| }; | ||||
| 
 | ||||
| test('should list tests', async ({ runUITest }) => { | ||||
|   const page = await runUITest(basicTestTree); | ||||
|   await expect.poll(dumpTestTree(page)).toBe(` | ||||
|     ▼ ◯ a.test.ts | ||||
|         ◯ passes | ||||
|         ◯ fails | ||||
|       ► ◯ suite | ||||
|     ▼ ◯ b.test.ts | ||||
|         ◯ passes | ||||
|         ◯ fails | ||||
|   `);
 | ||||
| }); | ||||
| 
 | ||||
| test('should traverse up/down', async ({ runUITest }) => { | ||||
|   const page = await runUITest(basicTestTree); | ||||
|   await page.getByText('a.test.ts').click(); | ||||
|   await expect.poll(dumpTestTree(page)).toContain(` | ||||
|     ▼ ◯ a.test.ts <= | ||||
|         ◯ passes | ||||
|         ◯ fails | ||||
|       ► ◯ suite | ||||
|   `);
 | ||||
| 
 | ||||
|   await page.keyboard.press('ArrowDown'); | ||||
|   await expect.poll(dumpTestTree(page)).toContain(` | ||||
|     ▼ ◯ a.test.ts | ||||
|         ◯ passes <= | ||||
|         ◯ fails | ||||
|       ► ◯ suite | ||||
|   `);
 | ||||
|   await page.keyboard.press('ArrowDown'); | ||||
|   await expect.poll(dumpTestTree(page)).toContain(` | ||||
|     ▼ ◯ a.test.ts | ||||
|         ◯ passes | ||||
|         ◯ fails <= | ||||
|       ► ◯ suite | ||||
|   `);
 | ||||
| 
 | ||||
|   await page.keyboard.press('ArrowUp'); | ||||
|   await expect.poll(dumpTestTree(page)).toContain(` | ||||
|     ▼ ◯ a.test.ts | ||||
|         ◯ passes <= | ||||
|         ◯ fails | ||||
|       ► ◯ suite | ||||
|   `);
 | ||||
| }); | ||||
| 
 | ||||
| test('should expand / collapse groups', async ({ runUITest }) => { | ||||
|   const page = await runUITest(basicTestTree); | ||||
| 
 | ||||
|   await page.getByText('suite').click(); | ||||
|   await page.keyboard.press('ArrowRight'); | ||||
|   await expect.poll(dumpTestTree(page)).toContain(` | ||||
|     ▼ ◯ a.test.ts | ||||
|         ◯ passes | ||||
|         ◯ fails | ||||
|       ▼ ◯ suite <= | ||||
|           ◯ inner passes | ||||
|           ◯ inner fails | ||||
|   `);
 | ||||
| 
 | ||||
|   await page.keyboard.press('ArrowLeft'); | ||||
|   await expect.poll(dumpTestTree(page)).toContain(` | ||||
|     ▼ ◯ a.test.ts | ||||
|         ◯ passes | ||||
|         ◯ fails | ||||
|       ► ◯ suite <= | ||||
|   `);
 | ||||
| 
 | ||||
|   await page.getByText('passes').first().click(); | ||||
|   await page.keyboard.press('ArrowLeft'); | ||||
|   await expect.poll(dumpTestTree(page)).toContain(` | ||||
|     ▼ ◯ a.test.ts <= | ||||
|         ◯ passes | ||||
|         ◯ fails | ||||
|   `);
 | ||||
| 
 | ||||
|   await page.keyboard.press('ArrowLeft'); | ||||
|   await expect.poll(dumpTestTree(page)).toContain(` | ||||
|     ► ◯ a.test.ts <= | ||||
|   `);
 | ||||
| }); | ||||
		Loading…
	
		Reference in New Issue