chore(ui): start adding ui mode tests (#21601)

This commit is contained in:
Pavel Feldman 2023-03-12 15:18:47 -07:00 committed by GitHub
parent 493171cb6b
commit a12e909a40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 261 additions and 31 deletions

View File

@ -63,8 +63,6 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
'--window-size=1280,800', '--window-size=1280,800',
'--test-type=', '--test-type=',
] : []; ] : [];
if (isUnderTest())
args.push(`--remote-debugging-port=0`);
const context = await traceViewerPlaywright[traceViewerBrowser as 'chromium'].launchPersistentContext(serverSideCallMetadata(), '', { const context = await traceViewerPlaywright[traceViewerBrowser as 'chromium'].launchPersistentContext(serverSideCallMetadata(), '', {
// TODO: store language in the trace. // TODO: store language in the trace.
@ -74,7 +72,7 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
ignoreDefaultArgs: ['--enable-automation'], ignoreDefaultArgs: ['--enable-automation'],
headless, headless,
colorScheme: 'no-override', colorScheme: 'no-override',
useWebSocket: isUnderTest() useWebSocket: isUnderTest(),
}); });
const controller = new ProgressController(serverSideCallMetadata(), context._browser); const controller = new ProgressController(serverSideCallMetadata(), context._browser);
@ -84,6 +82,9 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
await context.extendInjectedScript(consoleApiSource.source); await context.extendInjectedScript(consoleApiSource.source);
const [page] = context.pages(); const [page] = context.pages();
if (isUnderTest())
process.stderr.write('DevTools listening on: ' + context._browser.options.wsEndpoint + '\n');
if (traceViewerBrowser === 'chromium') if (traceViewerBrowser === 'chromium')
await installAppIcon(page); await installAppIcon(page);
await syncLocalStorageWithSettings(page, 'traceviewer'); await syncLocalStorageWithSettings(page, 'traceviewer');

View File

@ -35,7 +35,7 @@ export function debugMode() {
return debugEnv ? 'inspector' : ''; return debugEnv ? 'inspector' : '';
} }
let _isUnderTest = false; let _isUnderTest = !!process.env.PWTEST_UNDER_TEST;
export function setUnderTest() { export function setUnderTest() {
_isUnderTest = true; _isUnderTest = true;
} }

View File

@ -16,7 +16,7 @@
import { showTraceViewer } from 'playwright-core/lib/server'; import { showTraceViewer } from 'playwright-core/lib/server';
import type { Page } from 'playwright-core/lib/server/page'; 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 type { FullResult } from '../../reporter';
import { clearCompilationCache, dependenciesForTestFile } from '../common/compilationCache'; import { clearCompilationCache, dependenciesForTestFile } from '../common/compilationCache';
import type { FullConfigInternal } from '../common/types'; import type { FullConfigInternal } from '../common/types';
@ -53,15 +53,6 @@ class UIMode {
config._internal.configCLIOverrides.use.trace = { mode: 'on', sources: false }; config._internal.configCLIOverrides.use.trace = { mode: 'on', sources: false };
this._originalStderr = process.stderr.write.bind(process.stderr); 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(); this._installGlobalWatcher();
} }
@ -101,7 +92,15 @@ class UIMode {
} }
async showUI() { 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(); const exitPromise = new ManualPromise();
this._page.on('close', () => exitPromise.resolve()); this._page.on('close', () => exitPromise.resolve());
let queue = Promise.resolve(); let queue = Promise.resolve();

View File

@ -303,6 +303,7 @@ export const TestList: React.FC<{
treeState={treeState} treeState={treeState}
setTreeState={setTreeState} setTreeState={setTreeState}
rootItem={rootItem} rootItem={rootItem}
dataTestId='test-tree'
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>
@ -653,9 +654,12 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>
visitSuite(projectSuite.title, projectSuite, rootItem); visitSuite(projectSuite.title, projectSuite, rootItem);
} }
const propagateStatus = (treeItem: TreeItem) => { const sortAndPropagateStatus = (treeItem: TreeItem) => {
for (const child of treeItem.children) 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 allPassed = treeItem.children.length > 0;
let allSkipped = 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) else if (allPassed)
treeItem.status = 'passed'; treeItem.status = 'passed';
}; };
propagateStatus(rootItem); sortAndPropagateStatus(rootItem);
return rootItem; return rootItem;
} }

View File

