From c3c74e4b10c6232fec28b8f8bee4b664ddaf36d3 Mon Sep 17 00:00:00 2001 From: Holger Benl Date: Mon, 1 Jun 2026 12:49:16 +0200 Subject: [PATCH 1/8] test(bidi): remove fail expectation for Firefox/Bidi (#41064) --- tests/library/browsercontext-locale.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/library/browsercontext-locale.spec.ts b/tests/library/browsercontext-locale.spec.ts index 787dcb2ff5290..6bfb5aff80bd1 100644 --- a/tests/library/browsercontext-locale.spec.ts +++ b/tests/library/browsercontext-locale.spec.ts @@ -165,8 +165,8 @@ it('should not change default locale in another context', async ({ browser }) => } }); -it('should propagate locale to workers', async ({ browser, browserName, server }) => { - it.fail(browserName === 'firefox', 'https://github.com/microsoft/playwright/issues/38919'); +it('should propagate locale to workers', async ({ browser, browserName, isBidi, server }) => { + it.fail(browserName === 'firefox' && !isBidi, 'https://github.com/microsoft/playwright/issues/38919'); const context = await browser.newContext({ locale: 'ru-RU' }); const page = await context.newPage(); await page.goto(server.EMPTY_PAGE); From e8d5386c9a2519dfcc27236707e3690047b7955d Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 1 Jun 2026 15:42:27 +0200 Subject: [PATCH 2/8] fix(reporter): spread AggregateError sub-errors into testInfo.errors (#40925) --- packages/playwright/src/worker/testInfo.ts | 24 +++++--- tests/playwright-test/reporter.spec.ts | 72 ++++++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 6250ce936a20b..42b3a7a902c20 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -413,15 +413,23 @@ export class TestInfoImpl implements TestInfo { this.status = 'interrupted'; } - _failWithError(error: Error | unknown) { + _failWithError(root: Error | unknown) { if (this.status === 'passed' || this.status === 'skipped') - this.status = error instanceof TimeoutManagerError ? 'timedOut' : 'failed'; - const serialized = testInfoError(error); - const step: TestStepInternal | undefined = typeof error === 'object' ? (error as any)?.[stepSymbol] : undefined; - if (step && step.boxedStack) - serialized.stack = `${(error as Error).name}: ${(error as Error).message}\n${stringifyStackFrames(step.boxedStack).join('\n')}`; - this.errors.push(serialized); - this._tracing.appendForError(serialized); + this.status = root instanceof TimeoutManagerError ? 'timedOut' : 'failed'; + const visit = (error: Error | unknown) => { + const serialized = testInfoError(error); + const step: TestStepInternal | undefined = error === root && typeof error === 'object' ? (error as any)?.[stepSymbol] : undefined; + if (step && step.boxedStack) + serialized.stack = `${(error as Error).name}: ${(error as Error).message}\n${stringifyStackFrames(step.boxedStack).join('\n')}`; + this.errors.push(serialized); + this._tracing.appendForError(serialized); + const children = (error as any)?.errors; + if (Array.isArray(children)) { + for (const child of children) + visit(child); + } + }; + visit(root); } async _runAsStep(stepInfo: { title: string, category: 'hook' | 'fixture', location?: Location, group?: string }, cb: () => Promise) { diff --git a/tests/playwright-test/reporter.spec.ts b/tests/playwright-test/reporter.spec.ts index 8476629f03103..e0330b1e83b4e 100644 --- a/tests/playwright-test/reporter.spec.ts +++ b/tests/playwright-test/reporter.spec.ts @@ -915,3 +915,75 @@ test('should have static annotations on result when all tests are skipped', asyn 'annotation: skip', ]); }); + +test('AggregateError sub-errors are spread into testInfo.errors', async ({ runInlineTest }) => { + class TestReporter implements Reporter { + onTestEnd(test: TestCase, result: TestResult): void { + for (const error of result.errors) + console.log(`%%${error.message ?? error.value}`); + // For the boxed-step case, also surface a frame from the test file so + // we can assert that the boxed-stack rewrite only applies to the + // top-level error and not to its sub-errors. + if (test.title === 'boxed step') { + for (const error of result.errors) { + const frame = (error.stack ?? '').split('\n').find(l => l.includes('a.spec.ts:')); + console.log(`%%FRAME ${error.message}: ${frame?.trim()}`); + } + } + } + } + + const result = await runInlineTest({ + 'reporter.ts': `module.exports = ${TestReporter.toString()}`, + 'playwright.config.ts': `module.exports = { reporter: './reporter' };`, + 'a.spec.ts': ` + import { test } from '@playwright/test'; + test('basic', () => { + throw new AggregateError([new Error('a'), new Error('b')], 'parent'); + }); + test('nested', () => { + throw new AggregateError([ + new AggregateError([new Error('a'), new Error('b')], 'inner'), + new Error('c'), + ], 'outer'); + }); + test('non-error entries', () => { + const err: any = new Error('parent'); + err.errors = ['oops', { foo: 1 }, new Error('real')]; + throw err; + }); + test('boxed step', async () => { + const subA = new Error('sub a'); + const subB = new Error('sub b'); + const helper = async () => { + await test.step('boxed', async () => { + throw new AggregateError([subA, subB], 'top'); + }, { box: true }); + }; + await helper(); + }); + `, + }, { 'reporter': '', 'workers': 1 }); + + expect(result.exitCode).toBe(1); + expect(result.outputLines).toEqual([ + 'AggregateError: parent', + 'Error: a', + 'Error: b', + 'AggregateError: outer', + 'AggregateError: inner', + 'Error: a', + 'Error: b', + 'Error: c', + 'Error: parent', + `'oops'`, + '{ foo: 1 }', + 'Error: real', + 'AggregateError: top', + 'Error: sub a', + 'Error: sub b', + expect.stringMatching(/^FRAME AggregateError: top: at .*a\.spec\.ts:25:/), + expect.stringMatching(/^FRAME Error: sub a: at .*a\.spec\.ts:18:/), + expect.stringMatching(/^FRAME Error: sub b: at .*a\.spec\.ts:19:/), + ]); +}); From cadf83c4c593accd3ad97ab8aa2092de252bb1ce Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 1 Jun 2026 16:13:32 +0200 Subject: [PATCH 3/8] fix(ui-mode): include project outputDirs in allowedFileRoots (#41065) --- .../src/server/trace/viewer/traceViewer.ts | 20 +++++----- packages/playwright/src/runner/testRunner.ts | 6 +++ packages/playwright/src/runner/testServer.ts | 21 ++++++++++- tests/playwright-test/ui-mode-trace.spec.ts | 37 +++++++++++++++++++ 4 files changed, 71 insertions(+), 13 deletions(-) diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index d8060209f9aad..2c36fde99fa36 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -45,7 +45,6 @@ export type TraceViewerServerOptions = { port?: number; isServer?: boolean; transport?: Transport; - allowedFileRoots?: string[]; }; export type TraceViewerRedirectOptions = { @@ -88,10 +87,9 @@ function validateTraceUrlOrPath(traceFileOrUrl: string | undefined): string | un } } -export async function startTraceViewerServer(options?: TraceViewerServerOptions): Promise { +export async function startTraceViewerServer(options: TraceViewerServerOptions & { allowedFileRoots: () => string[] }): Promise { const server = new HttpServer(libPath('vite', 'traceViewer')); - const allowedRoots = (options?.allowedFileRoots ?? [process.cwd()]).map(r => path.resolve(r)); - const isAllowed = (filePath: string) => allowedRoots.some(root => isPathInside(root, filePath)); + const isAllowed = (filePath: string) => options.allowedFileRoots().some(root => isPathInside(path.resolve(root), filePath)); const serveTraceDataRoute = (request: http.IncomingMessage, response: http.ServerResponse, relativePath: string): boolean => { if (!relativePath.startsWith('/file')) @@ -154,11 +152,11 @@ export async function startTraceViewerServer(options?: TraceViewerServerOptions) }); } - const transport = options?.transport || (options?.isServer ? new StdinServer() : undefined); + const transport = options.transport || (options.isServer ? new StdinServer() : undefined); if (transport) server.createWebSocket(() => transport); - const { host, port } = options || {}; + const { host, port } = options; await server.start({ preferredPort: port, host }); return server; } @@ -197,7 +195,8 @@ export async function installRootRedirect(server: HttpServer, traceUrl: string | export async function runTraceViewerApp(traceUrl: string | undefined, browserName: string, options: TraceViewerServerOptions & { headless?: boolean }) { traceUrl = validateTraceUrlOrPath(traceUrl); - const server = await startTraceViewerServer({ ...options, allowedFileRoots: traceFileRoots(traceUrl, options.allowedFileRoots) }); + const allowedFileRoots = traceFileRoots(traceUrl); + const server = await startTraceViewerServer({ ...options, allowedFileRoots: () => allowedFileRoots }); await installRootRedirect(server, traceUrl, options); const page = await openTraceViewerApp(server.urlPrefix('precise'), browserName, options); page.on('close', () => gracefullyProcessExitDoNotHang(0)); @@ -206,14 +205,13 @@ export async function runTraceViewerApp(traceUrl: string | undefined, browserNam export async function runTraceInBrowser(traceUrl: string | undefined, options: TraceViewerServerOptions) { traceUrl = validateTraceUrlOrPath(traceUrl); - const server = await startTraceViewerServer({ ...options, allowedFileRoots: traceFileRoots(traceUrl, options.allowedFileRoots) }); + const allowedFileRoots = traceFileRoots(traceUrl); + const server = await startTraceViewerServer({ ...options, allowedFileRoots: () => allowedFileRoots }); await installRootRedirect(server, traceUrl, options); await openTraceInBrowser(server.urlPrefix('human-readable')); } -function traceFileRoots(traceUrl: string | undefined, configured: string[] | undefined): string[] { - if (configured) - return configured; +function traceFileRoots(traceUrl: string | undefined): string[] { if (traceUrl?.startsWith('file://')) { try { return [path.dirname(url.fileURLToPath(traceUrl))]; diff --git a/packages/playwright/src/runner/testRunner.ts b/packages/playwright/src/runner/testRunner.ts index 3f1f250dee0e6..254b1d243ca0f 100644 --- a/packages/playwright/src/runner/testRunner.ts +++ b/packages/playwright/src/runner/testRunner.ts @@ -104,6 +104,7 @@ export class TestRunner extends EventEmitter { private _watchTestDirs = false; private _populateDependenciesOnList = false; private _startingEnv: NodeJS.ProcessEnv = {}; + private _lastLoadedConfig: FullConfigInternal | undefined; constructor(configLocation: ConfigLocation, configCLIOverrides: ipc.ConfigCLIOverrides) { super(); @@ -398,12 +399,17 @@ export class TestRunner extends EventEmitter { } else { config.plugins.splice(0, config.plugins.length, ...this._plugins); } + this._lastLoadedConfig = config; return { config }; } catch (e) { return { config: null, error: serializeError(e) }; } } + lastLoadedConfig(): FullConfigInternal | undefined { + return this._lastLoadedConfig; + } + private async _loadConfigOrReportError(reporter: InternalReporter, overrides?: ipc.ConfigCLIOverrides): Promise { const { config, error } = await this._loadConfig(overrides); if (config) diff --git a/packages/playwright/src/runner/testServer.ts b/packages/playwright/src/runner/testServer.ts index e5cb70a98e5cd..5a31cd6f98bde 100644 --- a/packages/playwright/src/runner/testServer.ts +++ b/packages/playwright/src/runner/testServer.ts @@ -61,7 +61,24 @@ class TestServer { async start(options: { host?: string, port?: number }): Promise { this._dispatcher = new TestServerDispatcher(this._configLocation, this._configCLIOverrides); - return await coreServer.startTraceViewerServer({ ...options, transport: this._dispatcher.transport }); + return await coreServer.startTraceViewerServer({ + host: options.host, + port: options.port, + allowedFileRoots: () => this._allowedFileRoots(), + transport: this._dispatcher.transport, + }); + } + + private _allowedFileRoots(): string[] { + const roots = new Set([process.cwd(), this._configLocation.configDir]); + const config = this._dispatcher?._testRunner.lastLoadedConfig(); + if (config) { + for (const project of config.projects) { + roots.add(project.project.outputDir); + roots.add(project.project.testDir); + } + } + return [...roots]; } async stop() { @@ -97,7 +114,7 @@ export class TestServerDispatcher implements TestServerInterface { readonly transport: Transport; private _serializer: string | undefined; private _closeOnDisconnect = false; - private _testRunner: TestRunner; + _testRunner: TestRunner; private _globalSetupReport: ReportEntry[] | undefined; readonly _dispatchEvent: TestServerInterfaceEventEmitters['dispatchEvent']; diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index e890f35e83e8a..eb0643bcee376 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + import { createImage } from './playwright-test-fixtures'; import { test, expect, retries } from './ui-mode-fixtures'; @@ -841,3 +845,36 @@ test('should update state on subsequent run', async ({ runUITest, writeFiles }) - treeitem /Expect \"toBe\"/ `); }); + +test('should load trace when outputDir is outside cwd', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40950' }, +}, async ({ runUITest }) => { + const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pw-outputdir-')); + try { + const { page } = await runUITest({ + 'playwright.config.ts': ` + import { defineConfig } from '@playwright/test'; + export default defineConfig({ + outputDir: ${JSON.stringify(outputDir)}, + }); + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('trace test', async ({ page }) => { + await page.setContent(''); + }); + `, + }); + + await page.getByText('trace test').dblclick(); + + const listItem = page.getByTestId('actions-tree').getByRole('treeitem'); + await expect(listItem, 'action list').toHaveText([ + /Before Hooks/, + /Set content/, + /After Hooks/, + ]); + } finally { + await fs.promises.rm(outputDir, { recursive: true, force: true }); + } +}); From 48ad0944f5de1a1e850d0672afa36b72e19a0074 Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:44:11 -0600 Subject: [PATCH 4/8] feat(firefox-beta): roll to r1521 (#41058) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 7d0d277bf2427..a2423e961349a 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -38,7 +38,7 @@ }, { "name": "firefox-beta", - "revision": "1520", + "revision": "1521", "installByDefault": false, "browserVersion": "152.0b1", "title": "Firefox Beta" From 8727a661c34f14ad89cee0b88db06e73dd5c526f Mon Sep 17 00:00:00 2001 From: "microsoft-playwright-automation[bot]" <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:44:25 -0600 Subject: [PATCH 5/8] feat(firefox): roll to r1528 (#41057) Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com> --- packages/playwright-core/browsers.json | 2 +- packages/playwright-core/src/server/firefox/protocol.d.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index a2423e961349a..67ab17bdc7b78 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -31,7 +31,7 @@ }, { "name": "firefox", - "revision": "1525", + "revision": "1528", "installByDefault": true, "browserVersion": "151.0", "title": "Firefox" diff --git a/packages/playwright-core/src/server/firefox/protocol.d.ts b/packages/playwright-core/src/server/firefox/protocol.d.ts index 6e0822b91e849..1fbad16e2d7f2 100644 --- a/packages/playwright-core/src/server/firefox/protocol.d.ts +++ b/packages/playwright-core/src/server/firefox/protocol.d.ts @@ -486,12 +486,14 @@ export namespace Protocol { wsid: string; opcode: number; data: string; + timestamp: number; } export type webSocketFrameReceivedPayload = { frameId: string; wsid: string; opcode: number; data: string; + timestamp: number; } export type screencastFramePayload = { data: string; @@ -605,7 +607,7 @@ export namespace Protocol { }|null; }; export type screenshotParameters = { - mimeType: ("image/png"|"image/jpeg"); + mimeType: ("image/png"|"image/jpeg"|"image/webp"); clip: { x: number; y: number; From 1aa42fa543905961c10e7682bd510a706839ea3f Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 1 Jun 2026 17:46:00 +0200 Subject: [PATCH 6/8] fix(screenshotter): race cleanup against progress to honor timeout (#40901) --- packages/playwright-core/src/server/screenshotter.ts | 4 ++-- tests/library/screenshot.spec.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/screenshotter.ts b/packages/playwright-core/src/server/screenshotter.ts index fe854a1fb6183..c29e9e0b7c5e7 100644 --- a/packages/playwright-core/src/server/screenshotter.ts +++ b/packages/playwright-core/src/server/screenshotter.ts @@ -218,7 +218,7 @@ export class Screenshotter { const viewportRect = options.clip ? trimClipToSize(options.clip, viewportSize) : { x: 0, y: 0, ...viewportSize }; return await this._screenshot(progress, format, undefined, viewportRect, true, options); } finally { - await this._restorePageAfterScreenshot(); + await progress.race(this._restorePageAfterScreenshot()).catch(() => {}); } }); } @@ -245,7 +245,7 @@ export class Screenshotter { documentRect.y += scrollOffset.y; return await this._screenshot(progress, format, helper.enclosingIntRect(documentRect), undefined, fitsViewport, options); } finally { - await this._restorePageAfterScreenshot(); + await progress.race(this._restorePageAfterScreenshot()).catch(() => {}); } }); } diff --git a/tests/library/screenshot.spec.ts b/tests/library/screenshot.spec.ts index 21e4678d5d293..f54f1034e3cdd 100644 --- a/tests/library/screenshot.spec.ts +++ b/tests/library/screenshot.spec.ts @@ -211,6 +211,16 @@ browserTest.describe('page screenshot', () => { expect(pixel(0, 999).r).toBeLessThan(128); expect(pixel(0, 999).b).toBeGreaterThan(128); }); + + browserTest('should not hang when event loop is blocked', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/36702' } }, async ({ page }) => { + browserTest.setTimeout(5000); + await page.evaluate(() => { + setTimeout(() => { + while (true) {} + }, 10); + }); + await expect(page.screenshot({ fullPage: true, timeout: 200 })).rejects.toThrow(/page.screenshot: Timeout 200ms exceeded/); + }); }); browserTest.describe('element screenshot', () => { From 899c7a5e4cd1da09a90cbd7fa64379625d79b3c8 Mon Sep 17 00:00:00 2001 From: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:47:06 -0700 Subject: [PATCH 7/8] feat(mcp): accept ConnectOptions object for remoteEndpoint (#40964) Co-authored-by: Simon Knott --- .../src/tools/mcp/browserFactory.ts | 17 ++++++--- .../playwright-core/src/tools/mcp/config.d.ts | 13 +++---- .../playwright-core/src/tools/mcp/config.ts | 3 -- .../playwright-core/src/tools/mcp/program.ts | 1 - tests/mcp/cli-remote.spec.ts | 38 ------------------- tests/mcp/remote-endpoint.spec.ts | 20 +++++----- 6 files changed, 28 insertions(+), 64 deletions(-) delete mode 100644 tests/mcp/cli-remote.spec.ts diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index ab171f1ebe6f1..4ea7e58372bd2 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -115,7 +115,16 @@ async function createCDPBrowser(config: FullConfig, clientInfo: ClientInfo): Pro async function createRemoteBrowser(config: FullConfig): Promise { testDebug('create browser (remote)'); - const descriptor = await serverRegistry.find(config.browser.remoteEndpoint!); + // `remoteEndpoint` may be a plain URL string or a ConnectOptions object that + // carries additional fields such as `exposeNetwork`, `headers`, `slowMo`, and + // `timeout`. Normalize once so the rest of the function deals with a single + // shape. + const remote = config.browser.remoteEndpoint!; + const remoteOptions = typeof remote === 'string' + ? { endpoint: remote } + : remote; + + const descriptor = await serverRegistry.find(remoteOptions.endpoint); if (descriptor) { const browser = await connectToBrowserAcrossVersions(descriptor); return { @@ -131,13 +140,9 @@ async function createRemoteBrowser(config: FullConfig): Promise }; } - const endpoint = config.browser.remoteEndpoint!; const playwrightObject = playwright as Playwright; // Use connectToBrowser instead of playwright[browserName].connect because we don't have browserName. - const browser = await connectToBrowser(playwrightObject, { - endpoint, - headers: config.browser.remoteHeaders, - }); + const browser = await connectToBrowser(playwrightObject, remoteOptions); browser._connectToBrowserType(playwrightObject[browser._browserName], {}, undefined); return { browser, browserInfo: browserInfo(browser, config), canBind: false, ownership: 'attached' }; } diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts index 18bc3bc76f5de..bd9835c2e236b 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -82,14 +82,13 @@ export type Config = { cdpTimeout?: number; /** - * Remote endpoint to connect to an existing Playwright server. + * Remote endpoint to connect to an existing Playwright server. May be a + * WebSocket URL string, or a [ConnectOptions] object that mirrors the + * `connectOptions` shape used by the test runner. When passed as an object, + * `exposeNetwork`, `headers`, `slowMo`, and `timeout` are forwarded to the + * underlying connect call. */ - remoteEndpoint?: string; - - /** - * Headers to send with the remote endpoint connect request. - */ - remoteHeaders?: Record; + remoteEndpoint?: string | playwright.ConnectOptions & { endpoint: string }; /** * Paths to TypeScript files to add as initialization scripts for Playwright page. diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index 3f08edac21470..2583e2684d8ce 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -64,7 +64,6 @@ export type CLIOptions = { port?: number; proxyBypass?: string; proxyServer?: string; - remoteHeader?: Record; saveSession?: boolean; secrets?: Record; sharedBrowserContext?: boolean; @@ -334,7 +333,6 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s initPage: cliOptions.initPage, initScript: cliOptions.initScript, remoteEndpoint: cliOptions.endpoint, - remoteHeaders: cliOptions.remoteHeader, }, extension: cliOptions.extension, server: { @@ -407,7 +405,6 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?: options.port = numberParser(e.PLAYWRIGHT_MCP_PORT); options.proxyBypass = envToString(e.PLAYWRIGHT_MCP_PROXY_BYPASS); options.proxyServer = envToString(e.PLAYWRIGHT_MCP_PROXY_SERVER); - options.remoteHeader = headerParser(envToString(e.PLAYWRIGHT_MCP_REMOTE_HEADERS)); options.secrets = dotenvFileLoader(e.PLAYWRIGHT_MCP_SECRETS_FILE); options.storageState = envToString(e.PLAYWRIGHT_MCP_STORAGE_STATE); options.testIdAttribute = envToString(e.PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE); diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index dd3aa7fa74652..f42c1b0dcc38c 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -64,7 +64,6 @@ export function decorateMCPCommand(command: Command) { .option('--port ', 'port to listen on for SSE transport.') .option('--proxy-bypass ', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') - .option('--remote-header ', 'headers to send with the remote endpoint connect request, multiple can be specified.', headerParser) .option('--sandbox', 'enable the sandbox for all process types that are normally not sandboxed.') .option('--save-session', 'Whether to save the Playwright MCP session into the output directory.') .option('--secrets ', 'path to a file containing secrets in the dotenv format', dotenvFileLoader) diff --git a/tests/mcp/cli-remote.spec.ts b/tests/mcp/cli-remote.spec.ts deleted file mode 100644 index 40fb22b236b1c..0000000000000 --- a/tests/mcp/cli-remote.spec.ts +++ /dev/null @@ -1,38 +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. - */ - -import fs from 'fs'; -import { test, expect } from './cli-fixtures'; - -test.skip(({ mcpBrowser }) => mcpBrowser !== 'chromium', 'Run only on the chromium project; the remote server connection is browser-agnostic.'); - -test('attach to run-server endpoint with remoteHeaders from config', async ({ cli, runServerEndpoint, server }, testInfo) => { - const configPath = testInfo.outputPath('config.json'); - await fs.promises.writeFile(configPath, JSON.stringify({ - browser: { - remoteEndpoint: runServerEndpoint, - remoteHeaders: { 'x-playwright-browser': 'chromium' }, - isolated: true, - }, - }, null, 2)); - - const { exitCode } = await cli('attach', runServerEndpoint, '-s=remote', `--config=${configPath}`); - expect(exitCode).toBe(0); - - await cli('-s=remote', 'goto', server.HELLO_WORLD); - const { inlineSnapshot } = await cli('-s=remote', 'snapshot'); - expect(inlineSnapshot).toContain('Hello, world!'); -}); diff --git a/tests/mcp/remote-endpoint.spec.ts b/tests/mcp/remote-endpoint.spec.ts index 6aba3e9df20a2..2a19e737543e6 100644 --- a/tests/mcp/remote-endpoint.spec.ts +++ b/tests/mcp/remote-endpoint.spec.ts @@ -18,12 +18,11 @@ import { test, expect } from './fixtures'; test.skip(({ mcpBrowser }) => mcpBrowser !== 'chromium', 'Run only on the chromium project; the remote server connection is browser-agnostic.'); -test('remoteHeaders selects the browser on run-server endpoint', async ({ startClient, server, runServerEndpoint }) => { +test('connect without headers fails on run-server endpoint', async ({ startClient, server, runServerEndpoint }) => { const { client } = await startClient({ config: { browser: { remoteEndpoint: runServerEndpoint, - remoteHeaders: { 'x-playwright-browser': 'chromium' }, isolated: true, }, }, @@ -31,18 +30,22 @@ test('remoteHeaders selects the browser on run-server endpoint', async ({ startC const response = await client.callTool({ name: 'browser_navigate', - arguments: { url: server.HELLO_WORLD }, + arguments: { url: server.EMPTY_PAGE }, }); expect(response).toHaveResponse({ - page: expect.stringContaining('Page Title: Title'), + isError: true, + error: expect.stringContaining(`reading 'launch'`), }); }); -test('connect without remoteHeaders fails on run-server endpoint', async ({ startClient, server, runServerEndpoint }) => { +test('remoteEndpoint accepts ConnectOptions object with headers', async ({ startClient, server, runServerEndpoint }) => { const { client } = await startClient({ config: { browser: { - remoteEndpoint: runServerEndpoint, + remoteEndpoint: { + endpoint: runServerEndpoint, + headers: { 'x-playwright-browser': 'chromium' }, + }, isolated: true, }, }, @@ -50,10 +53,9 @@ test('connect without remoteHeaders fails on run-server endpoint', async ({ star const response = await client.callTool({ name: 'browser_navigate', - arguments: { url: server.EMPTY_PAGE }, + arguments: { url: server.HELLO_WORLD }, }); expect(response).toHaveResponse({ - isError: true, - error: expect.stringContaining(`reading 'launch'`), + page: expect.stringContaining('Page Title: Title'), }); }); From 1ac9a5701eb37e263ae63b561d7595818a8f624a Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Mon, 1 Jun 2026 11:07:40 -0600 Subject: [PATCH 8/8] feat(har): include `WebSocket` in `.har` (#41015) also include data for each sent/received frame in a custom property `_webSocketMessages` (for more info see ) both Chrome and WebKit already capture the `wallTime` for the initial request and a `timestamp` for each subsequent message (i.e. diff the `timestamp` relative to the initial `timestamp` and add the `wallTime` in order to determine the current `wallTime`) Firefox exposes a walltime `timestamp` for each message directly fixes --- .../src/server/chromium/crNetworkManager.ts | 8 +- .../src/server/firefox/ffNetworkManager.ts | 37 +- .../src/server/firefox/ffPage.ts | 57 ++- packages/playwright-core/src/server/frames.ts | 35 +- .../src/server/har/harTracer.ts | 63 +++- .../playwright-core/src/server/network.ts | 44 ++- .../src/server/webkit/webview/wvPage.ts | 10 +- .../server/webkit/wkInterceptableRequest.ts | 5 +- .../src/server/webkit/wkPage.ts | 12 +- packages/trace/src/har.ts | 9 + tests/library/channels.spec.ts | 7 +- tests/library/har-websocket.spec.ts | 332 ++++++++++++++++++ tests/library/route-web-socket.spec.ts | 67 ++++ tests/library/web-socket.spec.ts | 7 +- 14 files changed, 634 insertions(+), 59 deletions(-) create mode 100644 tests/library/har-websocket.spec.ts diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index a5628e83b78aa..82da90b57b492 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -76,10 +76,10 @@ export class CRNetworkManager { if (this._page) { sessionInfo.eventListeners.push(...[ eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page!.frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!.frameManager.onWebSocketRequest(e.requestId)), - eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page!.frameManager.onWebSocketRequest(e.requestId, headersObjectToArray(e.request.headers, '\n'), e.wallTime, e.timestamp)), + eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page!.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText, headersObjectToArray(e.response.headers, '\n'))), + eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page!.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page!.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), eventsHelper.addEventListener(session, 'Network.webSocketClosed', e => this._page!.frameManager.webSocketClosed(e.requestId)), eventsHelper.addEventListener(session, 'Network.webSocketFrameError', e => this._page!.frameManager.webSocketError(e.requestId, e.errorMessage)), ]); diff --git a/packages/playwright-core/src/server/firefox/ffNetworkManager.ts b/packages/playwright-core/src/server/firefox/ffNetworkManager.ts index 535d2fbcd2423..e17afd5c45e69 100644 --- a/packages/playwright-core/src/server/firefox/ffNetworkManager.ts +++ b/packages/playwright-core/src/server/firefox/ffNetworkManager.ts @@ -19,20 +19,21 @@ import { eventsHelper } from '@utils/eventsHelper'; import * as network from '../network'; import type { FFSession } from './ffConnection'; +import type { FFPage } from './ffPage'; import type { HeadersArray } from '../../server/types'; import type { RegisteredListener } from '@utils/eventsHelper'; import type * as frames from '../frames'; -import type { Page } from '../page'; import type * as types from '../types'; import type { Protocol } from './protocol'; export class FFNetworkManager { private _session: FFSession; private _requests: Map; - private _page: Page; + private _page: FFPage; private _eventListeners: RegisteredListener[]; + private _webSocketRequestIds = new Set(); - constructor(session: FFSession, page: Page) { + constructor(session: FFSession, page: FFPage) { this._session = session; this._requests = new Map(); @@ -59,7 +60,7 @@ export class FFNetworkManager { _onRequestWillBeSent(event: Protocol.Network.requestWillBeSentPayload) { const redirectedFrom = event.redirectedFrom ? (this._requests.get(event.redirectedFrom) || null) : null; - const frame = redirectedFrom ? redirectedFrom.request.frame() : (event.frameId ? this._page.frameManager.frame(event.frameId) : null); + const frame = redirectedFrom ? redirectedFrom.request.frame() : (event.frameId ? this._page._page.frameManager.frame(event.frameId) : null); if (!frame) return; // Align with Chromium and WebKit and not expose preflight OPTIONS requests to the client. @@ -67,18 +68,28 @@ export class FFNetworkManager { return; if (redirectedFrom) this._requests.delete(redirectedFrom._id); + // Align with Chromium and WebKit by having WebSocket be handled separately from other network activity. + if (event.cause === 'TYPE_WEBSOCKET') { + this._webSocketRequestIds.add(event.requestId); + this._page._onWebSocketRequestWillBeSent(event.requestId, event.url, event.headers); + return; + } const request = new InterceptableRequest(frame, redirectedFrom, event); let route; if (event.isIntercepted) route = new FFRouteImpl(this._session, request); this._requests.set(request._id, request); - this._page.frameManager.requestStarted(request.request, route); + this._page._page.frameManager.requestStarted(request.request, route); } _onResponseReceived(event: Protocol.Network.responseReceivedPayload) { const request = this._requests.get(event.requestId); - if (!request) + if (!request) { + // Align with Chromium and WebKit by having WebSocket be handled separately from other network activity. + if (this._webSocketRequestIds.has(event.requestId)) + this._page._onWebSocketResponseReceived(event.requestId, event.status, event.statusText, event.headers); return; + } const getResponseBody = async () => { const response = await this._session.send('Network.getResponseBody', { requestId: request._id @@ -124,13 +135,19 @@ export class FFNetworkManager { response.setRawResponseHeaders(null); // Headers size are not available in Firefox. response.setResponseHeadersSize(null); - this._page.frameManager.requestReceivedResponse(response); + this._page._page.frameManager.requestReceivedResponse(response); } _onRequestFinished(event: Protocol.Network.requestFinishedPayload) { const request = this._requests.get(event.requestId); - if (!request) + if (!request) { + // Align with Chromium and WebKit by having WebSocket be handled separately from other network activity. + if (this._webSocketRequestIds.has(event.requestId)) { + this._webSocketRequestIds.delete(event.requestId); + this._page._onWebSocketRequestFinished(event.requestId); + } return; + } const response = request.request._existingResponse()!; response.setTransferSize(event.transferSize); response.setEncodedBodySize(event.encodedBodySize); @@ -145,7 +162,7 @@ export class FFNetworkManager { response._requestFinished(responseEndTime); } response._setHttpVersion(event.protocolVersion ?? null); - this._page.frameManager.reportRequestFinished(request.request, response); + this._page._page.frameManager.reportRequestFinished(request.request, response); } _onRequestFailed(event: Protocol.Network.requestFailedPayload) { @@ -161,7 +178,7 @@ export class FFNetworkManager { response._setHttpVersion(null); } request.request._setFailureText(event.errorCode); - this._page.frameManager.requestFailed(request.request, event.errorCode === 'NS_BINDING_ABORTED'); + this._page._page.frameManager.requestFailed(request.request, event.errorCode === 'NS_BINDING_ABORTED'); } } diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 661e6122699de..80e9e1b27af6f 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -15,10 +15,12 @@ * limitations under the License. */ +import { assert } from '@isomorphic/assert'; import { splitErrorMessage } from '@isomorphic/stackTrace'; import { eventsHelper } from '@utils/eventsHelper'; import * as dialog from '../dialog'; import * as dom from '../dom'; +import * as network from '../network'; import { InitScript } from '../page'; import { Page, Worker } from '../page'; import { FFSession } from './ffConnection'; @@ -53,6 +55,8 @@ export class FFPage implements PageDelegate { private _eventListeners: RegisteredListener[]; private _workers = new Map(); private _initScripts: { initScript: InitScript, worldName?: string }[] = []; + private _webSocketRequests = new Map(); + private _webSocketResponses = new Map(); constructor(session: FFSession, browserContext: FFBrowserContext, opener: FFPage | null) { this._session = session; @@ -64,7 +68,7 @@ export class FFPage implements PageDelegate { this._browserContext = browserContext; this._page = new Page(this, browserContext); this.rawMouse.setPage(this._page); - this._networkManager = new FFNetworkManager(session, this._page); + this._networkManager = new FFNetworkManager(session, this); this._page.on(Page.Events.FrameDetached, frame => this._removeContextsForFrame(frame)); // TODO: remove Page.willOpenNewWindowAsynchronously from the protocol. this._eventListeners = [ @@ -90,6 +94,7 @@ export class FFPage implements PageDelegate { eventsHelper.addEventListener(this._session, 'Page.crashed', this._onCrashed.bind(this)), eventsHelper.addEventListener(this._session, 'Page.webSocketCreated', this._onWebSocketCreated.bind(this)), + eventsHelper.addEventListener(this._session, 'Page.webSocketOpened', this._onWebSocketOpened.bind(this)), eventsHelper.addEventListener(this._session, 'Page.webSocketClosed', this._onWebSocketClosed.bind(this)), eventsHelper.addEventListener(this._session, 'Page.webSocketFrameReceived', this._onWebSocketFrameReceived.bind(this)), eventsHelper.addEventListener(this._session, 'Page.webSocketFrameSent', this._onWebSocketFrameSent.bind(this)), @@ -119,7 +124,51 @@ export class FFPage implements PageDelegate { _onWebSocketCreated(event: Protocol.Page.webSocketCreatedPayload) { this._page.frameManager.onWebSocketCreated(webSocketId(event.frameId, event.wsid), event.requestURL); - this._page.frameManager.onWebSocketRequest(webSocketId(event.frameId, event.wsid)); + } + + _onWebSocketRequestWillBeSent(requestId: string, url: string, headers: types.HeadersArray) { + this._webSocketRequests.set(requestId, { url, headers }); + } + + _onWebSocketResponseReceived(requestId: string, status: number, statusText: string, headers: types.HeadersArray) { + this._webSocketResponses.set(requestId, { status, statusText, headers }); + } + + _onWebSocketRequestFinished(requestId: string) { + const response = this._webSocketResponses.get(requestId); + assert(response); + // If the request does not succeed then the WebSocket will never open, so pretend that it did. + if (response.status >= 400) { + const request = this._webSocketRequests.get(requestId); + assert(request); + + this._webSocketRequests.delete(requestId); + this._webSocketResponses.delete(requestId); + + const url = network.parseURL(request.url); + assert(url); + url.protocol = url.protocol === 'https' ? 'wss' : 'ws'; + + this._page.frameManager.onWebSocketCreated(requestId, url.toString()); + this._page.frameManager.onWebSocketRequest(requestId, request.headers); + this._page.frameManager.onWebSocketResponse(requestId, response.status, response.statusText, response.headers); + this._page.frameManager.webSocketClosed(requestId); + return; + } + } + + _onWebSocketOpened(event: Protocol.Page.webSocketOpenedPayload) { + const request = this._webSocketRequests.get(event.requestId); + assert(request); + + const response = this._webSocketResponses.get(event.requestId); + assert(response); + + this._webSocketRequests.delete(event.requestId); + this._webSocketResponses.delete(event.requestId); + + this._page.frameManager.onWebSocketRequest(webSocketId(event.frameId, event.wsid), request.headers); + this._page.frameManager.onWebSocketResponse(webSocketId(event.frameId, event.wsid), response.status, response.statusText, response.headers); } _onWebSocketClosed(event: Protocol.Page.webSocketClosedPayload) { @@ -129,11 +178,11 @@ export class FFPage implements PageDelegate { } _onWebSocketFrameReceived(event: Protocol.Page.webSocketFrameReceivedPayload) { - this._page.frameManager.webSocketFrameReceived(webSocketId(event.frameId, event.wsid), event.opcode, event.data); + this._page.frameManager.webSocketFrameReceived(webSocketId(event.frameId, event.wsid), event.opcode, event.data, event.timestamp); } _onWebSocketFrameSent(event: Protocol.Page.webSocketFrameSentPayload) { - this._page.frameManager.onWebSocketFrameSent(webSocketId(event.frameId, event.wsid), event.opcode, event.data); + this._page.frameManager.onWebSocketFrameSent(webSocketId(event.frameId, event.wsid), event.opcode, event.data, event.timestamp); } _onExecutionContextCreated(payload: Protocol.Runtime.executionContextCreatedPayload) { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 92f3113f2611f..88894ea2bff74 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -403,43 +403,56 @@ export class FrameManager { this._webSockets.set(requestId, ws); } - onWebSocketRequest(requestId: string) { + onWebSocketRequest(requestId: string, headers: types.HeadersArray, wallTime?: number, timestamp?: number) { const ws = this._webSockets.get(requestId); - if (ws && ws.markAsNotified()) + if (!ws) + return; + + if (ws.markAsNotified()) this._page.emit(Page.Events.WebSocket, ws); + + ws.requestSent(headers, wallTime, timestamp); } - onWebSocketResponse(requestId: string, status: number, statusText: string) { + onWebSocketResponse(requestId: string, status: number, statusText: string, headers: types.HeadersArray) { const ws = this._webSockets.get(requestId); - if (status < 400) + if (!ws) return; - if (ws) + + ws.responseReceived(status, statusText, headers); + if (status >= 400) ws.error(`${statusText}: ${status}`); } - onWebSocketFrameSent(requestId: string, opcode: number, data: string) { + onWebSocketFrameSent(requestId: string, opcode: number, data: string, timestamp: number) { const ws = this._webSockets.get(requestId); if (ws) - ws.frameSent(opcode, data); + ws.frameSent(opcode, data, timestamp); } - webSocketFrameReceived(requestId: string, opcode: number, data: string) { + webSocketFrameReceived(requestId: string, opcode: number, data: string, timestamp: number) { const ws = this._webSockets.get(requestId); if (ws) - ws.frameReceived(opcode, data); + ws.frameReceived(opcode, data, timestamp); } webSocketClosed(requestId: string) { const ws = this._webSockets.get(requestId); - if (ws) + if (ws) { + if (ws.markAsNotified()) + this._page.emit(Page.Events.WebSocket, ws); ws.closed(); + } this._webSockets.delete(requestId); } webSocketError(requestId: string, errorMessage: string): void { const ws = this._webSockets.get(requestId); - if (ws) + if (ws) { + if (ws.markAsNotified()) + this._page.emit(Page.Events.WebSocket, ws); ws.error(errorMessage); + } } private _fireInternalFrameNavigation(frame: Frame, event: NavigationEvent) { diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 4281e7b686e21..e6f22b6db00ba 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -30,9 +30,10 @@ import { helper } from '../helper'; import * as network from '../network'; import { nullProgress } from '../progress'; +import { Page } from '../page'; + import type { RegisteredListener } from '@utils/eventsHelper'; import type { APIRequestEvent, APIRequestFinishedEvent } from '../fetch'; -import type { Page } from '../page'; import type { Worker } from '../page'; import type { HeadersArray, LifecycleEvent } from '../types'; import type * as har from '@trace/har'; @@ -102,7 +103,10 @@ export class HarTracer { ]; if (this._context instanceof BrowserContext) { this._eventListeners.push( - eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, (page: Page) => this._createPageEntryIfNeeded(page)), + eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, (page: Page) => { + this._addPageEventListeners(page); + this._createPageEntryIfNeeded(page); + }), eventsHelper.addEventListener(this._context, BrowserContext.Events.Request, (request: network.Request) => this._onRequest(request)), eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFinished, ({ request, response }) => this._onRequestFinished(request, response).catch(() => {})), eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFailed, request => this._onRequestFailed(request)), @@ -111,11 +115,21 @@ export class HarTracer { eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestFulfilled, request => this._onRequestFulfilled(request)), eventsHelper.addEventListener(this._context, BrowserContext.Events.RequestContinued, request => this._onRequestContinued(request)), ); - for (const page of this._context.pages()) + for (const page of this._context.pages()) { + this._addPageEventListeners(page); this._createPageEntryIfNeeded(page); + } } } + private _addPageEventListeners(page: Page) { + if (this._page && page !== this._page) + return; + this._eventListeners.push( + eventsHelper.addEventListener(page, Page.Events.WebSocket, (webSocket: network.WebSocket) => this._onWebSocket(page, webSocket)), + ); + } + private _shouldIncludeEntryWithUrl(urlString: string) { return !this._options.urlFilter || urlMatches(this._baseURL, urlString, this._options.urlFilter); } @@ -418,6 +432,49 @@ export class HarTracer { harEntry._wasContinued = true; } + private _onWebSocket(page: Page, webSocket: network.WebSocket) { + if (!this._shouldIncludeEntryWithUrl(webSocket.url())) + return; + const url = network.parseURL(webSocket.url()); + if (!url) + return; + + const pageEntry = this._createPageEntryIfNeeded(page); + const harEntry = createHarEntry(pageEntry?.id, 'GET', url, page.mainFrame().guid, this._options); + harEntry._resourceType = 'websocket'; + harEntry._webSocketMessages = []; + + const eventListeners = [ + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Request, ({ headers }: { headers: HeadersArray }) => { + this._recordRequestHeadersAndCookies(harEntry, headers); + }), + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Response, ({ status, statusText, headers }: { status: number, statusText: string, headers: HeadersArray }) => { + harEntry.response.status = status; + harEntry.response.statusText = statusText; + this._recordResponseHeaders(harEntry, headers); + }), + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameSent, ({ opcode, data, timestamp }: { opcode: number, data: string, timestamp: number }) => { + harEntry._webSocketMessages!.push({ type: 'send', time: timestamp, opcode, data }); + }), + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.FrameReceived, ({ opcode, data, timestamp }: { opcode: number, data: string, timestamp: number }) => { + harEntry._webSocketMessages!.push({ type: 'receive', time: timestamp, opcode, data }); + }), + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.SocketError, (errorMessage: string) => { + harEntry.response._failureText = errorMessage; + }), + eventsHelper.addEventListener(webSocket, network.WebSocket.Events.Close, () => { + eventsHelper.removeEventListeners(eventListeners); + + if (this._started) + this._delegate.onEntryFinished(harEntry); + }), + ]; + this._eventListeners.push(...eventListeners); + + if (this._started) + this._delegate.onEntryStarted(harEntry); + } + private _storeResponseContent(buffer: Buffer | undefined, content: har.Content, resourceType: string) { if (!buffer) { content.size = 0; diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index ce78b508798aa..7e944987753d3 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -725,12 +725,20 @@ export class Response extends SdkObject { export class WebSocket extends SdkObject { private _url: string; private _notified = false; + private _requestWallTime: number | undefined; + private _requestTimestamp: number | undefined; + private _status: number | undefined; + private _statusText: string | undefined; + private _requestHeaders: HeadersArray | undefined; + private _responseHeaders: HeadersArray | undefined; static Events = { Close: 'close', SocketError: 'socketerror', FrameReceived: 'framereceived', FrameSent: 'framesent', + Request: 'request', + Response: 'response', }; constructor(parent: SdkObject, url: string) { @@ -752,12 +760,40 @@ export class WebSocket extends SdkObject { return this._url; } - frameSent(opcode: number, data: string) { - this.emit(WebSocket.Events.FrameSent, { opcode, data }); + requestSent(headers: HeadersArray, wallTime?: number, timestamp?: number) { + this._requestWallTime = wallTime; + this._requestTimestamp = timestamp; + + this.emit(WebSocket.Events.Request, { headers }); + } + + responseReceived(status: number, statusText: string, headers: HeadersArray) { + this.emit(WebSocket.Events.Response, { status, statusText, headers }); } - frameReceived(opcode: number, data: string) { - this.emit(WebSocket.Events.FrameReceived, { opcode, data }); + private _toWallTime(timestamp: number): number { + // The timestamp of each frame is relative to the timestamp (and walltime) of the initial request in Chromium and WebKit. + if (this._requestWallTime !== undefined && this._requestTimestamp !== undefined) + return this._requestWallTime + (timestamp - this._requestTimestamp); + + // The timestamp is already a walltime in Firefox. + return timestamp; + } + + frameSent(opcode: number, data: string, timestamp: number) { + this.emit(WebSocket.Events.FrameSent, { + opcode, + data, + timestamp: this._toWallTime(timestamp), + }); + } + + frameReceived(opcode: number, data: string, timestamp: number) { + this.emit(WebSocket.Events.FrameReceived, { + opcode, + data, + timestamp: this._toWallTime(timestamp), + }); } error(errorMessage: string) { diff --git a/packages/playwright-core/src/server/webkit/webview/wvPage.ts b/packages/playwright-core/src/server/webkit/webview/wvPage.ts index e043cd4635b0e..2b2ae27e22658 100644 --- a/packages/playwright-core/src/server/webkit/webview/wvPage.ts +++ b/packages/playwright-core/src/server/webkit/webview/wvPage.ts @@ -18,7 +18,7 @@ import { PNG } from 'pngjs'; import jpegjs from 'jpeg-js'; import { assert } from '@isomorphic/assert'; -import { headersArrayToObject } from '@isomorphic/headers'; +import { headersArrayToObject, headersObjectToArray } from '@isomorphic/headers'; import { ManualPromise } from '@isomorphic/manualPromise'; import { splitErrorMessage } from '@isomorphic/stackTrace'; import { debugLogger } from '@utils/debugLogger'; @@ -325,10 +325,10 @@ export class WVPage implements PageDelegate { eventsHelper.addEventListener(session, 'Network.loadingFinished', e => this._onLoadingFinished(e)), eventsHelper.addEventListener(session, 'Network.loadingFailed', e => this._onLoadingFailed(session, e)), eventsHelper.addEventListener(session, 'Network.webSocketCreated', e => this._page.frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page.frameManager.onWebSocketRequest(e.requestId)), - eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(session, 'Network.webSocketWillSendHandshakeRequest', e => this._page.frameManager.onWebSocketRequest(e.requestId, headersObjectToArray(e.request.headers), e.walltime, e.timestamp)), + eventsHelper.addEventListener(session, 'Network.webSocketHandshakeResponseReceived', e => this._page.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText, headersObjectToArray(e.response.headers, ','))), + eventsHelper.addEventListener(session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), + eventsHelper.addEventListener(session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), eventsHelper.addEventListener(session, 'Network.webSocketClosed', e => this._page.frameManager.webSocketClosed(e.requestId)), eventsHelper.addEventListener(session, 'Network.webSocketFrameError', e => this._page.frameManager.webSocketError(e.requestId, e.errorMessage)), ]; diff --git a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts index c0d0e1dde28fa..8fc68d2fa2592 100644 --- a/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts +++ b/packages/playwright-core/src/server/webkit/wkInterceptableRequest.ts @@ -42,6 +42,8 @@ const errorReasons: { [reason: string]: Protocol.Network.ResourceErrorType } = { 'failed': 'General', }; +export const wkSetCookieSeparator = process.platform === 'darwin' ? ',' : 'playwright-set-cookie-separator'; + export class WKInterceptableRequest { private _session: WKSession; private _requestId: string; @@ -83,8 +85,7 @@ export class WKInterceptableRequest { requestStart: timingPayload ? wkMillisToRoundishMillis(timingPayload.requestStart) : -1, responseStart: timingPayload ? wkMillisToRoundishMillis(timingPayload.responseStart) : -1, }; - const setCookieSeparator = process.platform === 'darwin' ? ',' : 'playwright-set-cookie-separator'; - const response = new network.Response(this.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers, ',', setCookieSeparator), timing, getResponseBody, responsePayload.source === 'service-worker'); + const response = new network.Response(this.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers, ',', wkSetCookieSeparator), timing, getResponseBody, responsePayload.source === 'service-worker'); // No raw response headers in WebKit, use "provisional" ones. response.setRawResponseHeaders(null); diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 731c02989be67..acc220a4b38a1 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -17,7 +17,7 @@ import { PNG } from 'pngjs'; import jpegjs from 'jpeg-js'; -import { headersArrayToObject } from '@isomorphic/headers'; +import { headersArrayToObject, headersObjectToArray } from '@isomorphic/headers'; import { splitErrorMessage } from '@isomorphic/stackTrace'; import { eventsHelper } from '@utils/eventsHelper'; import { hostPlatform } from '@utils/hostPlatform'; @@ -31,7 +31,7 @@ import { Page, PageBinding } from '../page'; import { WKSession } from './wkConnection'; import { createHandle, WKExecutionContext } from './wkExecutionContext'; import { RawKeyboardImpl, RawMouseImpl, RawTouchscreenImpl } from './wkInput'; -import { WKInterceptableRequest, WKRouteImpl } from './wkInterceptableRequest'; +import { WKInterceptableRequest, WKRouteImpl, wkSetCookieSeparator } from './wkInterceptableRequest'; import { WKProvisionalPage } from './wkProvisionalPage'; import { WKWorkers } from './wkWorkers'; import { translatePathToWSL } from './webkit'; @@ -397,10 +397,10 @@ export class WKPage implements PageDelegate { eventsHelper.addEventListener(this._session, 'Network.loadingFinished', e => this._onLoadingFinished(e)), eventsHelper.addEventListener(this._session, 'Network.loadingFailed', e => this._onLoadingFailed(this._session, e)), eventsHelper.addEventListener(this._session, 'Network.webSocketCreated', e => this._page.frameManager.onWebSocketCreated(e.requestId, e.url)), - eventsHelper.addEventListener(this._session, 'Network.webSocketWillSendHandshakeRequest', e => this._page.frameManager.onWebSocketRequest(e.requestId)), - eventsHelper.addEventListener(this._session, 'Network.webSocketHandshakeResponseReceived', e => this._page.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText)), - eventsHelper.addEventListener(this._session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData)), - eventsHelper.addEventListener(this._session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData)), + eventsHelper.addEventListener(this._session, 'Network.webSocketWillSendHandshakeRequest', e => this._page.frameManager.onWebSocketRequest(e.requestId, headersObjectToArray(e.request.headers), e.walltime, e.timestamp)), + eventsHelper.addEventListener(this._session, 'Network.webSocketHandshakeResponseReceived', e => this._page.frameManager.onWebSocketResponse(e.requestId, e.response.status, e.response.statusText, headersObjectToArray(e.response.headers, ',', wkSetCookieSeparator))), + eventsHelper.addEventListener(this._session, 'Network.webSocketFrameSent', e => e.response.payloadData && this._page.frameManager.onWebSocketFrameSent(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), + eventsHelper.addEventListener(this._session, 'Network.webSocketFrameReceived', e => e.response.payloadData && this._page.frameManager.webSocketFrameReceived(e.requestId, e.response.opcode, e.response.payloadData, e.timestamp)), eventsHelper.addEventListener(this._session, 'Network.webSocketClosed', e => this._page.frameManager.webSocketClosed(e.requestId)), eventsHelper.addEventListener(this._session, 'Network.webSocketFrameError', e => this._page.frameManager.webSocketError(e.requestId, e.errorMessage)), ]; diff --git a/packages/trace/src/har.ts b/packages/trace/src/har.ts index fd62f7873c392..63d81852af9ff 100644 --- a/packages/trace/src/har.ts +++ b/packages/trace/src/har.ts @@ -72,6 +72,15 @@ export type Entry = { _wasFulfilled?: boolean; _wasContinued?: boolean; _apiRequest?: boolean; + _resourceType?: string; + _webSocketMessages?: WebSocketMessage[]; +}; + +export type WebSocketMessage = { + type: 'send' | 'receive'; + time: number; + opcode: number; + data: string; }; export type Request = { diff --git a/tests/library/channels.spec.ts b/tests/library/channels.spec.ts index 7bff98362dc15..04875b70953df 100644 --- a/tests/library/channels.spec.ts +++ b/tests/library/channels.spec.ts @@ -221,7 +221,7 @@ it('should not generate dispatchers for subresources w/o listeners', async ({ pa }); }); -it('should work with the domain module', async ({ browserType, server, browserName, channel }) => { +it('should work with the domain module', async ({ browserType, server, channel }) => { const local = domain.create(); local.run(() => { }); let err; @@ -241,10 +241,7 @@ it('should work with the domain module', async ({ browserType, server, browserNa new WebSocket('ws://' + host + '/bogus-ws'); }, server.HOST); const message = await result; - if (browserName === 'firefox') - expect(message).toBe('CLOSE_ABNORMAL'); - else - expect(message).toContain(channel?.includes('msedge') ? '' : ': 400'); + expect(message).toContain(channel?.includes('msedge') ? '' : ': 400'); await browser.close(); diff --git a/tests/library/har-websocket.spec.ts b/tests/library/har-websocket.spec.ts new file mode 100644 index 0000000000000..0bea2c53c53e5 --- /dev/null +++ b/tests/library/har-websocket.spec.ts @@ -0,0 +1,332 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications 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. + */ + +import { browserTest as it, expect } from '../config/browserTest'; +import fs from 'fs'; +import net from 'net'; +import type { BrowserContext, BrowserContextOptions } from 'playwright-core'; +import type { AddressInfo } from 'net'; +import type { Entry, Log } from '../../packages/trace/src/har'; + +async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise, testInfo: any, options: { outputPath?: string } & Partial> = {}) { + const harPath = testInfo.outputPath(options.outputPath || 'test.har'); + const context = await contextFactory({ recordHar: { path: harPath, ...options }, ignoreHTTPSErrors: true }); + const page = await context.newPage(); + return { + page, + context, + getLog: async () => { + await context.close(); + return JSON.parse(fs.readFileSync(harPath).toString())['log'] as Log; + }, + }; +} + +it('should only have one websocket entry', async ({ contextFactory, server, browserName }, testInfo) => { + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.close()); + }); + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(url => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('ping')); + ws.addEventListener('close', () => resolve()); + }), wsUrl); + await closed; + const log = await getLog(); + const wsEntries = log.entries.filter(e => e.request.url.endsWith(`://${server.HOST}/ws`))! as Entry[]; + expect(wsEntries.length).toBe(1); + + const wsEntry = wsEntries[0]; + expect(wsEntry._resourceType).toBe('websocket'); +}); + +it('should include websocket handshake headers and status', async ({ contextFactory, server, browserName }, testInfo) => { + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.close()); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(url => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('ping')); + ws.addEventListener('close', () => resolve()); + }), wsUrl); + await closed; + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry._resourceType).toBe('websocket'); + expect(wsEntry.response.status).toBe(101); + expect(wsEntry.response.statusText).toBe('Switching Protocols'); + + const requestHeaderNames = wsEntry.request.headers.map(h => h.name.toLowerCase()); + expect(requestHeaderNames).toContain('upgrade'); + expect(requestHeaderNames).toContain('connection'); + expect(requestHeaderNames).toContain('sec-websocket-key'); + expect(requestHeaderNames).toContain('sec-websocket-version'); + const upgradeHeader = wsEntry.request.headers.find(h => h.name.toLowerCase() === 'upgrade')!; + expect(upgradeHeader.value.toLowerCase()).toBe('websocket'); + + const responseHeaderNames = wsEntry.response.headers.map(h => h.name.toLowerCase()); + expect(responseHeaderNames).toContain('upgrade'); + expect(responseHeaderNames).toContain('connection'); + expect(responseHeaderNames).toContain('sec-websocket-accept'); +}); + +it('should include websocket messages', async ({ contextFactory, server }, testInfo) => { + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.send('incoming')); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const beforeMs = Date.now(); + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(url => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('outgoing')); + ws.addEventListener('message', () => ws.close()); + ws.addEventListener('close', () => resolve()); + }), wsUrl); + await closed; + const afterMs = Date.now(); + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry._resourceType).toBe('websocket'); + expect(wsEntry.response.status).toBe(101); + expect(wsEntry.response.statusText).toBe('Switching Protocols'); + + const messages = wsEntry._webSocketMessages; + expect(messages.map(m => ({ type: m.type, opcode: m.opcode, data: m.data }))).toEqual([ + { type: 'send', opcode: 1, data: 'outgoing' }, + { type: 'receive', opcode: 1, data: 'incoming' }, + ]); + for (const m of messages) { + expect(m.time).toBeGreaterThanOrEqual(beforeMs / 1000 - 1); + expect(m.time).toBeLessThanOrEqual(afterMs / 1000 + 1); + } + expect(messages[0].time).toBeLessThanOrEqual(messages[1].time); +}); + +it('should include binary websocket messages', async ({ contextFactory, server }, testInfo) => { + const incoming = [0x01, 0x02, 0x03, 0x04]; + const outgoing = [0x05, 0x06, 0x07, 0x08]; + + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.send(Buffer.from(incoming))); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const closed = page.evaluate(({ url, outgoing }) => new Promise(resolve => { + const ws = new WebSocket(url); + ws.binaryType = 'arraybuffer'; + ws.addEventListener('open', () => ws.send(new Uint8Array(outgoing))); + ws.addEventListener('message', () => ws.close()); + ws.addEventListener('close', () => resolve()); + }), { url: wsUrl, outgoing }); + await closed; + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry._resourceType).toBe('websocket'); + expect(wsEntry.response.status).toBe(101); + expect(wsEntry.response.statusText).toBe('Switching Protocols'); + + const messages = wsEntry._webSocketMessages; + expect(messages.length).toBe(2); + expect(messages[0].type).toBe('send'); + expect(messages[0].opcode).toBe(2); + expect([...Buffer.from(messages[0].data, 'base64')]).toEqual(outgoing); + expect(messages[1].type).toBe('receive'); + expect(messages[1].opcode).toBe(2); + expect([...Buffer.from(messages[1].data, 'base64')]).toEqual(incoming); +}); + +it('should record websocket connection failure', async ({ contextFactory, server }, testInfo) => { + // Reserve a port and immediately release it so the WebSocket connect attempt is refused. + const portReservation = net.createServer(); + await new Promise(resolve => portReservation.listen(0, '127.0.0.1', () => resolve())); + const port = (portReservation.address() as AddressInfo).port; + await new Promise(resolve => portReservation.close(() => resolve())); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://127.0.0.1:${port}/ws-connect-fail`; + await page.evaluate(url => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('close', () => resolve()); + ws.addEventListener('error', () => resolve()); + }), wsUrl); + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry._resourceType).toBe('websocket'); + expect(wsEntry.response._failureText).toBeTruthy(); +}); + +it('should record websocket handshake failure', async ({ contextFactory, server, browserName }, testInfo) => { + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws-handshake-fail`; + const upgradePromise = server.waitForUpgrade(); + const wsClose = page.evaluate(url => new Promise(resolve => { + const ws = new WebSocket(url); + ws.addEventListener('close', () => resolve()); + ws.addEventListener('error', () => resolve()); + }), wsUrl); + const { socket } = await upgradePromise; + socket.write('HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + await wsClose; + const log = await getLog(); + + const wsEntry = log.entries.find(e => e.request.url === wsUrl)! as Entry; + expect(wsEntry._resourceType).toBe('websocket'); + if (browserName !== 'chromium') { + // Chromium only reports an error instead of giving a status code and text. + expect(wsEntry.response.status).toBe(403); + expect(wsEntry.response.statusText).toBe('Forbidden'); + } + expect(wsEntry.response._failureText).toBeTruthy(); +}); + +it('should still capture websocket when route passes messages through', async ({ contextFactory, server }, testInfo) => { + server.onceWebSocketConnection(ws => { + ws.on('message', () => ws.send('incoming')); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + let routeHandlerCalled = 0; + await page.routeWebSocket(/\/ws$/, ws => { + ++routeHandlerCalled; + const serverRoute = ws.connectToServer(); + ws.onMessage(message => serverRoute.send(message)); + serverRoute.onMessage(message => ws.send(message)); + }); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const messages = await page.evaluate(url => new Promise(resolve => { + const seen: string[] = []; + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('outgoing')); + ws.addEventListener('message', event => { + seen.push(event.data); + ws.close(); + }); + ws.addEventListener('close', () => resolve(seen)); + }), wsUrl); + expect(routeHandlerCalled).toBe(1); + expect(messages).toEqual(['incoming']); + const log = await getLog(); + + const wsEntries = log.entries.filter(e => e.request.url === wsUrl)! as Entry[]; + expect(wsEntries.length).toBe(1); + expect(wsEntries[0]._resourceType).toBe('websocket'); + expect(wsEntries[0].response.status).toBe(101); + expect(wsEntries[0]._webSocketMessages.map(m => ({ type: m.type, data: m.data }))).toEqual([ + { type: 'send', data: 'outgoing' }, + { type: 'receive', data: 'incoming' }, + ]); +}); + +it('should still allow routeWebSocket to fully mock the connection when capturing HAR', async ({ contextFactory, server }, testInfo) => { + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + let routeHandlerCalled = 0; + await page.routeWebSocket(/\/ws$/, ws => { + ++routeHandlerCalled; + ws.onMessage(message => { + if (message === 'ping') + ws.send('pong'); + }); + }); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const messages = await page.evaluate(url => new Promise(resolve => { + const seen: string[] = []; + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('ping')); + ws.addEventListener('message', event => { + seen.push(event.data); + ws.close(); + }); + ws.addEventListener('close', () => resolve(seen)); + }), wsUrl); + expect(routeHandlerCalled).toBe(1); + expect(messages).toEqual(['pong']); + const log = await getLog(); + + // Fully mocked routes never create a native WebSocket, so nothing should be recorded. + const wsEntries = log.entries.filter(e => e.request.url === wsUrl); + expect(wsEntries).toEqual([]); +}); + +it('should still allow routeWebSocket to modify messages when capturing HAR', async ({ contextFactory, server }, testInfo) => { + server.onceWebSocketConnection(ws => { + ws.on('message', message => ws.send(`server-saw-${message.toString()}`)); + }); + + const { page, getLog } = await pageWithHar(contextFactory, testInfo); + let routeHandlerCalled = 0; + await page.routeWebSocket(/\/ws$/, ws => { + ++routeHandlerCalled; + const serverRoute = ws.connectToServer(); + ws.onMessage(message => serverRoute.send(`modified-${message.toString()}`)); + serverRoute.onMessage(message => ws.send(`page-got-${message.toString()}`)); + }); + await page.goto(server.EMPTY_PAGE); + + const wsUrl = `ws://${server.HOST}/ws`; + const messages = await page.evaluate(url => new Promise(resolve => { + const seen: string[] = []; + const ws = new WebSocket(url); + ws.addEventListener('open', () => ws.send('hello')); + ws.addEventListener('message', event => { + seen.push(event.data); + ws.close(); + }); + ws.addEventListener('close', () => resolve(seen)); + }), wsUrl); + expect(routeHandlerCalled).toBe(1); + // The page sees the route-modified server response. + expect(messages).toEqual(['page-got-server-saw-modified-hello']); + const log = await getLog(); + + // HAR records actual wire traffic from the native WebSocket: outgoing messages + // are modified by the client-side route handler before they hit the server, + // and incoming messages are recorded before the server-side route handler modifies them. + const wsEntries = log.entries.filter(e => e.request.url === wsUrl)! as Entry[]; + expect(wsEntries.length).toBe(1); + expect(wsEntries[0]._webSocketMessages.map(m => ({ type: m.type, data: m.data }))).toEqual([ + { type: 'send', data: 'modified-hello' }, + { type: 'receive', data: 'server-saw-modified-hello' }, + ]); +}); diff --git a/tests/library/route-web-socket.spec.ts b/tests/library/route-web-socket.spec.ts index eb0cb80beb726..d968889490b4a 100644 --- a/tests/library/route-web-socket.spec.ts +++ b/tests/library/route-web-socket.spec.ts @@ -18,6 +18,7 @@ import { attachFrame, detachFrame } from '../config/utils'; import { contextTest as test, expect } from '../config/browserTest'; import type { Frame, Page, WebSocketRoute } from '@playwright/test'; import { TestServer } from '../config/testserver'; +import type { WebSocket as WebSocketServer } from 'ws'; declare global { interface Window { @@ -245,6 +246,72 @@ test('should work with ws.close', async ({ page, server }) => { expect(await closedPromise).toEqual({ code: 3009, reason: 'oops' }); }); +test('should observe upstream handshake failure when connectToServer is used', async ({ page, server }) => { + // Exercises the WebSocket-as-network-request path used by Firefox after the + // har-WebSocket plumbing change, where a 4xx handshake response is synthesized + // into a full lifecycle in FFPage._onWebSocketRequestFinished. + const serverCloses: { code: number | undefined, reason: string | undefined }[] = []; + let routeHandlerInvoked = 0; + await page.routeWebSocket(/.*/, ws => { + ++routeHandlerInvoked; + const serverRoute = ws.connectToServer(); + serverRoute.onClose((code, reason) => { + serverCloses.push({ code, reason }); + void ws.close(); + }); + }); + + const upgradePromise = server.waitForUpgrade(); + await setupWS(page, server, 'blob'); + const { socket } = await upgradePromise; + socket.write('HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n'); + socket.destroy(); + + // Server-side route handler observes the close caused by the rejected handshake. + await expect.poll(() => serverCloses.length).toBe(1); + expect(serverCloses[0].code).toBeGreaterThanOrEqual(1000); + + // Once the route closes the page-side socket, the page sees the close. + await expect.poll(() => page.evaluate(() => window.ws.readyState)).toBe(3); + expect(routeHandlerInvoked).toBe(1); +}); + +test('should observe multiple concurrent routed WebSockets with connectToServer', async ({ page, server }) => { + // Exercises FFNetworkManager._webSocketRequestIds tracking by routing two + // simultaneous WebSocket connections through `connectToServer()`. Each request + // gets a distinct id in the new Firefox WebSocket-as-network-request flow. + let routedConnections = 0; + await page.routeWebSocket(/.*/, ws => { + ++routedConnections; + const serverRoute = ws.connectToServer(); + ws.onMessage(message => serverRoute.send(message)); + serverRoute.onMessage(message => ws.send(message)); + }); + + // Echo all incoming messages on whichever connection arrives; re-register after each. + const handleConnection = (ws: WebSocketServer) => { + ws.on('message', data => ws.send(`echo-${data.toString()}`)); + server.onceWebSocketConnection(handleConnection); + }; + server.onceWebSocketConnection(handleConnection); + + await page.goto(server.EMPTY_PAGE); + const results = await page.evaluate(async host => { + const collect = (tag: string) => new Promise(resolve => { + const ws = new WebSocket(`ws://${host}/ws`); + ws.addEventListener('open', () => ws.send(`hi-${tag}`)); + ws.addEventListener('message', event => { + resolve(event.data); + ws.close(); + }); + }); + return Promise.all([collect('a'), collect('b')]); + }, server.HOST); + + expect(results.sort()).toEqual(['echo-hi-a', 'echo-hi-b']); + expect(routedConnections).toBe(2); +}); + test('should pattern match', async ({ page, server }) => { await page.routeWebSocket(/.*\/ws$/, async ws => { ws.connectToServer(); diff --git a/tests/library/web-socket.spec.ts b/tests/library/web-socket.spec.ts index 4f49148794e3e..cacd0865e3ac2 100644 --- a/tests/library/web-socket.spec.ts +++ b/tests/library/web-socket.spec.ts @@ -137,7 +137,7 @@ it('should emit binary frame events', async ({ page, server }) => { expect(sent[1][i]).toBe(i); }); -it('should emit error', async ({ page, server, browserName, channel }) => { +it('should emit error', async ({ page, server, channel }) => { let callback; const result = new Promise(f => callback = f); page.on('websocket', ws => ws.on('socketerror', callback)); @@ -145,10 +145,7 @@ it('should emit error', async ({ page, server, browserName, channel }) => { new WebSocket('ws://' + host + '/bogus-ws'); }, server.HOST); const message = await result; - if (browserName === 'firefox') - expect(message).toBe('CLOSE_ABNORMAL'); - else - expect(message).toContain(channel?.includes('msedge') ? '' : ': 400'); + expect(message).toContain(channel?.includes('msedge') ? '' : ': 400'); }); it('should not have stray error events', async ({ page, server }) => {