diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 5a85732807..0d2398835c 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -18,15 +18,30 @@ import path from 'path'; import fs from 'fs'; import { HttpServer } from '../../../utils/httpServer'; import { findChromiumChannel } from '../../registry'; -import { gracefullyCloseAll, isUnderTest } from '../../../utils'; +import { createGuid, gracefullyCloseAll, isUnderTest } from '../../../utils'; import { installAppIcon, syncLocalStorageWithSettings } from '../../chromium/crApp'; import { serverSideCallMetadata } from '../../instrumentation'; import { createPlaywright } from '../../playwright'; import { ProgressController } from '../../progress'; -import { open } from 'playwright-core/lib/utilsBundle'; +import { open, wsServer } from 'playwright-core/lib/utilsBundle'; import type { Page } from '../../page'; -type Options = { app?: string, headless?: boolean, host?: string, port?: number, isServer?: boolean, openInBrowser?: boolean }; +export type Transport = { + sendEvent?: (method: string, params: any) => void; + dispatch: (method: string, params: any) => Promise; + close?: () => void; + onclose: () => void; +}; + +type Options = { + app?: string; + headless?: boolean; + host?: string; + port?: number; + isServer?: boolean; + openInBrowser?: boolean; + transport?: Transport; +}; export async function showTraceViewer(traceUrls: string[], browserName: string, options?: Options): Promise { if (options?.openInBrowser) { @@ -79,6 +94,25 @@ async function startTraceViewerServer(traceUrls: string[], options?: Options): P }); const params = traceUrls.map(t => `trace=${t}`); + + if (options?.transport) { + const transport = options?.transport; + const guid = createGuid(); + params.push('ws=' + guid); + const wss = new wsServer({ server: server.server(), path: '/' + guid }); + wss.on('connection', ws => { + transport.sendEvent = (method, params) => ws.send(JSON.stringify({ method, params })); + transport.close = () => ws.close(); + ws.on('message', async (message: string) => { + const { id, method, params } = JSON.parse(message); + const result = await transport.dispatch(method, params); + ws.send(JSON.stringify({ id, result })); + }); + ws.on('close', () => transport.onclose()); + ws.on('error', () => transport.onclose()); + }); + } + if (options?.isServer) params.push('isServer'); if (isUnderTest()) diff --git a/packages/playwright-test/src/cli.ts b/packages/playwright-test/src/cli.ts index bde82be103..38914c17b9 100644 --- a/packages/playwright-test/src/cli.ts +++ b/packages/playwright-test/src/cli.ts @@ -139,8 +139,8 @@ async function runTests(args: string[], opts: { [key: string]: any }) { const runner = new Runner(config); let status: FullResult['status']; - if (opts.ui) - status = await runner.uiAllTests(); + if (opts.ui || opts.uiWeb) + status = await runner.uiAllTests(!!opts.uiWeb); else if (process.env.PWTEST_WATCH) status = await runner.watchAllTests(); else @@ -328,6 +328,7 @@ const testOptions: [string, string][] = [ ['--timeout ', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`], ['--trace ', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`], ['--ui', `Run tests in interactive UI mode`], + ['--ui-web', `Open interactive UI mode in a browser tab`], ['-u, --update-snapshots', `Update snapshots with actual results (default: only create missing snapshots)`], ['-j, --workers ', `Number of concurrent workers or percentage of logical CPU cores, use 1 to run in a single worker (default: 50%)`], ['-x', `Stop after the first failure`], diff --git a/packages/playwright-test/src/runner/runner.ts b/packages/playwright-test/src/runner/runner.ts index c2fc15e7fd..15caf4d59f 100644 --- a/packages/playwright-test/src/runner/runner.ts +++ b/packages/playwright-test/src/runner/runner.ts @@ -107,9 +107,9 @@ export class Runner { return await runWatchModeLoop(config); } - async uiAllTests(): Promise { + async uiAllTests(openInBrowser: boolean): Promise { const config = this._config; webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); - return await runUIMode(config); + return await runUIMode(config, openInBrowser); } } diff --git a/packages/playwright-test/src/runner/uiMode.ts b/packages/playwright-test/src/runner/uiMode.ts index 0dd8681051..d034ec4e8c 100644 --- a/packages/playwright-test/src/runner/uiMode.ts +++ b/packages/playwright-test/src/runner/uiMode.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { openTraceViewerApp } from 'playwright-core/lib/server'; -import type { Page } from 'playwright-core/lib/server/page'; +import { showTraceViewer } from 'playwright-core/lib/server'; import { isUnderTest, ManualPromise } from 'playwright-core/lib/utils'; import type { FullResult } from '../../reporter'; import { clearCompilationCache, collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache'; @@ -28,10 +27,11 @@ import { chokidar } from '../utilsBundle'; import type { FSWatcher } from 'chokidar'; import { open } from 'playwright-core/lib/utilsBundle'; import ListReporter from '../reporters/list'; +import type { Transport } from 'playwright-core/lib/server/trace/viewer/traceViewer'; class UIMode { private _config: FullConfigInternal; - private _page!: Page; + private _transport!: Transport; private _testRun: { run: Promise, stop: ManualPromise } | undefined; globalCleanup: (() => Promise) | undefined; private _globalWatcher: Watcher; @@ -58,11 +58,11 @@ class UIMode { this._originalStdoutWrite = process.stdout.write; this._originalStderrWrite = process.stderr.write; - this._globalWatcher = new Watcher('deep', () => this._dispatchEvent({ method: 'listChanged' })); + this._globalWatcher = new Watcher('deep', () => this._dispatchEvent('listChanged', {})); this._testWatcher = new Watcher('flat', events => { const collector = new Set(); events.forEach(f => collectAffectedTestFiles(f.file, collector)); - this._dispatchEvent({ method: 'testFilesChanged', params: { testFileNames: [...collector] } }); + this._dispatchEvent('testFilesChanged', { testFileNames: [...collector] }); }); } @@ -81,49 +81,58 @@ class UIMode { return status; } - async showUI() { - this._page = await openTraceViewerApp([], 'chromium', { app: 'uiMode.html', headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1' }); + async showUI(openInBrowser: boolean) { + const exitPromise = new ManualPromise(); + let queue = Promise.resolve(); + + this._transport = { + dispatch: async (method, params) => { + if (method === 'exit') { + exitPromise.resolve(); + return; + } + if (method === 'watch') { + this._watchFiles(params.fileNames); + return; + } + if (method === 'open' && params.location) { + open('vscode://file/' + params.location).catch(e => this._originalStderrWrite.call(process.stderr, String(e))); + return; + } + if (method === 'resizeTerminal') { + process.stdout.columns = params.cols; + process.stdout.rows = params.rows; + process.stderr.columns = params.cols; + process.stderr.columns = params.rows; + return; + } + if (method === 'stop') { + void this._stopTests(); + return; + } + queue = queue.then(() => this._queueListOrRun(method, params)); + await queue; + }, + + onclose: () => exitPromise.resolve(), + }; + await showTraceViewer([], 'chromium', { + app: 'uiMode.html', + headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1', + transport: this._transport, + openInBrowser, + }); + if (!process.env.PWTEST_DEBUG) { process.stdout.write = (chunk: string | Buffer) => { - this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stdout', chunk) }); + this._dispatchEvent('stdio', chunkToPayload('stdout', chunk)); return true; }; process.stderr.write = (chunk: string | Buffer) => { - this._dispatchEvent({ method: 'stdio', params: chunkToPayload('stderr', chunk) }); + this._dispatchEvent('stdio', chunkToPayload('stderr', chunk)); return true; }; } - const exitPromise = new ManualPromise(); - this._page.on('close', () => exitPromise.resolve()); - let queue = Promise.resolve(); - await this._page.exposeBinding('sendMessage', false, async (source, data) => { - const { method, params }: { method: string, params: any } = data; - if (method === 'exit') { - exitPromise.resolve(); - return; - } - if (method === 'watch') { - this._watchFiles(params.fileNames); - return; - } - if (method === 'open' && params.location) { - open('vscode://file/' + params.location).catch(e => this._originalStderrWrite.call(process.stderr, String(e))); - return; - } - if (method === 'resizeTerminal') { - process.stdout.columns = params.cols; - process.stdout.rows = params.rows; - process.stderr.columns = params.cols; - process.stderr.columns = params.rows; - return; - } - if (method === 'stop') { - void this._stopTests(); - return; - } - queue = queue.then(() => this._queueListOrRun(method, params)); - await queue; - }); await exitPromise; if (!process.env.PWTEST_DEBUG) { @@ -139,13 +148,12 @@ class UIMode { await this._runTests(params.testIds); } - private _dispatchEvent(message: any) { - // eslint-disable-next-line no-console - this._page.mainFrame().evaluateExpression(dispatchFuncSource, { isFunction: true }, message).catch(e => this._originalStderrWrite.call(process.stderr, String(e))); + private _dispatchEvent(method: string, params?: any) { + this._transport.sendEvent?.(method, params); } private async _listTests() { - const listReporter = new TeleReporterEmitter(e => this._dispatchEvent(e)); + const listReporter = new TeleReporterEmitter(e => this._dispatchEvent(e.method, e.params)); const reporter = new InternalReporter([listReporter]); this._config.cliListOnly = true; this._config.testIdMatcher = undefined; @@ -170,7 +178,7 @@ class UIMode { this._config.testIdMatcher = id => !testIdSet || testIdSet.has(id); const reporters = await createReporters(this._config, 'ui'); - reporters.push(new TeleReporterEmitter(e => this._dispatchEvent(e))); + reporters.push(new TeleReporterEmitter(e => this._dispatchEvent(e.method, e.params))); const reporter = new InternalReporter(reporters); const taskRunner = createTaskRunnerForWatch(this._config, reporter); const testRun = new TestRun(this._config, reporter); @@ -202,16 +210,12 @@ class UIMode { } } -const dispatchFuncSource = String((message: any) => { - (window as any).dispatch(message); -}); - -export async function runUIMode(config: FullConfigInternal): Promise { +export async function runUIMode(config: FullConfigInternal, openInBrowser: boolean): Promise { const uiMode = new UIMode(config); const status = await uiMode.runGlobalSetup(); if (status !== 'passed') return status; - await uiMode.showUI(); + await uiMode.showUI(openInBrowser); return await uiMode.globalCleanup?.() || 'passed'; } diff --git a/packages/trace-viewer/src/ui/uiModeView.css b/packages/trace-viewer/src/ui/uiModeView.css index bba942a22e..e635654428 100644 --- a/packages/trace-viewer/src/ui/uiModeView.css +++ b/packages/trace-viewer/src/ui/uiModeView.css @@ -73,6 +73,16 @@ height: 24px; } +.ui-mode .disconnected { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + background-color: rgba(0, 0, 0, 0.5); +} + .status-line { flex: auto; white-space: nowrap; @@ -157,4 +167,4 @@ .filter-entry label div { overflow: hidden; text-overflow: ellipsis; -} \ No newline at end of file +} diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 141c960d02..d302df1b95 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -80,6 +80,7 @@ export const UIModeView: React.FC<{}> = ({ const runTestPromiseChain = React.useRef(Promise.resolve()); const runTestBacklog = React.useRef>(new Set()); const [collapseAllCount, setCollapseAllCount] = React.useState(0); + const [isDisconnected, setIsDisconnected] = React.useState(false); const inputRef = React.useRef(null); @@ -94,7 +95,7 @@ export const UIModeView: React.FC<{}> = ({ React.useEffect(() => { inputRef.current?.focus(); - reloadTests(); + initWebSocket(() => setIsDisconnected(true)).then(() => reloadTests()); }, [reloadTests]); updateRootSuite = React.useCallback((config: FullConfig, rootSuite: Suite, loadErrors: TestError[], newProgress: Progress | undefined) => { @@ -159,6 +160,9 @@ export const UIModeView: React.FC<{}> = ({ const isRunningTest = !!runningState; return
+ {isDisconnected &&
+
Process disconnected
+
}
@@ -399,6 +403,8 @@ const TestList: React.FC<{ // Update watch all. React.useEffect(() => { + if (!testModel.rootSuite) + return; if (watchAll) { sendMessageNoReply('watch', { fileNames: [...fileNames] }); } else { @@ -411,7 +417,7 @@ const TestList: React.FC<{ } sendMessageNoReply('watch', { fileNames: [...fileNames] }); } - }, [rootItem, fileNames, watchAll, watchedTreeIds, treeItemMap]); + }, [testModel, rootItem, fileNames, watchAll, watchedTreeIds, treeItemMap]); const runTreeItem = (treeItem: TreeItem) => { setSelectedTreeItemId(treeItem.id); @@ -561,12 +567,6 @@ const TraceView: React.FC<{ drawer='bottom' />; }; -declare global { - interface Window { - binding(data: any): Promise; - } -} - let receiver: TeleReporterReceiver | undefined; let throttleTimer: NodeJS.Timeout | undefined; @@ -638,32 +638,41 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { return sendMessage('list', {}); }; -(window as any).dispatch = (message: any) => { - if (message.method === 'listChanged') { - refreshRootSuite(false).catch(() => {}); - return; - } +let lastId = 0; +let _ws: WebSocket; +const callbacks = new Map void, reject: (arg: Error) => void }>(); - if (message.method === 'testFilesChanged') { - runWatchedTests(message.params.testFileNames); - return; - } - - if (message.method === 'stdio') { - if (message.params.buffer) { - const data = atob(message.params.buffer); - xtermDataSource.write(data); +const initWebSocket = async (onClose: () => void) => { + const guid = new URLSearchParams(window.location.search).get('ws'); + const ws = new WebSocket(`ws://${window.location.hostname}:${window.location.port}/${guid}`); + await new Promise(f => ws.addEventListener('open', f)); + ws.addEventListener('close', onClose); + ws.addEventListener('message', event => { + const message = JSON.parse(event.data); + const { id, result, error, method, params } = message; + if (id) { + const callback = callbacks.get(id); + if (!callback) + return; + callbacks.delete(id); + if (error) + callback.reject(new Error(error)); + else + callback.resolve(result); } else { - xtermDataSource.write(message.params.text); + dispatchMessage(method, params); } - return; - } - - receiver?.dispatch(message)?.catch(() => {}); + }); + _ws = ws; }; -const sendMessage = async (method: string, params: any) => { - await (window as any).sendMessage({ method, params }); +const sendMessage = async (method: string, params: any): Promise => { + const id = ++lastId; + const message = { id, method, params }; + _ws.send(JSON.stringify(message)); + return new Promise((resolve, reject) => { + callbacks.set(id, { resolve, reject }); + }); }; const sendMessageNoReply = (method: string, params?: any) => { @@ -677,6 +686,30 @@ const sendMessageNoReply = (method: string, params?: any) => { }); }; +const dispatchMessage = (method: string, params?: any) => { + if (method === 'listChanged') { + refreshRootSuite(false).catch(() => {}); + return; + } + + if (method === 'testFilesChanged') { + runWatchedTests(params.testFileNames); + return; + } + + if (method === 'stdio') { + if (params.buffer) { + const data = atob(params.buffer); + xtermDataSource.write(data); + } else { + xtermDataSource.write(params.text); + } + return; + } + + receiver?.dispatch({ method, params })?.catch(() => {}); +}; + const outputDirForTestCase = (testCase: TestCase): string | undefined => { for (let suite: Suite | undefined = testCase.parent; suite; suite = suite.parent) { if (suite.project())