diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index d42138b0ff..aa6ef60271 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -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) { diff --git a/packages/trace-viewer/src/ui/testServerConnection.ts b/packages/playwright/src/isomorphic/testServerConnection.ts similarity index 58% rename from packages/trace-viewer/src/ui/testServerConnection.ts rename to packages/playwright/src/isomorphic/testServerConnection.ts index 45f75a633e..e29e448675 100644 --- a/packages/trace-viewer/src/ui/testServerConnection.ts +++ b/packages/playwright/src/isomorphic/testServerConnection.ts @@ -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; - readonly onListReport: Event; - readonly onTestReport: Event; - readonly onStdio: Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>; - readonly onListChanged: Event; - readonly onTestFilesChanged: Event; + readonly onClose: events.Event; + readonly onListReport: events.Event; + readonly onTestReport: events.Event; + readonly onStdio: events.Event<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>; + readonly onListChanged: events.Event; + readonly onTestFilesChanged: events.Event<{ testFiles: string[] }>; + readonly onLoadTraceRequested: events.Event<{ traceUrl: string }>; - private _onCloseEmitter = new EventEmitter(); - private _onListReportEmitter = new EventEmitter(); - private _onTestReportEmitter = new EventEmitter(); - private _onStdioEmitter = new EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>(); - private _onListChangedEmitter = new EventEmitter(); - private _onTestFilesChangedEmitter = new EventEmitter(); + private _onCloseEmitter = new events.EventEmitter(); + private _onListReportEmitter = new events.EventEmitter(); + private _onTestReportEmitter = new events.EventEmitter(); + private _onStdioEmitter = new events.EventEmitter<{ type: 'stderr' | 'stdout'; text?: string | undefined; buffer?: string | undefined; }>(); + private _onListChangedEmitter = new events.EventEmitter(); + private _onTestFilesChangedEmitter = new events.EventEmitter<{ testFiles: string[] }>(); + private _onLoadTraceRequestedEmitter = new events.EventEmitter<{ traceUrl: string }>(); - private _send: Promise<(method: string, params?: any) => Promise>; + private _lastId = 0; + private _ws: WebSocket; + private _callbacks = new Map void, reject: (arg: Error) => void }>(); + private _connectedPromise: Promise; - 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((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 { - 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 { diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index dc8a9e9d62..17324a4117 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -80,5 +80,16 @@ export interface TestServerInterfaceEvents { onTestReport: Event; onStdio: Event<{ type: 'stdout' | 'stderr', text?: string, buffer?: string }>; onListChanged: Event; - onTestFilesChanged: Event; + 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; } diff --git a/packages/playwright/src/isomorphic/testTree.ts b/packages/playwright/src/isomorphic/testTree.ts index bfe932306c..64f414a763 100644 --- a/packages/playwright/src/isomorphic/testTree.ts +++ b/packages/playwright/src/isomorphic/testTree.ts @@ -41,6 +41,7 @@ export type TestCaseItem = TreeItemBase & { children: TestItem[]; test: reporterTypes.TestCase | undefined; project: reporterTypes.FullProject | undefined; + tags: Array; }; export type TestItem = TreeItemBase & { @@ -112,6 +113,7 @@ export class TestTree { status: 'none', project: undefined, test: undefined, + tags: test.tags, }; this._addChild(parentGroup, testCaseItem); } diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index 3d08fecc5c..c17435402c 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -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) | 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(); 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) => Promise): Promise { diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index a4bed25a4e..a894920dfb 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -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(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 => { diff --git a/packages/trace-viewer/src/ui/workbenchLoader.tsx b/packages/trace-viewer/src/ui/workbenchLoader.tsx index bc87a514db..8efa265ae3 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/workbenchLoader.tsx @@ -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) diff --git a/packages/trace-viewer/src/ui/wsPort.ts b/packages/trace-viewer/src/ui/wsPort.ts deleted file mode 100644 index 298fa71d17..0000000000 --- a/packages/trace-viewer/src/ui/wsPort.ts +++ /dev/null @@ -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 void, reject: (arg: Error) => void }>(); - -export async function connect(options: { onEvent: (method: string, params?: any) => void, onClose: () => void }): Promise<(method: string, params?: any) => Promise> { - 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 => { - const id = ++lastId; - const message = { id, method, params }; - _ws.send(JSON.stringify(message)); - return new Promise((resolve, reject) => { - callbacks.set(id, { resolve, reject }); - }); -}; diff --git a/tests/playwright-test/ui-mode-test-update.spec.ts b/tests/playwright-test/ui-mode-test-update.spec.ts index b55633c12f..4996c959e6 100644 --- a/tests/playwright-test/ui-mode-test-update.spec.ts +++ b/tests/playwright-test/ui-mode-test-update.spec.ts @@ -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();