chore: prepare to reuse test server from ui mode (5) (#30005)

This commit is contained in:
Pavel Feldman 2024-03-19 14:08:21 -07:00 committed by GitHub
parent 1bb463163b
commit 0a22a86e2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 100 additions and 118 deletions

View File

@ -210,10 +210,10 @@ class StdinServer implements Transport {
sendEvent?: (method: string, params: any) => void;
close?: () => void;
private _loadTrace(url: string) {
this._traceUrl = url;
private _loadTrace(traceUrl: string) {
this._traceUrl = traceUrl;
clearTimeout(this._pollTimer);
this.sendEvent?.('loadTrace', { url });
this.sendEvent?.('loadTraceRequested', { traceUrl });
}
private _pollLoadTrace(url: string) {

View File

@ -16,67 +16,94 @@
import type { TestServerInterface, TestServerInterfaceEvents } from '@testIsomorphic/testServerInterface';
import type { Location, TestError } from 'playwright/types/testReporter';
import { connect } from './wsPort';
import type { Event } from '@testIsomorphic/events';
import { EventEmitter } from '@testIsomorphic/events';
import * as events from './events';
export class TestServerConnection implements TestServerInterface, TestServerInterfaceEvents {
readonly onClose: Event<void>;
readonly onListReport: Event<any>;
readonly onTestReport: Event<any>;
readonly onStdio: Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>;
readonly onListChanged: Event<void>;
readonly onTestFilesChanged: Event<string[]>;
readonly onClose: events.Event<void>;
readonly onListReport: events.Event<any>;
readonly onTestReport: events.Event<any>;
readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>;
readonly onListChanged: events.Event<void>;
readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>;
readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>;
private _onCloseEmitter = new EventEmitter<void>();
private _onListReportEmitter = new EventEmitter<any>();
private _onTestReportEmitter = new EventEmitter<any>();
private _onStdioEmitter = new EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>();
private _onListChangedEmitter = new EventEmitter<void>();
private _onTestFilesChangedEmitter = new EventEmitter<string[]>();
private _onCloseEmitter = new events.EventEmitter<void>();
private _onListReportEmitter = new events.EventEmitter<any>();
private _onTestReportEmitter = new events.EventEmitter<any>();
private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>();
private _onListChangedEmitter = new events.EventEmitter<void>();
private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>();
private _onLoadTraceRequestedEmitter = new events.EventEmitter<{ traceUrl: string }>();
private _send: Promise<(method: string, params?: any) => Promise<any>>;
private _lastId = 0;
private _ws: WebSocket;
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
private _connectedPromise: Promise<void>;
constructor() {
constructor(wsURL: string) {
this.onClose = this._onCloseEmitter.event;
this.onListReport = this._onListReportEmitter.event;
this.onTestReport = this._onTestReportEmitter.event;
this.onStdio = this._onStdioEmitter.event;
this.onListChanged = this._onListChangedEmitter.event;
this.onTestFilesChanged = this._onTestFilesChangedEmitter.event;
this.onLoadTraceRequested = this._onLoadTraceRequestedEmitter.event;
this._send = connect({
onEvent: (method, params) => this._dispatchEvent(method, params),
onClose: () => this._onCloseEmitter.fire(),
this._ws = new WebSocket(wsURL);
this._ws.addEventListener('message', event => {
const message = JSON.parse(String(event.data));
const { id, result, error, method, params } = message;
if (id) {
const callback = this._callbacks.get(id);
if (!callback)
return;
this._callbacks.delete(id);
if (error)
callback.reject(new Error(error));
else
callback.resolve(result);
} else {
this._dispatchEvent(method, params);
}
});
const pingInterval = setInterval(() => this._sendMessage('ping').catch(() => {}), 30000);
this._connectedPromise = new Promise<void>((f, r) => {
this._ws.addEventListener('open', () => {
f();
this._ws.send(JSON.stringify({ method: 'ready' }));
});
this._ws.addEventListener('error', r);
});
this._ws.addEventListener('close', () => {
this._onCloseEmitter.fire();
clearInterval(pingInterval);
});
}
private async _sendMessage(method: string, params?: any): Promise<any> {
if ((window as any)._sniffProtocolForTest)
(window as any)._sniffProtocolForTest({ method, params }).catch(() => {});
const send = await this._send;
const logForTest = (window as any).__logForTest;
const logForTest = (globalThis as any).__logForTest;
logForTest?.({ method, params });
return send(method, params).catch((e: Error) => {
// eslint-disable-next-line no-console
console.error(e);
await this._connectedPromise;
const id = ++this._lastId;
const message = { id, method, params };
this._ws.send(JSON.stringify(message));
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject });
});
}
private _dispatchEvent(method: string, params?: any) {
if (method === 'close')
this._onCloseEmitter.fire(undefined);
else if (method === 'listReport')
if (method === 'listReport')
this._onListReportEmitter.fire(params);
else if (method === 'testReport')
this._onTestReportEmitter.fire(params);
else if (method === 'stdio')
this._onStdioEmitter.fire(params);
else if (method === 'listChanged')
this._onListChangedEmitter.fire(undefined);
this._onListChangedEmitter.fire(params);
else if (method === 'testFilesChanged')
this._onTestFilesChangedEmitter.fire(params.testFileNames);
this._onTestFilesChangedEmitter.fire(params);
}
async ping(): Promise<void> {

View File

@ -80,5 +80,16 @@ export interface TestServerInterfaceEvents {
onTestReport: Event<any>;
onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>;
onListChanged: Event<void>;
onTestFilesChanged: Event<string[]>;
onTestFilesChanged: Event<{ testFiles: string[] }>;
onLoadTraceRequested: Event<{ traceUrl: string }>;
}
export interface TestServerInterfaceEventEmitters {
dispatchEvent(event: 'close', params: {}): void;
dispatchEvent(event: 'listReport', params: any): void;
dispatchEvent(event: 'testReport', params: any): void;
dispatchEvent(event: 'stdio', params: { type: 'stdout' | 'stderr', text?: string, buffer?: string }): void;
dispatchEvent(event: 'listChanged', params: {}): void;
dispatchEvent(event: 'testFilesChanged', params: { testFiles: string[] }): void;
dispatchEvent(event: 'loadTraceRequested', params: { traceUrl: string }): void;
}

View File

@ -41,6 +41,7 @@ export type TestCaseItem = TreeItemBase & {
children: TestItem[];
test: reporterTypes.TestCase | undefined;
project: reporterTypes.FullProject | undefined;
tags: Array<string>;
};
export type TestItem = TreeItemBase & {
@ -112,6 +113,7 @@ export class TestTree {
status: 'none',
project: undefined,
test: undefined,
tags: test.tags,
};
this._addChild(parentGroup, testCaseItem);
}

View File

@ -28,7 +28,7 @@ import ListReporter from '../reporters/list';
import { Multiplexer } from '../reporters/multiplexer';
import { SigIntWatcher } from './sigIntWatcher';
import { Watcher } from '../fsWatcher';
import type { TestServerInterface } from '../isomorphic/testServerInterface';
import type { TestServerInterface, TestServerInterfaceEventEmitters } from '../isomorphic/testServerInterface';
import { Runner } from './runner';
import { serializeError } from '../util';
import { prepareErrorStack } from '../reporters/base';
@ -95,6 +95,7 @@ class TestServerDispatcher implements TestServerInterface {
readonly transport: Transport;
private _queue = Promise.resolve();
private _globalCleanup: (() => Promise<FullResult['status']>) | undefined;
readonly _dispatchEvent: TestServerInterfaceEventEmitters['dispatchEvent'];
constructor(config: FullConfigInternal) {
this._config = config;
@ -106,8 +107,9 @@ class TestServerDispatcher implements TestServerInterface {
this._testWatcher = new Watcher('flat', events => {
const collector = new Set<string>();
events.forEach(f => collectAffectedTestFiles(f.file, collector));
this._dispatchEvent('testFilesChanged', { testFileNames: [...collector] });
this._dispatchEvent('testFilesChanged', { testFiles: [...collector] });
});
this._dispatchEvent = (method, params) => this.transport.sendEvent?.(method, params);
}
async ping() {}
@ -252,10 +254,6 @@ class TestServerDispatcher implements TestServerInterface {
async closeGracefully() {
gracefullyProcessExitDoNotHang(0);
}
_dispatchEvent(method: string, params?: any) {
this.transport.sendEvent?.(method, params);
}
}
export async function runTestServer(config: FullConfigInternal, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise<void>) => Promise<void>): Promise<FullResult['status']> {

View File

@ -42,7 +42,7 @@ import type { ActionTraceEvent } from '@trace/trace';
import { statusEx, TestTree } from '@testIsomorphic/testTree';
import type { TreeItem } from '@testIsomorphic/testTree';
import { testStatusIcon } from './testUtils';
import { TestServerConnection } from './testServerConnection';
import { TestServerConnection } from '@testIsomorphic/testServerConnection';
let updateRootSuite: (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress | undefined) => void = () => {};
let runWatchedTests = (fileNames: string[]) => {};
@ -90,7 +90,10 @@ export const UIModeView: React.FC<{}> = ({
const inputRef = React.useRef<HTMLInputElement>(null);
const reloadTests = React.useCallback(() => {
const connection = new TestServerConnection();
const guid = new URLSearchParams(window.location.search).get('ws');
const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const connection = new TestServerConnection(wsURL.toString());
wireConnectionListeners(connection);
connection.onClose(() => setIsDisconnected(true));
setTestServerConnection(connection);
@ -653,8 +656,8 @@ const wireConnectionListeners = (testServerConnection: TestServerConnection) =>
testServerConnection.listTests({}).catch(() => {});
});
testServerConnection.onTestFilesChanged(testFiles => {
runWatchedTests(testFiles);
testServerConnection.onTestFilesChanged(params => {
runWatchedTests(params.testFiles);
});
testServerConnection.onStdio(params => {

View File

@ -21,7 +21,7 @@ import { MultiTraceModel } from './modelUtil';
import './workbenchLoader.css';
import { toggleTheme } from '@web/theme';
import { Workbench } from './workbench';
import { connect } from './wsPort';
import { TestServerConnection } from '@testIsomorphic/testServerConnection';
export const WorkbenchLoader: React.FunctionComponent<{
}> = () => {
@ -84,17 +84,14 @@ export const WorkbenchLoader: React.FunctionComponent<{
}
if (params.has('isServer')) {
connect({
onEvent(method: string, params?: any) {
if (method === 'loadTrace') {
setTraceURLs(params!.url ? [params!.url] : []);
setDragOver(false);
setProcessingErrorMessage(null);
}
},
onClose() {}
}).then(sendMessage => {
sendMessage('ready');
const guid = new URLSearchParams(window.location.search).get('ws');
const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const testServerConnection = new TestServerConnection(wsURL.toString());
testServerConnection.onLoadTraceRequested(async params => {
setTraceURLs(params.traceUrl ? [params.traceUrl] : []);
setDragOver(false);
setProcessingErrorMessage(null);
});
} else if (!newTraceURLs.some(url => url.startsWith('blob:'))) {
// Don't re-use blob file URLs on page load (results in Fetch error)

View File

@ -1,56 +0,0 @@
/**
* 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.
*/
let lastId = 0;
let _ws: WebSocket;
const callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
export async function connect(options: { onEvent: (method: string, params?: any) => void, onClose: () => void }): Promise<(method: string, params?: any) => Promise<any>> {
const guid = new URLSearchParams(window.location.search).get('ws');
const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const ws = new WebSocket(wsURL);
await new Promise(f => ws.addEventListener('open', f));
ws.addEventListener('close', options.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 {
options.onEvent(method, params);
}
});
_ws = ws;
setInterval(() => sendMessage('ping').catch(() => {}), 30000);
return sendMessage;
}
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 });
});
};

View File

@ -169,7 +169,7 @@ test('should pick new / deleted nested tests', async ({ runUITest, writeFiles, d
`);
});
test('should update test locations', async ({ runUITest, writeFiles, deleteFile }) => {
test('should update test locations', async ({ runUITest, writeFiles }) => {
const { page } = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
@ -182,8 +182,8 @@ test('should update test locations', async ({ runUITest, writeFiles, deleteFile
passes
`);
const messages: any = [];
await page.exposeBinding('_sniffProtocolForTest', (_, data) => messages.push(data));
const messages: any[] = [];
await page.exposeBinding('__logForTest', (source, arg) => messages.push(arg));
const passesItemLocator = page.getByRole('listitem').filter({ hasText: 'passes' });
await passesItemLocator.hover();