@ -42,6 +42,10 @@
z-index: 10; z-index: 10;
} }
.list-view-indent {
min-width: 16px;
}
.list-view-content:focus .list-view-entry.selected { .list-view-content:focus .list-view-entry.selected {
background-color: var(--vscode-list-activeSelectionBackground); background-color: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground); color: var(--vscode-list-activeSelectionForeground);

View File

@ -59,7 +59,7 @@ export function ListView<T>({
onHighlighted?.(highlightedItem); onHighlighted?.(highlightedItem);
}, [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 <div
className='list-view-content' className='list-view-content'
tabIndex={0} tabIndex={0}
@ -115,14 +115,15 @@ export function ListView<T>({
const rendered = render(item); const rendered = render(item);
return <div return <div
key={id?.(item) || index} key={id?.(item) || index}
role='listitem'
className={'list-view-entry' + selectedSuffix + highlightedSuffix + errorSuffix} className={'list-view-entry' + selectedSuffix + highlightedSuffix + errorSuffix}
onClick={() => onSelected?.(item)} onClick={() => onSelected?.(item)}
onMouseEnter={() => setHighlightedItem(item)} onMouseEnter={() => setHighlightedItem(item)}
onMouseLeave={() => setHighlightedItem(undefined)} 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 {icon && <div
className={'codicon ' + (icon(item) || 'blank')} className={'codicon ' + (icon(item) || 'codicon-blank')}
style={{ minWidth: 16, marginRight: 4 }} style={{ minWidth: 16, marginRight: 4 }}
onDoubleClick={e => { onDoubleClick={e => {
e.preventDefault(); e.preventDefault();

View File

@ -56,6 +56,7 @@ export function TreeView<T extends TreeItem>({
treeState, treeState,
setTreeState, setTreeState,
noItemsMessage, noItemsMessage,
dataTestId,
}: TreeViewProps<T>) { }: TreeViewProps<T>) {
const treeItems = React.useMemo(() => { const treeItems = React.useMemo(() => {
for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent) for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent)
@ -66,6 +67,7 @@ export function TreeView<T extends TreeItem>({
return <TreeListView return <TreeListView
items={[...treeItems.keys()]} items={[...treeItems.keys()]}
id={item => item.id} id={item => item.id}
dataTestId={dataTestId}
render={item => { render={item => {
const rendered = render(item as T); const rendered = render(item as T);
return <> return <>

View File

@ -83,18 +83,24 @@ export class TestChildProcess {
async close() { async close() {
if (!this.process.killed) if (!this.process.killed)
this._killProcessGroup(); this._killProcessGroup('SIGINT');
return this.exited; 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) if (!this.process.pid || this.process.killed)
return; return;
try { try {
if (process.platform === 'win32') if (process.platform === 'win32')
execSync(`taskkill /pid ${this.process.pid} /T /F /FI "MEMUSAGE gt 0"`, { stdio: 'ignore' }); execSync(`taskkill /pid ${this.process.pid} /T /F /FI "MEMUSAGE gt 0"`, { stdio: 'ignore' });
else else
process.kill(-this.process.pid, 'SIGKILL'); process.kill(-this.process.pid, signal);
} catch (e) { } catch (e) {
// the process might have already stopped // the process might have already stopped
} }

View File

@ -28,7 +28,7 @@ import type { TestInfo } from './stable-test-runner';
import { expect } from './stable-test-runner'; import { expect } from './stable-test-runner';
import { test as base } from './stable-test-runner'; import { test as base } from './stable-test-runner';
const removeFolderAsync = promisify(rimraf); export const removeFolderAsync = promisify(rimraf);
export type CliRunResult = { export type CliRunResult = {
exitCode: number, exitCode: number,
@ -54,10 +54,10 @@ type TSCResult = {
exitCode: number; exitCode: number;
}; };
type Files = { [key: string]: string | Buffer }; export type Files = { [key: string]: string | Buffer };
type Params = { [key: string]: string | number | boolean | string[] }; 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(); const baseDir = testInfo.outputPath();
if (initial && !Object.keys(files).some(name => name.includes('package.json'))) { 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; 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> { async function runPlaywrightTest(childProcess: CommonFixtures['childProcess'], baseDir: string, params: any, env: NodeJS.ProcessEnv, options: RunOptions): Promise<RunResult> {
const paramList: string[] = []; const paramList: string[] = [];
@ -190,7 +190,7 @@ async function runPlaywrightCommand(childProcess: CommonFixtures['childProcess']
return { exitCode, output: testProcess.output.toString() }; return { exitCode, output: testProcess.output.toString() };
} }
function cleanEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { export function cleanEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return { return {
...process.env, ...process.env,
// BEGIN: Reserved CI // BEGIN: Reserved CI
@ -216,7 +216,7 @@ function cleanEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
}; };
} }
type RunOptions = { export type RunOptions = {
sendSIGINTAfter?: number; sendSIGINTAfter?: number;
additionalArgs?: string[]; additionalArgs?: string[];
cwd?: string, cwd?: string,
@ -254,7 +254,7 @@ export const test = base
testProcess = watchPlaywrightTest(childProcess, baseDir, { ...env, PWTEST_CACHE_DIR: cacheDir }, options); testProcess = watchPlaywrightTest(childProcess, baseDir, { ...env, PWTEST_CACHE_DIR: cacheDir }, options);
return testProcess; return testProcess;
}); });
await testProcess?.close(); await testProcess?.kill();
await removeFolderAsync(cacheDir); await removeFolderAsync(cacheDir);
}, },

View File

@ -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';

View File

@ -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 <=
`);
});