chore: allow opening ui mode over http (#23536)

This commit is contained in:
Pavel Feldman 2023-06-06 08:31:52 -07:00 committed by GitHub
parent dfd1518327
commit 699ac3a0f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 170 additions and 88 deletions

View file

@ -18,15 +18,30 @@ import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { HttpServer } from '../../../utils/httpServer'; import { HttpServer } from '../../../utils/httpServer';
import { findChromiumChannel } from '../../registry'; import { findChromiumChannel } from '../../registry';
import { gracefullyCloseAll, isUnderTest } from '../../../utils'; import { createGuid, gracefullyCloseAll, isUnderTest } from '../../../utils';
import { installAppIcon, syncLocalStorageWithSettings } from '../../chromium/crApp'; import { installAppIcon, syncLocalStorageWithSettings } from '../../chromium/crApp';
import { serverSideCallMetadata } from '../../instrumentation'; import { serverSideCallMetadata } from '../../instrumentation';
import { createPlaywright } from '../../playwright'; import { createPlaywright } from '../../playwright';
import { ProgressController } from '../../progress'; import { ProgressController } from '../../progress';
import { open } from 'playwright-core/lib/utilsBundle'; import { open, wsServer } from 'playwright-core/lib/utilsBundle';
import type { Page } from '../../page'; 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<void>;
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<void> { export async function showTraceViewer(traceUrls: string[], browserName: string, options?: Options): Promise<void> {
if (options?.openInBrowser) { if (options?.openInBrowser) {
@ -79,6 +94,25 @@ async function startTraceViewerServer(traceUrls: string[], options?: Options): P
}); });
const params = traceUrls.map(t => `trace=${t}`); 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) if (options?.isServer)
params.push('isServer'); params.push('isServer');
if (isUnderTest()) if (isUnderTest())

View file

@ -139,8 +139,8 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
const runner = new Runner(config); const runner = new Runner(config);
let status: FullResult['status']; let status: FullResult['status'];
if (opts.ui) if (opts.ui || opts.uiWeb)
status = await runner.uiAllTests(); status = await runner.uiAllTests(!!opts.uiWeb);
else if (process.env.PWTEST_WATCH) else if (process.env.PWTEST_WATCH)
status = await runner.watchAllTests(); status = await runner.watchAllTests();
else else
@ -328,6 +328,7 @@ const testOptions: [string, string][] = [
['--timeout <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`], ['--timeout <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`],
['--trace <mode>', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`], ['--trace <mode>', `Force tracing mode, can be ${kTraceModes.map(mode => `"${mode}"`).join(', ')}`],
['--ui', `Run tests in interactive UI mode`], ['--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)`], ['-u, --update-snapshots', `Update snapshots with actual results (default: only create missing snapshots)`],
['-j, --workers <workers>', `Number of concurrent workers or percentage of logical CPU cores, use 1 to run in a single worker (default: 50%)`], ['-j, --workers <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`], ['-x', `Stop after the first failure`],

View file

@ -107,9 +107,9 @@ export class Runner {
return await runWatchModeLoop(config); return await runWatchModeLoop(config);
} }
async uiAllTests(): Promise<FullResult['status']> { async uiAllTests(openInBrowser: boolean): Promise<FullResult['status']> {
const config = this._config; const config = this._config;
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));
return await runUIMode(config); return await runUIMode(config, openInBrowser);
} }
} }

View file

