chore: allow opening ui mode over http (#23536)
This commit is contained in:
parent
dfd1518327
commit
699ac3a0f2
|
|
@ -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<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> {
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -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 <timeout>', `Specify test timeout threshold in milliseconds, zero for unlimited (default: ${defaultTimeout})`],
|
||||
['--trace <mode>', `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 <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`],
|
||||
|
|
|
|||
|
|
@ -107,9 +107,9 @@ export class Runner {
|
|||
return await runWatchModeLoop(config);
|
||||
}
|
||||
|
||||
async uiAllTests(): Promise<FullResult['status']> {
|
||||
async uiAllTests(openInBrowser: boolean): Promise<FullResult['status']> {
|
||||
const config = this._config;
|
||||
webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p }));
|
||||
return await runUIMode(config);
|
||||
return await runUIMode(config, openInBrowser);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FullResult['status']>, stop: ManualPromise<void> } | undefined;
|
||||
globalCleanup: (() => Promise<FullResult['status']>) | 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<string>();
|
||||
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<FullResult['status']> {
|
||||
export async function runUIMode(config: FullConfigInternal, openInBrowser: boolean): Promise<FullResult['status']> {
|
||||
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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export const UIModeView: React.FC<{}> = ({
|
|||
const runTestPromiseChain = React.useRef(Promise.resolve());
|
||||
const runTestBacklog = React.useRef<Set<string>>(new Set());
|
||||
const [collapseAllCount, setCollapseAllCount] = React.useState(0);
|
||||
const [isDisconnected, setIsDisconnected] = React.useState(false);
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(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 <div className='vbox ui-mode'>
|
||||
{isDisconnected && <div className='drop-target'>
|
||||
<div className='title'>Process disconnected</div>
|
||||
</div>}
|
||||
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
|
||||
<div className='vbox'>
|
||||
<div className={'vbox' + (isShowingOutput ? '' : ' hidden')}>
|
||||
|
|
@ -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<void>;
|
||||
}
|
||||
}
|
||||
|
||||
let receiver: TeleReporterReceiver | undefined;
|
||||
|
||||
let throttleTimer: NodeJS.Timeout | undefined;
|
||||
|
|
@ -638,32 +638,41 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
|
|||
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<number, { resolve: (arg: any) => 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<any> => {
|
||||
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())
|
||||
|
|
|
|||
Loading…
Reference in a new issue