diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts
index ae860e9047..8d2f432faa 100644
--- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts
+++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts
@@ -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');
diff --git a/packages/playwright-core/src/utils/debug.ts b/packages/playwright-core/src/utils/debug.ts
index e8a3a44060..2e199b3a31 100644
--- a/packages/playwright-core/src/utils/debug.ts
+++ b/packages/playwright-core/src/utils/debug.ts
@@ -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;
}
diff --git a/packages/playwright-test/src/runner/uiMode.ts b/packages/playwright-test/src/runner/uiMode.ts
index 1c68759c55..a1261c65e7 100644
--- a/packages/playwright-test/src/runner/uiMode.ts
+++ b/packages/playwright-test/src/runner/uiMode.ts
@@ -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();
diff --git a/packages/trace-viewer/src/ui/watchMode.tsx b/packages/trace-viewer/src/ui/watchMode.tsx
index cfef4cc56b..66e5ade1f1 100644
--- a/packages/trace-viewer/src/ui/watchMode.tsx
+++ b/packages/trace-viewer/src/ui/watchMode.tsx
@@ -303,6 +303,7 @@ export const TestList: React.FC<{
treeState={treeState}
setTreeState={setTreeState}
rootItem={rootItem}
+ dataTestId='test-tree'
render={treeItem => {
return
{treeItem.title}
@@ -653,9 +654,12 @@ function createTree(rootSuite: Suite | undefined, projects: Map
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
else if (allPassed)
treeItem.status = 'passed';
};
- propagateStatus(rootItem);
+ sortAndPropagateStatus(rootItem);
return rootItem;
}
diff --git a/packages/web/src/components/listView.css b/packages/web/src/components/listView.css
index 60b789bb78..6dcc35b252 100644
--- a/packages/web/src/components/listView.css
+++ b/packages/web/src/components/listView.css
@@ -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);
diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx
index 035ff44322..6d35fbe581 100644
--- a/packages/web/src/components/listView.tsx
+++ b/packages/web/src/components/listView.tsx
@@ -59,7 +59,7 @@ export function ListView({
onHighlighted?.(highlightedItem);
}, [onHighlighted, highlightedItem]);
- return
+ return
({
const rendered = render(item);
return
onSelected?.(item)}
onMouseEnter={() => setHighlightedItem(item)}
onMouseLeave={() => setHighlightedItem(undefined)}
>
- {indentation ?
: undefined}
+ {indentation ? new Array(indentation).fill(0).map(() =>
) : undefined}
{icon &&
{
e.preventDefault();
diff --git a/packages/web/src/components/treeView.tsx b/packages/web/src/components/treeView.tsx
index b13d8f233a..95f9ddea7b 100644
--- a/packages/web/src/components/treeView.tsx
+++ b/packages/web/src/components/treeView.tsx
@@ -56,6 +56,7 @@ export function TreeView
({
treeState,
setTreeState,
noItemsMessage,
+ dataTestId,
}: TreeViewProps) {
const treeItems = React.useMemo(() => {
for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent)
@@ -66,6 +67,7 @@ export function TreeView({
return item.id}
+ dataTestId={dataTestId}
render={item => {
const rendered = render(item as T);
return <>
diff --git a/tests/config/commonFixtures.ts b/tests/config/commonFixtures.ts
index e2bb1043ae..e55b51b4cd 100644
--- a/tests/config/commonFixtures.ts
+++ b/tests/config/commonFixtures.ts
@@ -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
}
diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts
index af59afc84b..f7272ebeef 100644
--- a/tests/playwright-test/playwright-test-fixtures.ts
+++ b/tests/playwright-test/playwright-test-fixtures.ts
@@ -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 {
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);
},
diff --git a/tests/playwright-test/ui-mode-fixtures.ts b/tests/playwright-test/ui-mode-fixtures.ts
new file mode 100644
index 0000000000..b52ad77f1f
--- /dev/null
+++ b/tests/playwright-test/ui-mode-fixtures.ts
@@ -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;
+};
+
+export function dumpTestTree(page: Page): () => Promise {
+ 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({
+ 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';
diff --git a/tests/playwright-test/ui-mode-test-tree.spec.ts b/tests/playwright-test/ui-mode-test-tree.spec.ts
new file mode 100644
index 0000000000..ed8579fe42
--- /dev/null
+++ b/tests/playwright-test/ui-mode-test-tree.spec.ts
@@ -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 <=
+ `);
+});