@ -14,8 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { openTraceViewerApp } from 'playwright-core/lib/server'; import { showTraceViewer } from 'playwright-core/lib/server';
import type { Page } from 'playwright-core/lib/server/page';
import { isUnderTest, 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, collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache'; import { clearCompilationCache, collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache';
@ -28,10 +27,11 @@ import { chokidar } from '../utilsBundle';
import type { FSWatcher } from 'chokidar'; import type { FSWatcher } from 'chokidar';
import { open } from 'playwright-core/lib/utilsBundle'; import { open } from 'playwright-core/lib/utilsBundle';
import ListReporter from '../reporters/list'; import ListReporter from '../reporters/list';
import type { Transport } from 'playwright-core/lib/server/trace/viewer/traceViewer';
class UIMode { class UIMode {
private _config: FullConfigInternal; private _config: FullConfigInternal;
private _page!: Page; private _transport!: Transport;
private _testRun: { run: Promise<FullResult['status']>, stop: ManualPromise<void> } | undefined; private _testRun: { run: Promise<FullResult['status']>, stop: ManualPromise<void> } | undefined;
globalCleanup: (() => Promise<FullResult['status']>) | undefined; globalCleanup: (() => Promise<FullResult['status']>) | undefined;
private _globalWatcher: Watcher; private _globalWatcher: Watcher;
@ -58,11 +58,11 @@ class UIMode {
this._originalStdoutWrite = process.stdout.write; this._originalStdoutWrite = process.stdout.write;
this._originalStderrWrite = process.stderr.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 => { this._testWatcher = new Watcher('flat', events => {
const collector = new Set<string>(); const collector = new Set<string>();
events.forEach(f => collectAffectedTestFiles(f.file, collector)); events.forEach(f => collectAffectedTestFiles(f.file, collector));
this._dispatchEvent({ method: 'testFilesChanged', params: { testFileNames: [...collector] } }); this._dispatchEvent('testFilesChanged', { testFileNames: [...collector] });
}); });
} }
@ -81,23 +81,12 @@ class UIMode {
return status; return status;
} }
async showUI() { async showUI(openInBrowser: boolean) {
this._page = await openTraceViewerApp([], 'chromium', { app: 'uiMode.html', headless: isUnderTest() && process.env.PWTEST_HEADED_FOR_TEST !== '1' });
if (!process.env.PWTEST_DEBUG) {
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());
let queue = Promise.resolve(); let queue = Promise.resolve();
await this._page.exposeBinding('sendMessage', false, async (source, data) => {
const { method, params }: { method: string, params: any } = data; this._transport = {
dispatch: async (method, params) => {
if (method === 'exit') { if (method === 'exit') {
exitPromise.resolve(); exitPromise.resolve();
return; return;
@ -123,7 +112,27 @@ class UIMode {
} }
queue = queue.then(() => this._queueListOrRun(method, params)); queue = queue.then(() => this._queueListOrRun(method, params));
await queue; 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('stdio', chunkToPayload('stdout', chunk));
return true;
};
process.stderr.write = (chunk: string | Buffer) => {
this._dispatchEvent('stdio', chunkToPayload('stderr', chunk));
return true;
};
}
await exitPromise; await exitPromise;
if (!process.env.PWTEST_DEBUG) { if (!process.env.PWTEST_DEBUG) {
@ -139,13 +148,12 @@ class UIMode {
await this._runTests(params.testIds); await this._runTests(params.testIds);
} }
private _dispatchEvent(message: any) { private _dispatchEvent(method: string, params?: any) {
// eslint-disable-next-line no-console this._transport.sendEvent?.(method, params);
this._page.mainFrame().evaluateExpression(dispatchFuncSource, { isFunction: true }, message).catch(e => this._originalStderrWrite.call(process.stderr, String(e)));
} }
private async _listTests() { 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]); const reporter = new InternalReporter([listReporter]);
this._config.cliListOnly = true; this._config.cliListOnly = true;
this._config.testIdMatcher = undefined; this._config.testIdMatcher = undefined;
@ -170,7 +178,7 @@ class UIMode {
this._config.testIdMatcher = id => !testIdSet || testIdSet.has(id); this._config.testIdMatcher = id => !testIdSet || testIdSet.has(id);
const reporters = await createReporters(this._config, 'ui'); 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 reporter = new InternalReporter(reporters);
const taskRunner = createTaskRunnerForWatch(this._config, reporter); const taskRunner = createTaskRunnerForWatch(this._config, reporter);
const testRun = new TestRun(this._config, reporter); const testRun = new TestRun(this._config, reporter);
@ -202,16 +210,12 @@ class UIMode {
} }
} }
const dispatchFuncSource = String((message: any) => { export async function runUIMode(config: FullConfigInternal, openInBrowser: boolean): Promise<FullResult['status']> {
(window as any).dispatch(message);
});
export async function runUIMode(config: FullConfigInternal): Promise<FullResult['status']> {
const uiMode = new UIMode(config); const uiMode = new UIMode(config);
const status = await uiMode.runGlobalSetup(); const status = await uiMode.runGlobalSetup();
if (status !== 'passed') if (status !== 'passed')
return status; return status;
await uiMode.showUI(); await uiMode.showUI(openInBrowser);
return await uiMode.globalCleanup?.() || 'passed'; return await uiMode.globalCleanup?.() || 'passed';
} }

View file

@ -73,6 +73,16 @@
height: 24px; 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 { .status-line {
flex: auto; flex: auto;
white-space: nowrap; white-space: nowrap;

View file

@ -80,6 +80,7 @@ export const UIModeView: React.FC<{}> = ({
const runTestPromiseChain = React.useRef(Promise.resolve()); const runTestPromiseChain = React.useRef(Promise.resolve());
const runTestBacklog = React.useRef<Set<string>>(new Set()); const runTestBacklog = React.useRef<Set<string>>(new Set());
const [collapseAllCount, setCollapseAllCount] = React.useState(0); const [collapseAllCount, setCollapseAllCount] = React.useState(0);
const [isDisconnected, setIsDisconnected] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
@ -94,7 +95,7 @@ export const UIModeView: React.FC<{}> = ({
React.useEffect(() => { React.useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
reloadTests(); initWebSocket(() => setIsDisconnected(true)).then(() => reloadTests());
}, [reloadTests]); }, [reloadTests]);
updateRootSuite = React.useCallback((config: FullConfig, rootSuite: Suite, loadErrors: TestError[], newProgress: Progress | undefined) => { updateRootSuite = React.useCallback((config: FullConfig, rootSuite: Suite, loadErrors: TestError[], newProgress: Progress | undefined) => {
@ -159,6 +160,9 @@ export const UIModeView: React.FC<{}> = ({
const isRunningTest = !!runningState; const isRunningTest = !!runningState;
return <div className='vbox ui-mode'> return <div className='vbox ui-mode'>
{isDisconnected && <div className='drop-target'>
<div className='title'>Process disconnected</div>
</div>}
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}> <SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<div className='vbox'> <div className='vbox'>
<div className={'vbox' + (isShowingOutput ? '' : ' hidden')}> <div className={'vbox' + (isShowingOutput ? '' : ' hidden')}>
@ -399,6 +403,8 @@ const TestList: React.FC<{
// Update watch all. // Update watch all.
React.useEffect(() => { React.useEffect(() => {
if (!testModel.rootSuite)
return;
if (watchAll) { if (watchAll) {
sendMessageNoReply('watch', { fileNames: [...fileNames] }); sendMessageNoReply('watch', { fileNames: [...fileNames] });
} else { } else {
@ -411,7 +417,7 @@ const TestList: React.FC<{
} }
sendMessageNoReply('watch', { fileNames: [...fileNames] }); sendMessageNoReply('watch', { fileNames: [...fileNames] });
} }
}, [rootItem, fileNames, watchAll, watchedTreeIds, treeItemMap]); }, [testModel, rootItem, fileNames, watchAll, watchedTreeIds, treeItemMap]);
const runTreeItem = (treeItem: TreeItem) => { const runTreeItem = (treeItem: TreeItem) => {
setSelectedTreeItemId(treeItem.id); setSelectedTreeItemId(treeItem.id);
@ -561,12 +567,6 @@ const TraceView: React.FC<{
drawer='bottom' />; drawer='bottom' />;
}; };
declare global {
interface Window {
binding(data: any): Promise<void>;
}
}
let receiver: TeleReporterReceiver | undefined; let receiver: TeleReporterReceiver | undefined;
let throttleTimer: NodeJS.Timeout | undefined; let throttleTimer: NodeJS.Timeout | undefined;
@ -638,32 +638,41 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
return sendMessage('list', {}); return sendMessage('list', {});
}; };
(window as any).dispatch = (message: any) => { let lastId = 0;
if (message.method === 'listChanged') { let _ws: WebSocket;
refreshRootSuite(false).catch(() => {}); const callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
return;
}
if (message.method === 'testFilesChanged') { const initWebSocket = async (onClose: () => void) => {
runWatchedTests(message.params.testFileNames); 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; return;
} callbacks.delete(id);
if (error)
if (message.method === 'stdio') { callback.reject(new Error(error));
if (message.params.buffer) { else
const data = atob(message.params.buffer); callback.resolve(result);
xtermDataSource.write(data);
} else { } else {
xtermDataSource.write(message.params.text); dispatchMessage(method, params);
} }
return; });
} _ws = ws;
receiver?.dispatch(message)?.catch(() => {});
}; };
const sendMessage = async (method: string, params: any) => { const sendMessage = async (method: string, params: any): Promise<any> => {
await (window as any).sendMessage({ method, params }); 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) => { 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 => { const outputDirForTestCase = (testCase: TestCase): string | undefined => {
for (let suite: Suite | undefined = testCase.parent; suite; suite = suite.parent) { for (let suite: Suite | undefined = testCase.parent; suite; suite = suite.parent) {
if (suite.project()) if (suite.project())