From fd9395d740c2106acd7536b0af49b163e599d36a Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 23 Jul 2026 11:35:34 -0600 Subject: [PATCH 1/5] fix(webview): route `exposeFunctions` callbacks to the originating frame (#41919) WebKit has no `Runtime.addBinding`, so the webview delegate has to handle bindings through `console.debug(BINDING_CALL_TAG, payload)` instead the console message carries no execution context id, so every call was dispatched against the main frame, meaning a callback fired from a child frame (e.g. `frame.evaluate(..., { exposeFunctions: true })`) reached the wrong `BindingsController` and never resolved the calling frame's pending promise, hanging until the 30s timeout tore the page down tag each call with an empty object created in the calling context, as an `objectId` carries the injected script id, meaning that the host can route the call to the frame it came from and release the marker afterwards --- .../src/server/webkit/webview/wvPage.ts | 16 ++++++++++++++-- tests/page/page-evaluate-callback.spec.ts | 12 ++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/playwright-core/src/server/webkit/webview/wvPage.ts b/packages/playwright-core/src/server/webkit/webview/wvPage.ts index 8147a440e92ff..069a4af5fcf0a 100644 --- a/packages/playwright-core/src/server/webkit/webview/wvPage.ts +++ b/packages/playwright-core/src/server/webkit/webview/wvPage.ts @@ -538,7 +538,7 @@ export class WVPage implements PageDelegate { const [bindingName, bindingArg] = parameters; if (bindingName.value === BINDING_CALL_TAG && bindingArg.type === 'string') { - const context = [...this._contextIdToContext.values()].find(c => c.frame === this._page.mainFrame()); + const context = this._bindingCallContext(parameters); if (context) this._page.onBindingCalled(bindingArg.value, context).catch(e => debugLogger.log('error', e)); return; @@ -609,6 +609,15 @@ export class WVPage implements PageDelegate { this._onConsoleRepeatCountUpdated({ count: 1, timestamp: event.message.timestamp }); } + private _bindingCallContext(parameters: Protocol.Runtime.RemoteObject[]): dom.FrameExecutionContext | undefined { + const contextObject = parameters[2]; + if (contextObject?.objectId) { + this._session.sendMayFail('Runtime.releaseObject', { objectId: contextObject.objectId }); + return this._contextIdToContext.get(JSON.parse(contextObject.objectId).injectedScriptId); + } + return [...this._contextIdToContext.values()].find(c => c.frame === this._page.mainFrame()); + } + _onConsoleRepeatCountUpdated(event: Protocol.Console.messageRepeatCountUpdatedPayload) { if (this._lastConsoleMessage) { const { @@ -1261,7 +1270,10 @@ const bindingBridgeSource = ` Object.defineProperty(window, '${PageBinding.kBindingName}', { configurable: true, writable: false, - value: function(payload) { console.debug('${BINDING_CALL_TAG}', payload); }, + value: function(payload) { + const contextObject = {}; + console.debug('${BINDING_CALL_TAG}', payload, contextObject); + }, }); } `; diff --git a/tests/page/page-evaluate-callback.spec.ts b/tests/page/page-evaluate-callback.spec.ts index d7157f62cd1e9..1c4edf7100f22 100644 --- a/tests/page/page-evaluate-callback.spec.ts +++ b/tests/page/page-evaluate-callback.spec.ts @@ -132,6 +132,18 @@ it('should work in a child frame', async ({ page, server }) => { expect(received).toEqual([42]); }); +it('should route callbacks back to the calling frame', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + const frame = await attachFrame(page, 'frame1', server.EMPTY_PAGE); + const greet = async (where: string) => `hello ${where}`; + const [fromMain, fromChild] = await Promise.all([ + page.evaluate(async ({ cb }) => await cb('main'), { cb: greet }, { exposeFunctions: true }), + frame.evaluate(async ({ cb }) => await cb('child'), { cb: greet }, { exposeFunctions: true }), + ]); + expect(fromMain).toBe('hello main'); + expect(fromChild).toBe('hello child'); +}); + it('should work with jsHandle.evaluate', async ({ page }) => { const handle = await page.evaluateHandle(() => window); const received: number[] = []; From 3e14efd6b76d87f8ba67a5e437974064e4bd342d Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 23 Jul 2026 12:31:58 -0600 Subject: [PATCH 2/5] fix(registry): evaluate `defaultCacheDirectory` lazily (#41942) `defaultCacheDirectory` used to be computed inside the `PLAYWRIGHT_BROWSERS_PATH` branch of `registryDirectory`, so setting that variable skipped the platform check hoisted it into an eagerly evaluated module constant, so it now throws `Unsupported platform: ` at import, before `registryDirectory` consults `PLAYWRIGHT_BROWSERS_PATH` this regressed setups that point `PLAYWRIGHT_BROWSERS_PATH` at their own browsers on platforms without a default cache directory, such as Android/Termux evaluate `defaultCacheDirectory`, `defaultRegistryDirectory`, and `baseDaemonDir` lazily so an explicit `PLAYWRIGHT_BROWSERS_PATH` is honored before any default cache directory is computed `registryDirectory` still throws eagerly when no override is set, since there is nowhere to place browsers fixes --- .../src/server/registry/index.ts | 16 ++++-- .../playwright-core/src/serverRegistry.ts | 16 ++++-- .../src/tools/cli-client/registry.ts | 16 ++++-- .../src/tools/mcp/browserFactory.ts | 2 +- tests/library/browsers-path.spec.ts | 55 +++++++++++++++++++ 5 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 tests/library/browsers-path.spec.ts diff --git a/packages/playwright-core/src/server/registry/index.ts b/packages/playwright-core/src/server/registry/index.ts index 2b6e0141a7689..9b1583e3f3a9a 100644 --- a/packages/playwright-core/src/server/registry/index.ts +++ b/packages/playwright-core/src/server/registry/index.ts @@ -495,7 +495,7 @@ const DOWNLOAD_PATHS: Record = { }, }; -export const defaultCacheDirectory = (() => { +function computeDefaultCacheDirectory(): string { if (process.platform === 'linux') return process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); if (process.platform === 'darwin') @@ -503,9 +503,17 @@ export const defaultCacheDirectory = (() => { if (process.platform === 'win32') return process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); throw new Error('Unsupported platform: ' + process.platform); -})(); +} + +let _defaultCacheDirectory: string | undefined; -export const defaultRegistryDirectory = path.join(defaultCacheDirectory, 'ms-playwright'); +export function defaultCacheDirectory(): string { + return _defaultCacheDirectory ??= computeDefaultCacheDirectory(); +} + +export function defaultRegistryDirectory(): string { + return path.join(defaultCacheDirectory(), 'ms-playwright'); +} export const registryDirectory = (() => { let result: string; @@ -516,7 +524,7 @@ export const registryDirectory = (() => { else if (envDefined) result = envDefined; else - result = defaultRegistryDirectory; + result = defaultRegistryDirectory(); if (!path.isAbsolute(result)) { // It is important to resolve to the absolute path: diff --git a/packages/playwright-core/src/serverRegistry.ts b/packages/playwright-core/src/serverRegistry.ts index 3f5ddb0a74a67..3f43ad8d79494 100644 --- a/packages/playwright-core/src/serverRegistry.ts +++ b/packages/playwright-core/src/serverRegistry.ts @@ -171,7 +171,7 @@ class ServerRegistry extends EventEmitter { } private _browsersDir() { - return process.env.PWTEST_SERVER_REGISTRY || registryDirectory; + return process.env.PWTEST_SERVER_REGISTRY || registryDirectory(); } private _startWatcher() { @@ -242,7 +242,7 @@ async function canConnectTo(descriptor: BrowserDescriptor): Promise { }); } -const defaultCacheDirectory = (() => { +function computeDefaultCacheDirectory(): string { if (process.platform === 'linux') return process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); if (process.platform === 'darwin') @@ -250,8 +250,16 @@ const defaultCacheDirectory = (() => { if (process.platform === 'win32') return process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); throw new Error('Unsupported platform: ' + process.platform); -})(); +} + +let _defaultCacheDirectory: string | undefined; -const registryDirectory = path.join(defaultCacheDirectory, 'ms-playwright', 'b'); +function defaultCacheDirectory(): string { + return _defaultCacheDirectory ??= computeDefaultCacheDirectory(); +} + +function registryDirectory(): string { + return path.join(defaultCacheDirectory(), 'ms-playwright', 'b'); +} export const serverRegistry = new ServerRegistry(); diff --git a/packages/playwright-core/src/tools/cli-client/registry.ts b/packages/playwright-core/src/tools/cli-client/registry.ts index 76de68c4a601b..a9edb03236306 100644 --- a/packages/playwright-core/src/tools/cli-client/registry.ts +++ b/packages/playwright-core/src/tools/cli-client/registry.ts @@ -115,9 +115,9 @@ export class Registry { static async load(): Promise { const sessions = new Map(); - const hashDirs = await fs.promises.readdir(baseDaemonDir).catch(() => []); + const hashDirs = await fs.promises.readdir(baseDaemonDir()).catch(() => []); for (const workspaceDirHash of hashDirs) { - const daemonDir = path.join(baseDaemonDir, workspaceDirHash); + const daemonDir = path.join(baseDaemonDir(), workspaceDirHash); const stat = await fs.promises.stat(daemonDir); if (!stat.isDirectory()) continue; @@ -142,7 +142,7 @@ export class Registry { } } -export const baseDaemonDir = (() => { +function computeBaseDaemonDir(): string { if (process.env.PWTEST_DAEMON_SESSION_DIR) return process.env.PWTEST_DAEMON_SESSION_DIR; @@ -156,7 +156,13 @@ export const baseDaemonDir = (() => { if (!localCacheDir) throw new Error('Unsupported platform: ' + process.platform); return path.join(localCacheDir, 'ms-playwright', 'daemon'); -})(); +} + +let _baseDaemonDir: string | undefined; + +export function baseDaemonDir(): string { + return _baseDaemonDir ??= computeBaseDaemonDir(); +} export function createClientInfo(): ClientInfo { const workspaceDir = findWorkspaceDir(process.cwd()); @@ -189,7 +195,7 @@ function findWorkspaceDir(startDir: string): string | undefined { } const daemonProfilesDir = (workspaceDirHash: string) => { - return path.join(baseDaemonDir, workspaceDirHash); + return path.join(baseDaemonDir(), workspaceDirHash); }; export function explicitSessionName(sessionName?: string): string | undefined { diff --git a/packages/playwright-core/src/tools/mcp/browserFactory.ts b/packages/playwright-core/src/tools/mcp/browserFactory.ts index 9f593c31c03f8..eef9c2d903cb6 100644 --- a/packages/playwright-core/src/tools/mcp/browserFactory.ts +++ b/packages/playwright-core/src/tools/mcp/browserFactory.ts @@ -195,7 +195,7 @@ async function createPersistentBrowser(config: FullConfig, clientInfo: ClientInf } async function createUserDataDir(config: FullConfig, clientInfo: ClientInfo) { - const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? path.join(defaultCacheDirectory, 'ms-playwright-mcp'); + const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? path.join(defaultCacheDirectory(), 'ms-playwright-mcp'); const browserToken = config.browser.launchOptions?.channel ?? config.browser?.browserName; // Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead. const rootPathToken = createHash(clientInfo.cwd); diff --git a/tests/library/browsers-path.spec.ts b/tests/library/browsers-path.spec.ts new file mode 100644 index 0000000000000..e2d055c35d4b0 --- /dev/null +++ b/tests/library/browsers-path.spec.ts @@ -0,0 +1,55 @@ +/** + * 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 { playwrightTest as test, expect } from '../config/browserTest'; +import { spawnSync } from 'child_process'; +import path from 'path'; + +const playwrightCorePath = path.join(__dirname, '..', '..', 'packages', 'playwright-core'); + +// Importing playwright-core while pretending to run on an unsupported platform +// (e.g. Android/Termux, where `process.platform === 'android'`). +function requireCoreOnAndroid(browsersPath: string | undefined): { status: number | null, output: string } { + const script = [ + `Object.defineProperty(process, 'platform', { value: 'android' });`, + `require(${JSON.stringify(playwrightCorePath)});`, + `console.log('PLAYWRIGHT_CORE_LOADED');`, + ].join('\n'); + const env = { ...process.env }; + delete env.PLAYWRIGHT_BROWSERS_PATH; + if (browsersPath !== undefined) + env.PLAYWRIGHT_BROWSERS_PATH = browsersPath; + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8', env }); + return { status: result.status, output: (result.stdout || '') + (result.stderr || '') }; +} + +test('should import on an unsupported platform when PLAYWRIGHT_BROWSERS_PATH=0', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41852' }, +}, () => { + const { status, output } = requireCoreOnAndroid('0'); + expect(output).not.toContain('Unsupported platform'); + expect(output).toContain('PLAYWRIGHT_CORE_LOADED'); + expect(status).toBe(0); +}); + +test('should import on an unsupported platform with an explicit PLAYWRIGHT_BROWSERS_PATH', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41852' }, +}, () => { + const { status, output } = requireCoreOnAndroid(path.join(test.info().outputDir, 'pw-browsers')); + expect(output).not.toContain('Unsupported platform'); + expect(output).toContain('PLAYWRIGHT_CORE_LOADED'); + expect(status).toBe(0); +}); From c28b53e6242303eb52d88af6dffaebe0cc9d55d1 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 23 Jul 2026 20:22:49 +0100 Subject: [PATCH 3/5] feat(dashboard): add a debugger actions panel (#41711) --- packages/dashboard/src/dashboard.css | 19 +- packages/dashboard/src/dashboard.tsx | 298 ++++++++++-------- packages/dashboard/src/dashboardChannel.ts | 24 ++ packages/dashboard/src/dashboardModel.ts | 38 ++- packages/dashboard/src/debuggerPanel.css | 170 ++++++++++ packages/dashboard/src/debuggerPanel.tsx | 152 +++++++++ packages/isomorphic/protocolMetainfo.ts | 1 + .../playwright-core/src/client/channels.d.ts | 21 ++ .../playwright-core/src/client/debugger.ts | 7 + packages/playwright-core/src/client/events.ts | 3 +- .../playwright-core/src/server/channels.d.ts | 21 ++ .../playwright-core/src/server/debugger.ts | 123 +++++++- .../server/dispatchers/debuggerDispatcher.ts | 8 + .../tools/dashboard/dashboardController.ts | 282 +++++++++++++++-- packages/protocol/spec/playwright.yml | 30 ++ packages/protocol/src/validator.ts | 17 + tests/library/debugger.spec.ts | 177 +++++++++++ 17 files changed, 1220 insertions(+), 171 deletions(-) create mode 100644 packages/dashboard/src/debuggerPanel.css create mode 100644 packages/dashboard/src/debuggerPanel.tsx diff --git a/packages/dashboard/src/dashboard.css b/packages/dashboard/src/dashboard.css index 288a4fbdce08e..2da2f34e02c00 100644 --- a/packages/dashboard/src/dashboard.css +++ b/packages/dashboard/src/dashboard.css @@ -53,6 +53,23 @@ box-shadow: 0 0 0 2px rgb(var(--recording-red) / 0.35); } +.mode-toggle.toolbar-button.toggled.mode-debugger, +.mode-toggle.toolbar-button.toggled.mode-debugger > .codicon { + color: var(--color-accent-fg); +} + +.mode-toggle.toolbar-button.toggled.mode-debugger { + background: var(--color-accent-subtle); + box-shadow: none; +} + +.debugger-paused-label { + flex: none; + font-weight: 600; + color: var(--color-attention-fg); + white-space: nowrap; +} + .mode-toggle.flash { animation: mode-toggle-flash 0.45s ease-in-out 4; } @@ -75,7 +92,7 @@ height: 40px; min-height: 40px; background: var(--color-canvas-overlay); - padding: 0 8px; + padding: 0 12px; position: relative; z-index: 100; border-bottom: 1px solid var(--color-border-muted); diff --git a/packages/dashboard/src/dashboard.tsx b/packages/dashboard/src/dashboard.tsx index d4a0adf380ba5..e9f5473344118 100644 --- a/packages/dashboard/src/dashboard.tsx +++ b/packages/dashboard/src/dashboard.tsx @@ -20,8 +20,10 @@ import { ChevronLeftIcon, ChevronRightIcon, LockIcon, LockOpenIcon, ReloadIcon, import { clientToViewport, getImageLayout } from './imageLayout'; import { Recording } from './recording'; import { AnnotateSidebar, AnnotateOverlay } from './annotateView'; +import { DebuggerPanel } from './debuggerPanel'; import { ToolbarButton } from '@web/components/toolbarButton'; +import { SplitView } from '@web/components/splitView'; import { useMeasureForRef } from '@web/uiUtils'; import type { DashboardModel } from './dashboardModel'; @@ -66,7 +68,7 @@ export const Dashboard: React.FC = ({ model }) => { const [, setRevision] = React.useState(0); React.useEffect(() => model.subscribe(() => setRevision(r => r + 1)), [model]); - const { tabs, mode, recording, liveFrame, annotateSession, pendingCapture } = model.state; + const { tabs, mode, recording, liveFrame, annotateSession, pendingCapture, debuggerPanelOpen, debuggerPaused, apiCalls } = model.state; const interactive = mode === 'interactive'; const annotateActive = !!annotateSession; const selectedFrame = annotateSession?.frames.find(f => f.id === annotateSession.selectedFrameId) ?? null; @@ -234,145 +236,175 @@ export const Dashboard: React.FC = ({ model }) => { const modeLabel = annotateActive ? 'Dashboard: annotate' : isRecording ? 'Dashboard: record' : 'Dashboard'; const overlayOpen = !!selectedFrame; + + let actionPointStyle: React.CSSProperties | undefined; + if (debuggerPanelOpen && liveFrame?.viewportWidth && liveFrame?.viewportHeight) { + const call = [...apiCalls].reverse().find(c => c.actionPoint && c.status === 'running'); + if (call?.actionPoint) { + actionPointStyle = { + left: `${(call.actionPoint.x / liveFrame.viewportWidth) * 100}%`, + top: `${(call.actionPoint.y / liveFrame.viewportHeight) * 100}%`, + }; + } + } + return (
-
- {/* Toolbar */} - - - {/* Viewport */} -
-
-
- {showBrowserChrome && ( -
- - - -
- setOmniboxValue(e.target.value)} - onKeyDown={e => { - if (!interactive) - return; - onOmniboxKeyDown(e); - }} - onFocus={e => { - if (!interactive) { - flashInteractiveHint(); - e.target.blur(); - return; - } - e.target.select(); - }} - aria-disabled={!interactive || undefined} - aria-label={interactive ? 'Search or enter URL' : 'URL input - enable interactive mode to use this field'} - readOnly={!interactive} - /> + } + main={
+ {/* Toolbar */} + + + {/* Viewport */} +
+
+
+ {showBrowserChrome && ( +
+ + + +
+ setOmniboxValue(e.target.value)} + onKeyDown={e => { + if (!interactive) + return; + onOmniboxKeyDown(e); + }} + onFocus={e => { + if (!interactive) { + flashInteractiveHint(); + e.target.blur(); + return; + } + e.target.select(); + }} + aria-disabled={!interactive || undefined} + aria-label={interactive ? 'Search or enter URL' : 'URL input - enable interactive mode to use this field'} + readOnly={!interactive} + /> +
+ )} +
e.preventDefault()} + > + screencast + {actionPointStyle &&
}
- )} -
e.preventDefault()} - > - screencast + {overlayText &&
{overlayText}
}
- {overlayText &&
{overlayText}
}
-
-
+
} + /> {selectedFrame && ( ; submitAnnotation(params: { frames: SubmittedAnnotationFrame[]; feedback: string }): Promise; cancelAnnotation(): Promise; + debuggerResume(): Promise; + debuggerPause(): Promise; + debuggerStep(): Promise; on(event: K, listener: (params: DashboardChannelEvents[K]) => void): void; off(event: K, listener: (params: DashboardChannelEvents[K]) => void): void; diff --git a/packages/dashboard/src/dashboardModel.ts b/packages/dashboard/src/dashboardModel.ts index 30f4c38bf04b9..ecb05edf338ef 100644 --- a/packages/dashboard/src/dashboardModel.ts +++ b/packages/dashboard/src/dashboardModel.ts @@ -18,7 +18,7 @@ import { buildAnnotatedImage, saveAnnotationAsDownload } from './annotationImage import { buildAnnotationZip } from './annotationZip'; import type { Annotation } from './annotations'; -import type { DashboardChannel, DashboardChannelEvents, MouseButton, SessionStatus, SubmittedAnnotationFrame, Tab } from './dashboardChannel'; +import type { ApiCall, DashboardChannel, DashboardChannelEvents, DebuggerSource, MouseButton, SessionStatus, SubmittedAnnotationFrame, Tab } from './dashboardChannel'; import type { ClientInfo } from '../../playwright-core/src/tools/cli-client/registry'; import type { BrowserDescriptor } from '../../playwright-core/src/serverRegistry'; @@ -62,6 +62,14 @@ export type DashboardState = { pendingCapture: boolean; mode: Mode; recording: RecordingState | null; + // Debugger / actions panel. + debuggerPanelOpen: boolean; + apiCalls: ApiCall[]; + debuggerPaused: boolean; + // Pause was requested, but the debugger has not paused yet — it engages + // before the next action. + debuggerPauseRequested: boolean; + debuggerSource: DebuggerSource | null; }; type Listener = () => void; @@ -76,6 +84,11 @@ const initialState: DashboardState = { pendingCapture: false, mode: 'readonly', recording: null, + debuggerPanelOpen: false, + apiCalls: [], + debuggerPaused: false, + debuggerPauseRequested: false, + debuggerSource: null, }; export class DashboardModel { @@ -94,6 +107,9 @@ export class DashboardModel { client.on('frame', params => this._emit({ liveFrame: params })); client.on('annotate', () => this.enterAnnotate('cli')); client.on('cancelAnnotate', () => this.cancelAnnotate(false)); + client.on('apiCalls', params => this._emit({ apiCalls: params.apiCalls })); + client.on('debuggerPaused', params => this._emit({ debuggerPaused: params.paused, debuggerPauseRequested: params.paused ? false : this.state.debuggerPauseRequested })); + client.on('debuggerSource', params => this._emit({ debuggerSource: params.source })); } subscribe(listener: Listener): () => void { @@ -268,6 +284,26 @@ export class DashboardModel { void this._discardRecording(); } + // Debugger / actions panel. + + toggleDebuggerPanel() { + this._emit({ debuggerPanelOpen: !this.state.debuggerPanelOpen }); + } + + debuggerResume() { + this._emit({ debuggerPauseRequested: false }); + void this._client.debuggerResume(); + } + + debuggerPause() { + this._emit({ debuggerPauseRequested: true }); + void this._client.debuggerPause(); + } + + debuggerStep() { + void this._client.debuggerStep(); + } + cancelAnnotate(notifyServer = true) { this._requestId++; const s = this.state; diff --git a/packages/dashboard/src/debuggerPanel.css b/packages/dashboard/src/debuggerPanel.css new file mode 100644 index 0000000000000..7ddff7631ad75 --- /dev/null +++ b/packages/dashboard/src/debuggerPanel.css @@ -0,0 +1,170 @@ +/** + * 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. + */ + +.debugger-panel { + display: flex; + flex-direction: column; + flex: auto; + width: 100%; + height: 100%; + background: var(--color-canvas-default); + overflow: hidden; +} + +.debugger-panel-title { + font-weight: 600; + padding: 0 4px; +} + +.debugger-panel .debugger-run-control:not(:disabled), +.debugger-panel .debugger-run-control:not(:disabled) .codicon { + color: var(--color-success-fg); +} + +.debugger-panel.paused .toolbar, +.debugger-panel.pause-pending .toolbar { + background: var(--color-attention-subtle); +} + +.debugger-status { + flex: none; + padding: 0 8px; + font-weight: 500; + color: var(--color-attention-fg); + white-space: nowrap; +} + +.debugger-call-log { + display: flex; + flex-direction: column; + flex: auto; + width: 100%; + min-width: 0; + min-height: 0; + line-height: 20px; + white-space: pre; + overflow: auto; +} + +.debugger-source { + display: flex; + flex-direction: column; + flex: auto; + width: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.debugger-source-header { + flex: none; + padding: 2px 8px; + font-weight: 500; + color: var(--color-fg-muted); + border-bottom: 1px solid var(--color-border-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.debugger-source .cm-wrapper, +.debugger-source .CodeMirror { + flex: auto; + min-height: 0; +} + +.debugger-empty { + padding: 12px; + color: var(--color-fg-muted); + white-space: normal; +} + +.debugger-call { + display: flex; + flex: none; + flex-direction: column; + border-top: 1px solid var(--color-border-muted); +} + +.debugger-call-header { + min-height: 24px; + display: flex; + align-items: center; + padding: 0 2px; + gap: 2px; +} + +.debugger-call-header.expandable { + cursor: pointer; +} + +.debugger-call-header.expandable:hover { + background: var(--color-canvas-subtle); +} + +.debugger-call .codicon { + padding: 0 4px; + flex: none; +} + +.debugger-call-title { + flex: none; + font-weight: 500; +} + +.debugger-call-location { + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-fg-muted); +} + +.debugger-call .codicon-loading { + margin-left: auto; +} + +.debugger-call .codicon-error { + color: var(--color-danger-fg, #cd3131); + margin-left: auto; +} + +.debugger-call.error { + background-color: var(--color-danger-subtle, rgba(205, 49, 49, 0.1)); +} + +.debugger-call-message { + flex: none; + padding: 3px 0 3px 36px; + display: flex; + align-items: center; + color: var(--color-fg-muted); +} + +.debugger-call-message.error { + color: var(--color-danger-fg, #cd3131); + white-space: pre-wrap; +} + +.debugger-action-point { + position: absolute; + width: 2%; + aspect-ratio: 1; + transform: translate(-50%, -50%); + background: red; + border-radius: 50%; + pointer-events: none; + z-index: 11; +} diff --git a/packages/dashboard/src/debuggerPanel.tsx b/packages/dashboard/src/debuggerPanel.tsx new file mode 100644 index 0000000000000..e8a4659500637 --- /dev/null +++ b/packages/dashboard/src/debuggerPanel.tsx @@ -0,0 +1,152 @@ +/** + * 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 React from 'react'; +import './debuggerPanel.css'; +import { Toolbar } from '@web/components/toolbar'; +import { ToolbarButton } from '@web/components/toolbarButton'; +import { SplitView } from '@web/components/splitView'; +import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; +import { clsx } from '@web/uiUtils'; + +import type { ApiCall, DebuggerSource } from './dashboardChannel'; +import type { DashboardModel } from './dashboardModel'; + +type DebuggerPanelProps = { + model: DashboardModel; +}; + +// Modeled after the recorder's CallLogView + debug toolbar (packages/recorder/src). +export const DebuggerPanel: React.FC = ({ model }) => { + const { apiCalls, debuggerPaused, debuggerPauseRequested, debuggerSource } = model.state; + const callLogRef = React.useRef(null); + const messagesEndRef = React.useRef(null); + // Follow the log only while the user is already near the bottom, so that + // live updates do not steal a scrolled-up position. + const stickToBottomRef = React.useRef(true); + // Explicit expand/collapse overrides per call id; when absent, the default is + // driven by status (running/error expanded, success collapsed). + const [expandOverrides, setExpandOverrides] = React.useState>(new Map()); + + React.useLayoutEffect(() => { + if (stickToBottomRef.current) + messagesEndRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }, [apiCalls]); + + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'F8') { + event.preventDefault(); + if (debuggerPaused) + model.debuggerResume(); + else + model.debuggerPause(); + } else if (event.key === 'F10' && debuggerPaused) { + event.preventDefault(); + model.debuggerStep(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [debuggerPaused, model]); + + const pausePending = debuggerPauseRequested && !debuggerPaused; + return ( +
+ +
Actions
+ model.debuggerResume()} /> + model.debuggerPause()} /> + model.debuggerStep()} /> + {debuggerPaused &&
Paused
} + {pausePending &&
Pausing before the next action…
} +
+
+ { + const element = callLogRef.current!; + stickToBottomRef.current = element.scrollTop + element.clientHeight >= element.scrollHeight - 40; + }}> + {apiCalls.length === 0 &&
No actions yet
} + {apiCalls.map(call => { + const hasDetails = call.logs.length > 0 || !!call.error; + const override = expandOverrides.get(call.id); + const isExpanded = hasDetails && (typeof override === 'boolean' ? override : call.status !== 'success'); + return ( +
+
{ + if (!hasDetails) + return; + const next = new Map(expandOverrides); + next.set(call.id, !isExpanded); + setExpandOverrides(next); + }}> + + {call.title} + {call.location && {locationLabel(call.location)}} + {call.status !== 'success' && } +
+ {isExpanded && call.logs.map((message, i) => ( +
{message.trim()}
+ ))} + {!!call.error && } +
+ ); + })} +
+
} + sidebar={} + /> +
+ ); +}; + +const SourceView: React.FC<{ source: DebuggerSource | null }> = ({ source }) => { + if (!source) { + return
+
No source
+
; + } + const file = source.file.split(/[\\/]/).pop() ?? source.file; + return
+
{file}
+ +
; +}; + +function iconClass(status: Exclude): string { + switch (status) { + case 'running': return 'codicon-loading codicon-modifier-spin'; + case 'error': return 'codicon-error'; + } +} + +function locationLabel(location: NonNullable): string { + const file = location.file.split(/[\\/]/).pop() ?? location.file; + return location.line ? `${file}:${location.line}` : file; +} diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index c8897fdf9f33a..29fbd276b645a 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -331,6 +331,7 @@ export const methodMetainfo = new Map([ ['Debugger.resume', { title: 'Resume', group: 'configuration', }], ['Debugger.next', { title: 'Step to next call', group: 'configuration', }], ['Debugger.runTo', { title: 'Run to location', group: 'configuration', }], + ['Debugger.enable', { internal: true, }], ['Dialog.accept', { title: 'Accept dialog', }], ['Dialog.dismiss', { title: 'Dismiss dialog', }], ['Tracing.tracingStart', { title: 'Start tracing', group: 'configuration', }], diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 5f00e885856c3..ddc8a9125ce4d 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -5000,6 +5000,7 @@ export interface BindingCallEvents { export type DebuggerInitializer = {}; export interface DebuggerEventTarget { on(event: 'pausedStateChanged', callback: (params: DebuggerPausedStateChangedEvent) => void): this; + on(event: 'apiCallsUpdated', callback: (params: DebuggerApiCallsUpdatedEvent) => void): this; } export interface DebuggerChannel extends DebuggerEventTarget, Channel { _type_Debugger: boolean; @@ -5007,6 +5008,7 @@ export interface DebuggerChannel extends DebuggerEventTarget, Channel { resume(params: DebuggerResumeParams, options: TimeoutOptions): Promise; next(params: DebuggerNextParams, options: TimeoutOptions): Promise; runTo(params: DebuggerRunToParams, options: TimeoutOptions): Promise; + enable(params: DebuggerEnableParams, options: TimeoutOptions): Promise; } export type DebuggerPausedStateChangedEvent = { pausedDetails?: { @@ -5019,6 +5021,21 @@ export type DebuggerPausedStateChangedEvent = { stack?: string, }, }; +export type DebuggerApiCallsUpdatedEvent = { + apiCalls: { + id: string, + title: string, + location?: { + file: string, + line?: number, + column?: number, + }, + newLogEntries: string[], + actionPoint?: Point, + status: 'running' | 'success' | 'error', + error?: string, + }[], +}; export type DebuggerRequestPauseParams = {}; export type DebuggerRequestPauseOptions = {}; export type DebuggerRequestPauseResult = void; @@ -5039,9 +5056,13 @@ export type DebuggerRunToOptions = { }; export type DebuggerRunToResult = void; +export type DebuggerEnableParams = {}; +export type DebuggerEnableOptions = {}; +export type DebuggerEnableResult = void; export interface DebuggerEvents { 'pausedStateChanged': DebuggerPausedStateChangedEvent; + 'apiCallsUpdated': DebuggerApiCallsUpdatedEvent; } // ----------- Dialog ----------- diff --git a/packages/playwright-core/src/client/debugger.ts b/packages/playwright-core/src/client/debugger.ts index 7c68e1ee91592..4fb2e6f372127 100644 --- a/packages/playwright-core/src/client/debugger.ts +++ b/packages/playwright-core/src/client/debugger.ts @@ -36,6 +36,13 @@ export class Debugger extends ChannelOwner implements this._pausedDetails = pausedDetails ?? null; this.emit(Events.Debugger.PausedStateChanged); }); + this._channel.on('apiCallsUpdated', ({ apiCalls }) => { + this.emit(Events.Debugger.ApiCallsUpdated, apiCalls); + }); + } + + async _enable(): Promise { + await this._channel.enable({}, kNoTimeout); } async requestPause(): Promise { diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index c69b3aa5a47b6..86ee9201d13cb 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -36,7 +36,8 @@ export const Events = { }, Debugger: { - PausedStateChanged: 'pausedstatechanged' + PausedStateChanged: 'pausedstatechanged', + ApiCallsUpdated: 'apicallsupdated', }, BrowserContext: { diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index bb47a21310a93..9ce45772cbaaa 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -5001,6 +5001,7 @@ export interface BindingCallEvents { export type DebuggerInitializer = {}; export interface DebuggerEventTarget { _dispatchEvent(event: 'pausedStateChanged', params?: DebuggerPausedStateChangedEvent): void; + _dispatchEvent(event: 'apiCallsUpdated', params?: DebuggerApiCallsUpdatedEvent): void; } export interface DebuggerChannel extends DebuggerEventTarget, Channel { _type_Debugger: boolean; @@ -5008,6 +5009,7 @@ export interface DebuggerChannel extends DebuggerEventTarget, Channel { resume(params: DebuggerResumeParams, progress: Progress): Promise; next(params: DebuggerNextParams, progress: Progress): Promise; runTo(params: DebuggerRunToParams, progress: Progress): Promise; + enable(params: DebuggerEnableParams, progress: Progress): Promise; } export type DebuggerPausedStateChangedEvent = { pausedDetails?: { @@ -5020,6 +5022,21 @@ export type DebuggerPausedStateChangedEvent = { stack?: string, }, }; +export type DebuggerApiCallsUpdatedEvent = { + apiCalls: { + id: string, + title: string, + location?: { + file: string, + line?: number, + column?: number, + }, + newLogEntries: string[], + actionPoint?: Point, + status: 'running' | 'success' | 'error', + error?: string, + }[], +}; export type DebuggerRequestPauseParams = {}; export type DebuggerRequestPauseOptions = {}; export type DebuggerRequestPauseResult = void; @@ -5040,9 +5057,13 @@ export type DebuggerRunToOptions = { }; export type DebuggerRunToResult = void; +export type DebuggerEnableParams = {}; +export type DebuggerEnableOptions = {}; +export type DebuggerEnableResult = void; export interface DebuggerEvents { 'pausedStateChanged': DebuggerPausedStateChangedEvent; + 'apiCallsUpdated': DebuggerApiCallsUpdatedEvent; } // ----------- Dialog ----------- diff --git a/packages/playwright-core/src/server/debugger.ts b/packages/playwright-core/src/server/debugger.ts index aec3ecbdeeee0..32ce4fa5ccdb6 100644 --- a/packages/playwright-core/src/server/debugger.ts +++ b/packages/playwright-core/src/server/debugger.ts @@ -15,27 +15,59 @@ */ import { getMetainfo } from '@isomorphic/protocolMetainfo'; +import { renderTitleForCall } from '@isomorphic/protocolFormatter'; import { monotonicTime } from '@isomorphic/time'; import { SdkObject } from './instrumentation'; import { BrowserContext } from './browserContext'; import type { CallMetadata, InstrumentationListener } from './instrumentation'; import type { Progress } from './progress'; +import type { Point } from './types'; const symbol = Symbol('Debugger'); +const kApiCallsFlushDelay = 500; + +const DebuggerEvent = { + PausedStateChanged: 'pausedstatechanged', + ApiCallsUpdated: 'apicallsupdated', +} as const; type PauseAt = { next?: boolean, location?: { file: string, line?: number, column?: number } }; -export class Debugger extends SdkObject implements InstrumentationListener { +export type ApiCallUpdate = { + id: string; + title: string; + location?: { file: string, line?: number, column?: number }; + newLogEntries: string[]; + actionPoint?: Point; + status: 'running' | 'success' | 'error'; + error?: string; +}; + +type OngoingCall = { + metadata: CallMetadata; + actionPoint?: Point; + sentLogCount: number; + status: 'running' | 'success' | 'error'; +}; + +type DebuggerEventMap = { + [DebuggerEvent.PausedStateChanged]: []; + [DebuggerEvent.ApiCallsUpdated]: [apiCalls: ApiCallUpdate[]]; +}; + +export class Debugger extends SdkObject implements InstrumentationListener { + static Events = DebuggerEvent; + private _pauseAt: PauseAt = {}; private _pausedCall: { metadata: CallMetadata, sdkObject: SdkObject, resolve: () => void } | undefined; private _enabled = false; private _pauseBeforeWaitingActions = false; // instead of inside input actions private _context: BrowserContext; - - static Events = { - PausedStateChanged: 'pausedstatechanged' - }; + private _apiCallsEnabled = false; + private _ongoingCalls = new Map(); + private _apiCallsWithPendingUpdates = new Set(); + private _apiCallsFlushTimer: NodeJS.Timeout | undefined; private _muted = false; constructor(context: BrowserContext) { @@ -47,6 +79,8 @@ export class Debugger extends SdkObject implements InstrumentationListener { context.instrumentation.addListener(this, context, { order: 'last' }); this._context.once(BrowserContext.Events.Close, () => { this._context.instrumentation.removeListener(this); + if (this._apiCallsFlushTimer) + clearTimeout(this._apiCallsFlushTimer); }); } @@ -84,6 +118,12 @@ export class Debugger extends SdkObject implements InstrumentationListener { } async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + if (!metadata.internal && metadata.method) + this._ongoingCalls.set(metadata.id, { metadata, sentLogCount: 0, status: 'running' }); + if (this._apiCallsEnabled) { + this._apiCallsWithPendingUpdates.add(metadata.id); + this._flushApiCalls(); + } if (this._muted || metadata.internal) return; const metainfo = getMetainfo(metadata); @@ -94,7 +134,15 @@ export class Debugger extends SdkObject implements InstrumentationListener { await this._pause(sdkObject, metadata); } - async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { + async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, point?: Point): Promise { + const call = this._ongoingCalls.get(metadata.id); + if (call) { + call.actionPoint = point; + if (this._apiCallsEnabled) { + this._apiCallsWithPendingUpdates.add(metadata.id); + this._flushApiCalls(); + } + } if (this._muted || metadata.internal) return; const metainfo = getMetainfo(metadata); @@ -103,6 +151,67 @@ export class Debugger extends SdkObject implements InstrumentationListener { await this._pause(sdkObject, metadata); } + async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + const call = this._ongoingCalls.get(metadata.id); + if (!call) + return; + call.status = metadata.error ? 'error' : 'success'; + if (this._apiCallsEnabled) { + this._apiCallsWithPendingUpdates.add(metadata.id); + this._flushApiCalls(); + } + this._ongoingCalls.delete(metadata.id); + } + + onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): void { + if (this._apiCallsEnabled && this._ongoingCalls.has(metadata.id)) { + this._apiCallsWithPendingUpdates.add(metadata.id); + this._scheduleApiCallsFlush(); + } + } + + enableApiCalls() { + if (this._apiCallsEnabled) + return; + this._apiCallsEnabled = true; + this._pauseBeforeWaitingActions = false; + for (const id of this._ongoingCalls.keys()) + this._apiCallsWithPendingUpdates.add(id); + this._flushApiCalls(); + } + + private _scheduleApiCallsFlush() { + if (this._apiCallsFlushTimer || !this._apiCallsWithPendingUpdates.size) + return; + this._apiCallsFlushTimer = setTimeout(() => this._flushApiCalls(), kApiCallsFlushDelay); + } + + private _flushApiCalls() { + if (this._apiCallsFlushTimer) { + clearTimeout(this._apiCallsFlushTimer); + this._apiCallsFlushTimer = undefined; + } + const updates: ApiCallUpdate[] = []; + for (const id of this._apiCallsWithPendingUpdates) { + const call = this._ongoingCalls.get(id); + if (!call) + continue; + updates.push({ + id: call.metadata.id, + title: renderTitleForCall(call.metadata) ?? '', + location: call.metadata.location, + newLogEntries: call.metadata.log.slice(call.sentLogCount), + actionPoint: call.actionPoint, + status: call.metadata.error ? 'error' : call.status, + error: call.metadata.error?.error?.message, + }); + call.sentLogCount = call.metadata.log.length; + } + this._apiCallsWithPendingUpdates.clear(); + if (updates.length) + this.emit(Debugger.Events.ApiCallsUpdated, updates); + } + private async _pause(sdkObject: SdkObject, metadata: CallMetadata) { if (this._muted || metadata.internal) return; @@ -128,6 +237,8 @@ export class Debugger extends SdkObject implements InstrumentationListener { } setPauseBeforeWaitingActions() { + if (this._apiCallsEnabled) + return; this._pauseBeforeWaitingActions = true; } diff --git a/packages/playwright-core/src/server/dispatchers/debuggerDispatcher.ts b/packages/playwright-core/src/server/dispatchers/debuggerDispatcher.ts index 40626750b1728..1c9522415621f 100644 --- a/packages/playwright-core/src/server/dispatchers/debuggerDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/debuggerDispatcher.ts @@ -19,6 +19,7 @@ import { Dispatcher } from './dispatcher'; import { Debugger } from '../debugger'; import type { BrowserContextDispatcher } from './browserContextDispatcher'; +import type { ApiCallUpdate } from '../debugger'; import type * as channels from '../channels'; import type { Progress } from '../progress'; @@ -35,6 +36,9 @@ export class DebuggerDispatcher extends Dispatcher { this._dispatchEvent('pausedStateChanged', { pausedDetails: this._serializePausedDetails() }); }); + this.addObjectListener(Debugger.Events.ApiCallsUpdated, (apiCalls: ApiCallUpdate[]) => { + this._dispatchEvent('apiCallsUpdated', { apiCalls }); + }); this._dispatchEvent('pausedStateChanged', { pausedDetails: this._serializePausedDetails() }); } @@ -68,4 +72,8 @@ export class DebuggerDispatcher extends Dispatcher { this._object.runTo(progress, params.location); } + + async enable(params: channels.DebuggerEnableParams, progress: Progress): Promise { + this._object.enableApiCalls(); + } } diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index 210586802221e..0e263c65e9538 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -27,10 +27,36 @@ import { SessionProviderEvent } from './sessionProvider'; import type * as api from '../../..'; import type { Transport } from '@utils/httpServer'; -import type { SubmittedAnnotationFrame, Tab } from '@dashboard/dashboardChannel'; +import type { ApiCall, DebuggerSource, SubmittedAnnotationFrame, Tab } from '@dashboard/dashboardChannel'; import type { BrowserDescriptor } from '../../serverRegistry'; import type { SessionProvider } from './sessionProvider'; +// Incremental API-call payload emitted by the (private) Debugger.apiCallsUpdated event. +type ApiCallDelta = { + id: string; + title: string; + location?: { file: string; line?: number; column?: number }; + newLogEntries: string[]; + actionPoint?: { x: number; y: number }; + status: 'running' | 'success' | 'error'; + error?: string; +}; + +function languageForFile(file: string): DebuggerSource['language'] { + if (file.endsWith('.py')) + return 'python'; + if (file.endsWith('.java')) + return 'java'; + if (file.endsWith('.cs')) + return 'csharp'; + return 'javascript'; +} + +function wrapInternal(target: api.BrowserContext | api.Page, func: () => Promise): Promise { + // eslint-disable-next-line no-restricted-syntax + return (target as any)._wrapApiCall(func, { internal: true }); +} + export type AnnotateResult = | { type: 'submitted', frames: SubmittedAnnotationFrame[], feedback: string } | { type: 'cancelled' }; @@ -41,6 +67,8 @@ export class DashboardConnection implements Transport { private _provider: SessionProvider; private _attachedPage: AttachedPage | undefined; + private _contextDebuggers = new Map(); + private _activeContextDebugger: ContextDebugger | undefined; private _onclose: () => void; private _onconnected?: () => void; private _pushTabsScheduled = false; @@ -68,6 +96,13 @@ export class DashboardConnection implements Transport { void this._tryRevealPending(); }); this._provider.on(SessionProviderEvent.ContextClosed, context => { + const contextDebugger = this._contextDebuggers.get(context); + if (contextDebugger) { + contextDebugger.dispose(); + this._contextDebuggers.delete(context); + if (this._activeContextDebugger === contextDebugger) + this._activeContextDebugger = undefined; + } if (this._attachedPage?.page.context() === context) { this._attachedPage.dispose(); this._attachedPage = undefined; @@ -82,6 +117,10 @@ export class DashboardConnection implements Transport { this._provider.dispose(); this._attachedPage?.dispose(); this._attachedPage = undefined; + for (const contextDebugger of this._contextDebuggers.values()) + contextDebugger.dispose(); + this._contextDebuggers.clear(); + this._activeContextDebugger = undefined; this._pendingReveal = undefined; this._resolvePendingAnnotate({ type: 'cancelled' }); for (const stream of this._streams.values()) { @@ -120,14 +159,15 @@ export class DashboardConnection implements Transport { const context = this._provider.findContext(params); if (!context) return; - const page = await context.newPage(); + const page = await wrapInternal(context, () => context.newPage()); await this._switchAttachedTo(page); this._pushTabs(); } async closeTab(params: { browser: string; context: string; page: string }) { const page = this._provider.findPage(params); - await page?.close({ reason: 'Closed in Dashboard' }); + if (page) + await wrapInternal(page, () => page.close({ reason: 'Closed in Dashboard' })); } async closeSession(params: { browser: string }) { @@ -141,6 +181,22 @@ export class DashboardConnection implements Transport { await this._attachedPage?.setScreencastActive(params.visible); } + async debuggerResume() { + await this._activeContextDebugger?.resume(); + } + + async debuggerPause() { + await this._activeContextDebugger?.pause(); + } + + async debuggerStep() { + await this._activeContextDebugger?.step(); + } + + activeContextDebugger(): ContextDebugger | undefined { + return this._activeContextDebugger; + } + revealSession(sessionName: string, workspaceDir?: string) { this._pendingReveal = { sessionName, workspaceDir }; void this._tryRevealPending(); @@ -228,6 +284,18 @@ export class DashboardConnection implements Transport { this.sendEvent?.('frame', { data, viewportWidth, viewportHeight }); } + emitApiCalls(apiCalls: ApiCall[]) { + this.sendEvent?.('apiCalls', { apiCalls }); + } + + emitDebuggerPaused(paused: boolean) { + this.sendEvent?.('debuggerPaused', { paused }); + } + + emitDebuggerSource(source: DebuggerSource | null) { + this.sendEvent?.('debuggerSource', { source }); + } + emitAnnotate({ signal }: { signal: AbortSignal }): Promise { return new Promise(resolve => { if (signal.aborted) { @@ -282,8 +350,7 @@ export class DashboardConnection implements Transport { queueMicrotask(async () => { this._pushTabsScheduled = false; try { - const tabs = await this._aggregateTabs(); - this.emitTabs(tabs); + this.emitTabs(await this._aggregateTabs()); } catch { // best-effort } @@ -310,7 +377,7 @@ export class DashboardConnection implements Transport { browser: browserId(browser), context: contextId(context), page: pageId(page), - title: await page.title().catch(() => ''), + title: await wrapInternal(page, () => page.title()).catch(() => ''), url: page.url(), selected: page === attachedPage, faviconUrl: await faviconUrl(page), @@ -334,8 +401,24 @@ export class DashboardConnection implements Transport { attached.dispose(); throw e; } - if (this._attachedPage === attached) + if (this._attachedPage === attached) { + this._setActiveContextDebugger(this._contextDebuggerFor(page.context())); this._tryFireAnnotate(); + } + } + + private _contextDebuggerFor(context: api.BrowserContext): ContextDebugger { + let contextDebugger = this._contextDebuggers.get(context); + if (!contextDebugger) { + contextDebugger = new ContextDebugger(this, context); + this._contextDebuggers.set(context, contextDebugger); + } + return contextDebugger; + } + + private _setActiveContextDebugger(contextDebugger: ContextDebugger) { + this._activeContextDebugger = contextDebugger; + contextDebugger.activate(); } _handleAttachedPageClose(context: api.BrowserContext) { @@ -348,6 +431,147 @@ export class DashboardConnection implements Transport { } } +class ContextDebugger { + private _owner: DashboardConnection; + private _context: api.BrowserContext; + private _listeners: Disposable[] = []; + private _apiCalls = new Map(); + private _source: DebuggerSource | null = null; + private _sourceCache = new Map(); + private _lastSourceKey: string | undefined; + + constructor(owner: DashboardConnection, context: api.BrowserContext) { + this._owner = owner; + this._context = context; + this._listeners.push( + eventsHelper.addEventListener(this._context.debugger, 'apicallsupdated', (apiCalls: ApiCallDelta[]) => this._onApiCallsUpdated(apiCalls)), + eventsHelper.addEventListener(this._context.debugger, 'pausedstatechanged', () => this._onPausedStateChanged()), + ); + // eslint-disable-next-line no-restricted-syntax + const dbg = this._context.debugger as any; + if (typeof dbg._enable === 'function') + void dbg._enable().catch(() => {}); + } + + dispose() { + this._listeners.forEach(d => d.dispose()); + this._listeners = []; + } + + apiCalls(): ApiCall[] { + return [...this._apiCalls.values()]; + } + + paused(): boolean { + return this._context.debugger.pausedDetails() !== null; + } + + activate() { + this._owner.emitApiCalls(this.apiCalls()); + this._owner.emitDebuggerPaused(this.paused()); + this._updateSource(true); + } + + async resume() { + await wrapInternal(this._context, () => this._context.debugger.resume()).catch(() => {}); + } + + async pause() { + await wrapInternal(this._context, () => this._context.debugger.requestPause()).catch(() => {}); + } + + async step() { + await wrapInternal(this._context, () => this._context.debugger.next()).catch(() => {}); + } + + private _isActive(): boolean { + return this._owner.activeContextDebugger() === this; + } + + private _onApiCallsUpdated(deltas: ApiCallDelta[]) { + for (const delta of deltas) { + const existing = this._apiCalls.get(delta.id); + this._apiCalls.set(delta.id, { + id: delta.id, + title: delta.title, + location: delta.location, + logs: [...(existing?.logs ?? []), ...delta.newLogEntries], + actionPoint: delta.actionPoint ?? existing?.actionPoint, + status: delta.status, + error: delta.error, + }); + } + if (!this._isActive()) + return; + this._owner.emitApiCalls(this.apiCalls()); + this._updateSource(); + } + + private _onPausedStateChanged() { + if (!this._isActive()) + return; + this._owner.emitDebuggerPaused(this.paused()); + this._updateSource(); + } + + private _updateSource(force = false) { + const paused = this._context.debugger.pausedDetails(); + let location: { file: string; line?: number } | undefined; + let type: DebuggerSource['highlight'][number]['type'] = 'running'; + let active = false; + if (paused?.location) { + location = paused.location; + type = 'paused'; + active = true; + } else { + const calls = [...this._apiCalls.values()].reverse(); + const running = calls.find(c => c.status === 'running' && c.location?.file); + if (running) { + location = running.location; + type = 'running'; + active = true; + } else { + const last = calls.find(c => c.location?.file); + location = last?.location; + type = last?.status === 'error' ? 'error' : 'running'; + } + } + + const text = location?.file && location.file !== '' ? this._readSource(location.file) : undefined; + if (location?.file && text !== undefined) { + const key = `${location.file}:${location.line}:${type}:${active}`; + if (key !== this._lastSourceKey) { + this._lastSourceKey = key; + const highlight = active && location.line ? [{ line: location.line, type }] : []; + this._source = { + file: location.file, + language: languageForFile(location.file), + text, + highlight, + revealLine: location.line, + }; + this._owner.emitDebuggerSource(this._source); + return; + } + } + if (force) + this._owner.emitDebuggerSource(this._source); + } + + private _readSource(file: string): string | undefined { + let text = this._sourceCache.get(file); + if (text === undefined) { + try { + text = fs.readFileSync(file, 'utf-8'); + } catch { + return undefined; + } + this._sourceCache.set(file, text); + } + return text; + } +} + class AttachedPage { private _owner: DashboardConnection; private _page: api.Page; @@ -385,7 +609,7 @@ class AttachedPage { this._listeners.forEach(d => d.dispose()); this._listeners = []; if (this._screencastRunning) - this._page.screencast.stop().catch(() => {}); + wrapInternal(this._page, () => this._page.screencast.stop()).catch(() => {}); this._screencastRunning = false; this._recordingPath = null; } @@ -396,52 +620,52 @@ class AttachedPage { await this._startScreencast(this._page); } else if (!active && this._screencastRunning) { this._screencastRunning = false; - await this._page.screencast.stop().catch(() => {}); + await wrapInternal(this._page, () => this._page.screencast.stop()).catch(() => {}); } } async navigate(params: { url: string }) { if (!params.url) return; - await this._page.goto(params.url); + await wrapInternal(this._page, () => this._page.goto(params.url)); } async back() { - await this._page.goBack(); + await wrapInternal(this._page, () => this._page.goBack()); } async forward() { - await this._page.goForward(); + await wrapInternal(this._page, () => this._page.goForward()); } async reload() { - await this._page.reload(); + await wrapInternal(this._page, () => this._page.reload()); } async mousemove(params: { x: number; y: number }) { - await this._page.mouse.move(params.x, params.y); + await wrapInternal(this._page, () => this._page.mouse.move(params.x, params.y)); } async mousedown(params: { x: number; y: number; button?: 'left' | 'middle' | 'right' }) { - await this._page.mouse.move(params.x, params.y); - await this._page.mouse.down({ button: params.button || 'left' }); + await wrapInternal(this._page, () => this._page.mouse.move(params.x, params.y)); + await wrapInternal(this._page, () => this._page.mouse.down({ button: params.button || 'left' })); } async mouseup(params: { x: number; y: number; button?: 'left' | 'middle' | 'right' }) { - await this._page.mouse.move(params.x, params.y); - await this._page.mouse.up({ button: params.button || 'left' }); + await wrapInternal(this._page, () => this._page.mouse.move(params.x, params.y)); + await wrapInternal(this._page, () => this._page.mouse.up({ button: params.button || 'left' })); } async wheel(params: { deltaX: number; deltaY: number }) { - await this._page.mouse.wheel(params.deltaX, params.deltaY); + await wrapInternal(this._page, () => this._page.mouse.wheel(params.deltaX, params.deltaY)); } async keydown(params: { key: string }) { - await this._page.keyboard.down(params.key); + await wrapInternal(this._page, () => this._page.keyboard.down(params.key)); } async keyup(params: { key: string }) { - await this._page.keyboard.up(params.key); + await wrapInternal(this._page, () => this._page.keyboard.up(params.key)); } async startRecording() { @@ -465,8 +689,8 @@ class AttachedPage { } async screenshot(): Promise<{ data: string; viewportWidth: number; viewportHeight: number; ariaSnapshot: string }> { - const buffer = await this._page.screenshot({ type: 'png' }); - const ariaSnapshot = await this._page.ariaSnapshot({ boxes: true, mode: 'ai' }); + const buffer = await wrapInternal(this._page, () => this._page.screenshot({ type: 'png' })); + const ariaSnapshot = await wrapInternal(this._page, () => this._page.ariaSnapshot({ boxes: true, mode: 'ai' })); const vp = await this._viewportSize(); return { data: buffer.toString('base64'), @@ -482,11 +706,11 @@ class AttachedPage { const vp = this._page.viewportSize(); if (vp) return vp; - return await this._page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })); + return await wrapInternal(this._page, () => this._page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }))); } private async _startScreencast(page: api.Page) { - await page.screencast.start({ + await wrapInternal(page, () => page.screencast.start({ onFrame: ({ data, viewportWidth, viewportHeight }) => { if (this._disposed) return; @@ -494,11 +718,11 @@ class AttachedPage { }, size: { width: 1280, height: 800 }, ...(this._recordingPath ? { path: this._recordingPath } : {}), - }); + })); } private async _restartScreencast(page: api.Page) { - await page.screencast.stop().catch(() => {}); + await wrapInternal(page, () => page.screencast.stop()).catch(() => {}); await this._startScreencast(page); } } @@ -519,7 +743,7 @@ function contextId(c: api.BrowserContext): string { } async function faviconUrl(page: api.Page): Promise { - const url = page.evaluate(async () => { + const url = wrapInternal(page, () => page.evaluate(async () => { const response = await fetch(document.querySelector('link[rel~="icon"]')?.href ?? '/favicon.ico'); if (!response.ok) return undefined; @@ -532,7 +756,7 @@ async function faviconUrl(page: api.Page): Promise { reader.onerror = reject; reader.readAsDataURL(blob); }); - }).catch(() => undefined); + })).catch(() => undefined); const timeout = new Promise(resolve => setTimeout(() => resolve(undefined), 3000)); return await Promise.race([url, timeout]); } diff --git a/packages/protocol/spec/playwright.yml b/packages/protocol/spec/playwright.yml index 287f3714d4076..de3634d75b1fb 100644 --- a/packages/protocol/spec/playwright.yml +++ b/packages/protocol/spec/playwright.yml @@ -456,6 +456,9 @@ Debugger: line: int? column: int? + enable: + internal: true + events: pausedStateChanged: @@ -472,6 +475,33 @@ Debugger: title: string stack: string? + apiCallsUpdated: + parameters: + apiCalls: + type: array + items: + type: object + properties: + id: string + title: string + location: + type: object? + properties: + file: string + line: int? + column: int? + newLogEntries: + type: array + items: string + actionPoint: Point? + status: + type: enum + literals: + - running + - success + - error + error: string? + Dialog: type: interface diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 0445d88627c11..00ac25e317585 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -2939,6 +2939,21 @@ scheme.DebuggerPausedStateChangedEvent = tObject({ stack: tOptional(tString), })), }); +scheme.DebuggerApiCallsUpdatedEvent = tObject({ + apiCalls: tArray(tObject({ + id: tString, + title: tString, + location: tOptional(tObject({ + file: tString, + line: tOptional(tInt), + column: tOptional(tInt), + })), + newLogEntries: tArray(tString), + actionPoint: tOptional(tType('Point')), + status: tEnum(['running', 'success', 'error']), + error: tOptional(tString), + })), +}); scheme.DebuggerRequestPauseParams = tOptional(tObject({})); scheme.DebuggerRequestPauseResult = tOptional(tObject({})); scheme.DebuggerResumeParams = tOptional(tObject({})); @@ -2953,6 +2968,8 @@ scheme.DebuggerRunToParams = tObject({ }), }); scheme.DebuggerRunToResult = tOptional(tObject({})); +scheme.DebuggerEnableParams = tOptional(tObject({})); +scheme.DebuggerEnableResult = tOptional(tObject({})); scheme.DialogInitializer = tObject({ page: tOptional(tChannel(['Page'])), type: tString, diff --git a/tests/library/debugger.spec.ts b/tests/library/debugger.spec.ts index 172de6bcdbefb..36df139bc6ddc 100644 --- a/tests/library/debugger.spec.ts +++ b/tests/library/debugger.spec.ts @@ -15,6 +15,9 @@ */ import { contextTest as it, expect } from '../config/browserTest'; +import type * as channels from '../../packages/playwright-core/src/client/channels'; + +type ApiCall = channels.DebuggerApiCallsUpdatedEvent['apiCalls'][number]; it('should pause at next and resume', async ({ context, server }) => { const page = await context.newPage(); @@ -93,3 +96,177 @@ it('should run to location', async ({ context, server }) => { await dbg.resume(); await clickPromise; }); + +it('should stream api calls via _enable', async ({ context, server }) => { + const page = await context.newPage(); + await page.setContent(''); + const dbg = context.debugger as any; + + // Accumulate deltas by id, appending newLogEntries — mirrors the dashboard bridge. + const calls = new Map(); + const statusHistory = new Map(); + dbg.on('apicallsupdated', (apiCalls: ApiCall[]) => { + for (const call of apiCalls) { + const existing = calls.get(call.id); + calls.set(call.id, { + title: call.title, + status: call.status, + location: call.location, + actionPoint: call.actionPoint ?? existing?.actionPoint, + logs: [...(existing?.logs ?? []), ...call.newLogEntries], + }); + statusHistory.set(call.id, [...(statusHistory.get(call.id) ?? []), call.status]); + } + }); + await dbg._enable(); + + await page.click('button'); + + const clickCall = () => [...calls.values()].find(c => c.title.includes('Click')); + await expect.poll(() => clickCall()?.status).toBe('success'); + + const call = clickCall()!; + expect(call.location).toEqual(expect.objectContaining({ file: expect.stringContaining('debugger.spec') })); + expect(call.actionPoint).toEqual(expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) })); + expect(call.logs.length).toBeGreaterThan(0); + + // The status streamed running -> success (not just a single terminal event). + const clickId = [...calls.entries()].find(([, c]) => c.title.includes('Click'))![0]; + const history = statusHistory.get(clickId)!; + expect(history[0]).toBe('running'); + expect(history[history.length - 1]).toBe('success'); +}); + +it('should report error status for failed api calls', async ({ context, server }) => { + const page = await context.newPage(); + await page.setContent('
no button here
'); + const dbg = context.debugger as any; + + const calls = new Map(); + dbg.on('apicallsupdated', (apiCalls: ApiCall[]) => { + for (const call of apiCalls) + calls.set(call.id, call); + }); + await dbg._enable(); + + await page.click('button', { timeout: 1000 }).catch(() => {}); + + await expect.poll(() => [...calls.values()].find(c => c.title.includes('Click'))?.status).toBe('error'); + const call = [...calls.values()].find(c => c.title.includes('Click'))!; + expect(call.error).toBeTruthy(); +}); + +it('should not stream internal api calls', async ({ context, server }) => { + const page = await context.newPage(); + await page.setContent(''); + const dbg = context.debugger as any; + const calls: ApiCall[] = []; + dbg.on('apicallsupdated', (apiCalls: ApiCall[]) => calls.push(...apiCalls)); + await dbg._enable(); + + // A call wrapped as internal (mirrors the dashboard's own traffic) is excluded. + await (page as any)._wrapApiCall(() => page.click('button'), { internal: true }); + // A normal call is still streamed. + await page.click('button'); + + await expect.poll(() => calls.some(c => c.title.includes('Click'))).toBe(true); + const clickIds = new Set(calls.filter(c => c.title.includes('Click')).map(c => c.id)); + expect(clickIds.size).toBe(1); +}); + +it('should not pause at internal api calls', async ({ context, server }) => { + const page = await context.newPage(); + await page.setContent(''); + const dbg = context.debugger as any; + + await dbg.requestPause(); + // The internal call must not trip the pause, and must not consume the arming. + await (page as any)._wrapApiCall(() => page.click('button'), { internal: true }); + expect(dbg.pausedDetails()).toBeNull(); + + // The next normal call still pauses. + const clickPromise = page.click('button'); + await new Promise(resolve => dbg.once('pausedstatechanged', resolve)); + expect(dbg.pausedDetails()).toBeTruthy(); + await Promise.all([ + dbg.resume(), + new Promise(resolve => dbg.once('pausedstatechanged', resolve)), + clickPromise, + ]); +}); + +it('should expose the action point while paused on an input action', async ({ context, server }) => { + const page = await context.newPage(); + await page.setContent(''); + const dbg = context.debugger as any; + const calls = new Map(); + dbg.on('apicallsupdated', (apiCalls: ApiCall[]) => { + for (const c of apiCalls) { + const existing = calls.get(c.id); + calls.set(c.id, { ...c, actionPoint: c.actionPoint ?? existing?.actionPoint }); + } + }); + await dbg._enable(); + + // With api calls enabled, requestPause pauses inside the action (after auto-waiting), + // so the action point is known — unlike the default "pause before waiting" behavior. + await dbg.requestPause(); + const clickPromise = page.click('button'); + await new Promise(resolve => dbg.once('pausedstatechanged', resolve)); + + expect(dbg.pausedDetails()).toEqual(expect.objectContaining({ title: expect.stringContaining('Click') })); + const clickCall = [...calls.values()].find(c => c.title.includes('Click'))!; + expect(clickCall.status).toBe('running'); + expect(clickCall.actionPoint).toEqual(expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) })); + + await Promise.all([ + dbg.resume(), + new Promise(resolve => dbg.once('pausedstatechanged', resolve)), + clickPromise, + ]); +}); + +it('should replay ongoing calls when enabled late', async ({ context, server }) => { + const page = await context.newPage(); + await page.setContent(''); + const dbg = context.debugger as any; + + // Start a call and pause on it, so it is definitely in-flight — but do NOT enable yet. + await dbg.requestPause(); + const clickPromise = page.click('button'); + await new Promise(resolve => dbg.once('pausedstatechanged', resolve)); + + // Enabling now must replay the already-ongoing call right away. + const calls: ApiCall[] = []; + dbg.on('apicallsupdated', (apiCalls: ApiCall[]) => calls.push(...apiCalls)); + await dbg._enable(); + + await expect.poll(() => calls.find(c => c.title.includes('Click'))?.status).toBe('running'); + const clickCall = calls.find(c => c.title.includes('Click'))!; + expect(clickCall.location).toEqual(expect.objectContaining({ file: expect.stringContaining('debugger.spec') })); + + await Promise.all([ + dbg.resume(), + new Promise(resolve => dbg.once('pausedstatechanged', resolve)), + clickPromise, + ]); +}); + +it('should keep pause working when api calls are enabled', async ({ context, server }) => { + const page = await context.newPage(); + await page.setContent('
click me
'); + const dbg = context.debugger as any; + await dbg._enable(); + + await dbg.requestPause(); + const clickPromise = page.click('div'); + await new Promise(resolve => dbg.once('pausedstatechanged', resolve)); + expect(dbg.pausedDetails()).toEqual(expect.objectContaining({ title: expect.stringContaining('Click') })); + + await Promise.all([ + dbg.resume(), + new Promise(resolve => dbg.once('pausedstatechanged', resolve)), + clickPromise, + ]); + expect(dbg.pausedDetails()).toBeNull(); +}); From a92a7a3366ccb82d34c445bc2e7e7e71b7f0e4dc Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 23 Jul 2026 14:48:29 -0600 Subject: [PATCH 4/5] Revert "feat(routeFromHar): add interceptAPIRequests option (#41294)" (#41964) --- docs/src/api/class-browsercontext.md | 6 - packages/isomorphic/protocolMetainfo.ts | 2 - packages/playwright-client/types/types.d.ts | 9 - .../src/client/browserContext.ts | 4 +- .../playwright-core/src/client/channels.d.ts | 24 -- .../playwright-core/src/client/harRouter.ts | 18 - .../src/server/browserContext.ts | 32 -- .../playwright-core/src/server/channels.d.ts | 24 -- .../dispatchers/browserContextDispatcher.ts | 35 -- .../src/server/dispatchers/dispatcher.ts | 8 - .../dispatchers/localUtilsDispatcher.ts | 4 - packages/playwright-core/src/server/fetch.ts | 141 +------ .../playwright-core/src/server/harBackend.ts | 43 +-- .../playwright-core/src/server/localUtils.ts | 22 +- packages/playwright-core/types/types.d.ts | 9 - packages/protocol/spec/browserContext.yml | 20 - packages/protocol/src/validator.ts | 14 - tests/library/browsercontext-har.spec.ts | 353 ------------------ 18 files changed, 18 insertions(+), 750 deletions(-) diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 37ebb8f05f0db..f5c2cbc95ef12 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1310,12 +1310,6 @@ When set to `minimal`, only record information necessary for routing from HAR. T Optional setting to control resource content management. If `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is specified, content is stored inline the HAR file. -### option: BrowserContext.routeFromHAR.interceptAPIRequests -* since: v1.62 -- `interceptAPIRequests` <[boolean]> - -If set to `true`, requests made via [APIRequestContext] (such as [`property: BrowserContext.request`] or [`property: Page.request`]) are also served from the HAR file. By default these requests are sent to the network, matching the behavior prior to v1.62. Defaults to `false` for backward compatibility. - ## async method: BrowserContext.routeWebSocket * since: v1.48 diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index 29fbd276b645a..423b0a131c6df 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -89,8 +89,6 @@ export const methodMetainfo = new Map([ ['BrowserContext.setGeolocation', { title: 'Set geolocation', group: 'configuration', }], ['BrowserContext.setHTTPCredentials', { title: 'Set HTTP credentials', group: 'configuration', }], ['BrowserContext.setNetworkInterceptionPatterns', { title: 'Route requests', group: 'route', }], - ['BrowserContext.routeAPIRequestsFromHar', { internal: true, }], - ['BrowserContext.unrouteAPIRequestsFromHar', { internal: true, }], ['BrowserContext.setWebSocketInterceptionPatterns', { title: 'Route WebSockets', group: 'route', }], ['BrowserContext.setOffline', { title: 'Set offline mode', }], ['BrowserContext.storageState', { title: 'Get storage state', group: 'configuration', }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 687087f92814c..101f50fe293a2 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -10258,15 +10258,6 @@ export interface BrowserContext { * @param options */ routeFromHAR(har: string, options?: { - /** - * If set to `true`, requests made via [APIRequestContext](https://playwright.dev/docs/api/class-apirequestcontext) - * (such as [browserContext.request](https://playwright.dev/docs/api/class-browsercontext#browser-context-request) or - * [page.request](https://playwright.dev/docs/api/class-page#page-request)) are also served from the HAR file. By - * default these requests are sent to the network, matching the behavior prior to v1.62. Defaults to `false` for - * backward compatibility. - */ - interceptAPIRequests?: boolean; - /** * - If set to 'abort' any request not found in the HAR file will be aborted. * - If set to 'fallback' falls through to the next route handler in the handler chain. diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 7186b1fc584b9..e752abe2fe42a 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -398,7 +398,7 @@ export class BrowserContext extends ChannelOwner await this._updateWebSocketInterceptionPatterns({ title: 'Route WebSockets' }); } - async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean, updateContent?: 'attach' | 'embed', updateMode?: 'minimal' | 'full', interceptAPIRequests?: boolean } = {}): Promise { + async routeFromHAR(har: string, options: { url?: string | RegExp, notFound?: 'abort' | 'fallback', update?: boolean, updateContent?: 'attach' | 'embed', updateMode?: 'minimal' | 'full' } = {}): Promise { const localUtils = this._connection.localUtils(); if (!localUtils) throw new Error('Route from har is not supported in thin clients'); @@ -409,8 +409,6 @@ export class BrowserContext extends ChannelOwner const harRouter = await HarRouter.create(localUtils, har, options.notFound || 'abort', { urlMatch: options.url }); this._harRouters.push(harRouter); await harRouter.addContextRoute(this); - if (options.interceptAPIRequests) - await harRouter.addAPIRequestRoute(this); } private _disposeHarRouters() { diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index ddc8a9125ce4d..6deafa90d2c4b 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -1273,8 +1273,6 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, Channe setGeolocation(params: BrowserContextSetGeolocationParams, options: TimeoutOptions): Promise; setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams, options: TimeoutOptions): Promise; setNetworkInterceptionPatterns(params: BrowserContextSetNetworkInterceptionPatternsParams, options: TimeoutOptions): Promise; - routeAPIRequestsFromHar(params: BrowserContextRouteAPIRequestsFromHarParams, options: TimeoutOptions): Promise; - unrouteAPIRequestsFromHar(params: BrowserContextUnrouteAPIRequestsFromHarParams, options: TimeoutOptions): Promise; setWebSocketInterceptionPatterns(params: BrowserContextSetWebSocketInterceptionPatternsParams, options: TimeoutOptions): Promise; setOffline(params: BrowserContextSetOfflineParams, options: TimeoutOptions): Promise; storageState(params: BrowserContextStorageStateParams, options: TimeoutOptions): Promise; @@ -1509,28 +1507,6 @@ export type BrowserContextSetNetworkInterceptionPatternsOptions = { }; export type BrowserContextSetNetworkInterceptionPatternsResult = void; -export type BrowserContextRouteAPIRequestsFromHarParams = { - harId: string, - urlGlob?: string, - urlRegexSource?: string, - urlRegexFlags?: string, - notFound: 'abort' | 'fallback', -}; -export type BrowserContextRouteAPIRequestsFromHarOptions = { - urlGlob?: string, - urlRegexSource?: string, - urlRegexFlags?: string, -}; -export type BrowserContextRouteAPIRequestsFromHarResult = { - registrationId: string, -}; -export type BrowserContextUnrouteAPIRequestsFromHarParams = { - registrationId: string, -}; -export type BrowserContextUnrouteAPIRequestsFromHarOptions = { - -}; -export type BrowserContextUnrouteAPIRequestsFromHarResult = void; export type BrowserContextSetWebSocketInterceptionPatternsParams = { patterns: { glob?: string, diff --git a/packages/playwright-core/src/client/harRouter.ts b/packages/playwright-core/src/client/harRouter.ts index 5791bf33829c6..e4350ad5e3f8c 100644 --- a/packages/playwright-core/src/client/harRouter.ts +++ b/packages/playwright-core/src/client/harRouter.ts @@ -15,9 +15,7 @@ */ import { debugLogger } from '@utils/debugLogger'; -import { isRegExp, isString } from '@isomorphic/rtti'; -import { kNoTimeout } from './timeoutSettings'; import type { BrowserContext } from './browserContext'; import type { LocalUtils } from './localUtils'; import type { Route } from './network'; @@ -31,7 +29,6 @@ export class HarRouter { private _harId: string; private _notFoundAction: HarNotFoundAction; private _options: { urlMatch?: URLMatch; baseURL?: string; }; - private _apiRequestRegistrations: { context: BrowserContext, registrationId: string }[] = []; static async create(localUtils: LocalUtils, file: string, notFoundAction: HarNotFoundAction, options: { urlMatch?: URLMatch }): Promise { const { harId, error } = await localUtils.harOpen({ file }); @@ -120,26 +117,11 @@ export class HarRouter { await page.route(this._options.urlMatch || '**/*', route => this._handle(route)); } - async addAPIRequestRoute(context: BrowserContext) { - const urlMatch = this._options.urlMatch; - const { registrationId } = await context._channel.routeAPIRequestsFromHar({ - harId: this._harId, - urlGlob: isString(urlMatch) ? urlMatch : undefined, - urlRegexSource: isRegExp(urlMatch) ? urlMatch.source : undefined, - urlRegexFlags: isRegExp(urlMatch) ? urlMatch.flags : undefined, - notFound: this._notFoundAction, - }, kNoTimeout); - this._apiRequestRegistrations.push({ context, registrationId }); - } - async [Symbol.asyncDispose]() { await this.dispose(); } dispose() { - for (const { context, registrationId } of this._apiRequestRegistrations) - context._channel.unrouteAPIRequestsFromHar({ registrationId }, kNoTimeout).catch(() => {}); - this._apiRequestRegistrations = []; this._localUtils.harClose({ harId: this._harId }).catch(() => {}); } } diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 5d3ea5a3863ec..fb2023c8fff83 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -39,13 +39,11 @@ import type { Browser, BrowserOptions } from './browser'; import type { ConsoleMessage } from './console'; import type { Download } from './download'; import type * as frames from './frames'; -import type { HarBackend } from './harBackend'; import type { PageError } from './page'; import type { Progress } from './progress'; import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import type { SerializedStorage } from '@injected/storageScript'; import type * as types from './types'; -import type { URLMatch } from '@isomorphic/urlMatch'; import type * as channels from './channels'; const BrowserContextEvent = { @@ -122,7 +120,6 @@ export abstract class BrowserContext extends Sdk private _playwrightBindingExposed?: Promise; readonly dialogManager: DialogManager; private _consoleApiExposed = false; - private _harForAPIRequests: HarForAPIRequestsRegistration[] = []; constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) { super(browser, 'browser-context'); @@ -750,37 +747,8 @@ export abstract class BrowserContext extends Sdk async notifyRoutesInFlightAboutRemovedHandler(handler: network.RouteHandler): Promise { await Promise.all([...this._routesInFlight].map(route => route.removeHandler(handler))); } - - routeAPIRequestsFromHar(options: { harBackend: HarBackend, urlMatch: URLMatch | undefined, notFound: 'abort' | 'fallback', baseURL: string | undefined }): { dispose: () => void } { - const registration: HarForAPIRequestsRegistration = { - harBackend: options.harBackend, - urlMatch: options.urlMatch, - notFound: options.notFound, - baseURL: options.baseURL, - }; - // Give priority to the newest registration, mirroring BrowserContext.route/Page.route. - this._harForAPIRequests.unshift(registration); - return { - dispose: () => { - const index = this._harForAPIRequests.indexOf(registration); - if (index !== -1) - this._harForAPIRequests.splice(index, 1); - }, - }; - } - - harForAPIRequests(): readonly HarForAPIRequestsRegistration[] { - return this._harForAPIRequests; - } } -export type HarForAPIRequestsRegistration = { - harBackend: HarBackend; - urlMatch: URLMatch | undefined; - notFound: 'abort' | 'fallback'; - baseURL: string | undefined; -}; - export function validateBrowserContextOptions(options: types.BrowserContextOptions, browserOptions: BrowserOptions) { if (options.noDefaultViewport && options.deviceScaleFactor !== undefined) throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`); diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index 9ce45772cbaaa..5b65ebfbc058e 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -1274,8 +1274,6 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, Channe setGeolocation(params: BrowserContextSetGeolocationParams, progress: Progress): Promise; setHTTPCredentials(params: BrowserContextSetHTTPCredentialsParams, progress: Progress): Promise; setNetworkInterceptionPatterns(params: BrowserContextSetNetworkInterceptionPatternsParams, progress: Progress): Promise; - routeAPIRequestsFromHar(params: BrowserContextRouteAPIRequestsFromHarParams, progress: Progress): Promise; - unrouteAPIRequestsFromHar(params: BrowserContextUnrouteAPIRequestsFromHarParams, progress: Progress): Promise; setWebSocketInterceptionPatterns(params: BrowserContextSetWebSocketInterceptionPatternsParams, progress: Progress): Promise; setOffline(params: BrowserContextSetOfflineParams, progress: Progress): Promise; storageState(params: BrowserContextStorageStateParams, progress: Progress): Promise; @@ -1510,28 +1508,6 @@ export type BrowserContextSetNetworkInterceptionPatternsOptions = { }; export type BrowserContextSetNetworkInterceptionPatternsResult = void; -export type BrowserContextRouteAPIRequestsFromHarParams = { - harId: string, - urlGlob?: string, - urlRegexSource?: string, - urlRegexFlags?: string, - notFound: 'abort' | 'fallback', -}; -export type BrowserContextRouteAPIRequestsFromHarOptions = { - urlGlob?: string, - urlRegexSource?: string, - urlRegexFlags?: string, -}; -export type BrowserContextRouteAPIRequestsFromHarResult = { - registrationId: string, -}; -export type BrowserContextUnrouteAPIRequestsFromHarParams = { - registrationId: string, -}; -export type BrowserContextUnrouteAPIRequestsFromHarOptions = { - -}; -export type BrowserContextUnrouteAPIRequestsFromHarResult = void; export type BrowserContextSetWebSocketInterceptionPatternsParams = { patterns: { glob?: string, diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 4d24b22c8329c..3616c35cd75ff 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -46,13 +46,10 @@ import type { Request, Response, RouteHandler } from '../network'; import type { InitScript, Page, PageError } from '../page'; import type { Disposable } from '../disposable'; import type { DispatcherScope } from './dispatcher'; -import type { LocalUtilsDispatcher } from './localUtilsDispatcher'; import type * as channels from '../channels'; import type { Progress } from '../progress'; import type { URLMatch } from '@isomorphic/urlMatch'; -type HarForAPIRequestsDisposable = Disposable & { registrationId: string }; - export class BrowserContextDispatcher extends Dispatcher implements channels.BrowserContextChannel { _type_BrowserContext = true; private _context: BrowserContext; @@ -338,38 +335,6 @@ export class BrowserContextDispatcher extends Dispatcher { - // Reuse the HarBackend that was already opened via localUtils.harOpen for the page-side - // route, rather than opening a second backend for the same HAR file. The backend is owned - // by LocalUtils and closed via harClose, so this registration must not dispose it. - const harBackend = this.connection.getDispatcher('LocalUtils')?.harBackendForId(params.harId); - if (!harBackend) - throw new Error('Internal error: har was not opened'); - const urlMatch: URLMatch | undefined = - params.urlRegexSource !== undefined && params.urlRegexFlags !== undefined ? new RegExp(params.urlRegexSource, params.urlRegexFlags) : - params.urlGlob !== undefined ? params.urlGlob : undefined; - const registrationId = createGuid(); - const registration = this._context.routeAPIRequestsFromHar({ - harBackend, - urlMatch, - notFound: params.notFound, - baseURL: this._context._options.baseURL, - }); - this._disposables.push({ - registrationId, - dispose: async () => registration.dispose(), - } as HarForAPIRequestsDisposable); - return { registrationId }; - } - - async unrouteAPIRequestsFromHar(params: channels.BrowserContextUnrouteAPIRequestsFromHarParams, progress: Progress): Promise { - const index = this._disposables.findIndex(d => (d as HarForAPIRequestsDisposable).registrationId === params.registrationId); - if (index === -1) - return; - const [disposable] = this._disposables.splice(index, 1); - await progress.race(disposable.dispose()); - } - async storageState(params: channels.BrowserContextStorageStateParams, progress: Progress): Promise { return await this._context.storageState(progress, params.indexedDB, params.credentials); } diff --git a/packages/playwright-core/src/server/dispatchers/dispatcher.ts b/packages/playwright-core/src/server/dispatchers/dispatcher.ts index 172a58368b278..2ad03db61fd42 100644 --- a/packages/playwright-core/src/server/dispatchers/dispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/dispatcher.ts @@ -266,14 +266,6 @@ export class DispatcherConnection { return this._dispatcherByObject.get(object) as DispatcherType | undefined; } - getDispatcher(type: string): DispatcherType | undefined { - for (const dispatcher of this._dispatcherByGuid.values()) { - if (dispatcher._type === type) - return dispatcher as DispatcherType; - } - return undefined; - } - registerDispatcher(dispatcher: DispatcherScope) { assert(!this._dispatcherByGuid.has(dispatcher._guid)); this._dispatcherByGuid.set(dispatcher._guid, dispatcher); diff --git a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts index 536d92e6fdff2..8051658f48b7c 100644 --- a/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/localUtilsDispatcher.ts @@ -67,10 +67,6 @@ export class LocalUtilsDispatcher extends Dispatcher { return await localUtils.harUnzip(progress, params); } diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index e5778afb760a6..2d32cada41a67 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -23,7 +23,7 @@ import * as zlib from 'zlib'; import { createGuid } from '@utils/crypto'; import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent, timingForSocket } from '@utils/happyEyeballs'; import { assert } from '@isomorphic/assert'; -import { constructURLBasedOnBaseURL, urlMatches } from '@isomorphic/urlMatch'; +import { constructURLBasedOnBaseURL } from '@isomorphic/urlMatch'; import { eventsHelper } from '@utils/eventsHelper'; import { monotonicTime } from '@isomorphic/time'; import { createProxyAgent } from '@utils/network'; @@ -150,10 +150,6 @@ export abstract class APIRequestContext extends SdkObject { abstract addCookies(cookies: channels.NetworkCookie[]): Promise; abstract cookies(progress: Progress, url: URL): Promise; - protected async _lookupInHar(progress: Progress, url: URL, method: string, headers: HeadersObject, postData: Buffer | undefined, maxRedirects: number): Promise { - return undefined; - } - protected _disposeImpl() { this._disposed = true; APIRequestContext.allInstances.delete(this); @@ -229,9 +225,7 @@ export abstract class APIRequestContext extends SdkObject { const postData = serializePostData(params, headers); if (postData) setHeader(headers, 'content-length', String(postData.byteLength)); - const { body, log, response } = - (await this._lookupInHar(progress, requestUrl, method, headers, postData, maxRedirects)) || - (await this._sendRequestWithRetries(progress, requestUrl, options, postData, params.maxRetries)); + const { body, log, response } = await this._sendRequestWithRetries(progress, requestUrl, options, postData, params.maxRetries); const failOnStatusCode = params.failOnStatusCode !== undefined ? params.failOnStatusCode : !!defaults.failOnStatusCode; if (failOnStatusCode && (response.status < 200 || response.status >= 400)) { let responseText = ''; @@ -248,7 +242,7 @@ export abstract class APIRequestContext extends SdkObject { return { ...response, fetchUid }; } - _parseSetCookieHeader(responseUrl: string, setCookie: string[] | undefined): channels.NetworkCookie[] { + private _parseSetCookieHeader(responseUrl: string, setCookie: string[] | undefined): channels.NetworkCookie[] { if (!setCookie) return []; const url = new URL(responseUrl); @@ -275,7 +269,7 @@ export abstract class APIRequestContext extends SdkObject { return cookies; } - async _updateRequestCookieHeader(progress: Progress, url: URL, headers: HeadersObject) { + private async _updateRequestCookieHeader(progress: Progress, url: URL, headers: HeadersObject) { if (getHeader(headers, 'cookie') !== undefined) return; const contextCookies = await this.cookies(progress, url); @@ -706,114 +700,6 @@ export class BrowserContextAPIRequestContext extends APIRequestContext { override async storageState(progress: Progress, indexedDB?: boolean): Promise { return this._context.storageState(progress, indexedDB); } - - protected override async _lookupInHar(progress: Progress, url: URL, method: string, headers: HeadersObject, postData: Buffer | undefined, maxRedirects: number): Promise { - const registrations = this._context.harForAPIRequests(); - if (!registrations.length) - return undefined; - - const log: string[] = []; - const fetchLog = (message: string) => { - log.push(message); - progress.log(message); - }; - - await this._updateRequestCookieHeader(progress, url, headers); - - const urlString = url.toString(); - fetchLog(`→ ${method} ${urlString}`); - const headersArray: HeadersArray = Object.entries(headers).map(([name, value]) => ({ name, value })); - for (const registration of [...registrations]) { - if (!urlMatches(registration.baseURL, urlString, registration.urlMatch)) - continue; - const lookupResult = await progress.race(registration.harBackend.lookup(urlString, method, headersArray, postData, false, { apiRequestOnly: true, maxRedirects })); - if (lookupResult.action === 'error') { - const message = lookupResult.message ? ` :${lookupResult.message}` : ''; - fetchLog(`HAR: lookup failed${message}`); - if (registration.notFound === 'abort') - throw new Error(`Request "${method} ${urlString}" failed to lookup in the HAR file${message}`); - continue; - } - - if (lookupResult.action === 'noentry') { - fetchLog(`HAR: no entry found`); - if (registration.notFound === 'abort') - throw new Error(`Request "${method} ${urlString}" was not found in the HAR file`); - continue; - } - - const finalUrl = lookupResult.url ?? urlString; - const status = lookupResult.status ?? 0; - const statusText = lookupResult.statusText ?? ''; - const responseHeaders = lookupResult.headers ?? []; - const body = lookupResult.body ?? Buffer.from(''); - fetchLog(`← ${status} ${statusText} (from HAR)`); - for (const { name, value } of responseHeaders) - fetchLog(` ${name}: ${value}`); - - // Emit Request/RequestFinished here (not before the loop) so that we do not double-emit - // when there is no match and we fall through to the live request path, which emits them too. - const requestCookies = getHeader(headers, 'cookie')?.split(';').map(p => { - const indexOfEquals = p.indexOf('='); - const name = indexOfEquals !== -1 ? p.substring(0, indexOfEquals).trim() : p.trim(); - const value = indexOfEquals !== -1 ? p.substring(indexOfEquals + 1).trim() : ''; - return { name, value }; - }) || []; - const requestEvent: APIRequestEvent = { - url, - method, - headers, - cookies: requestCookies, - postData, - }; - this.emit(APIRequestContext.Events.Request, requestEvent); - - const setCookie = responseHeaders.filter(h => h.name.toLowerCase() === 'set-cookie').map(h => h.value); - const cookies = this._parseSetCookieHeader(finalUrl, setCookie); - if (cookies.length) { - try { - await progress.race(this.addCookies(cookies)); - } catch (e) { - // Cookie value is limited by 4096 characters in the browsers. If setCookies failed, - // we try setting each cookie individually just in case only some of them are bad. - await progress.race(Promise.all(cookies.map(c => this.addCookies([c]).catch(() => {})))); - } - } - - const serverAddr = lookupResult.serverIPAddress !== undefined && lookupResult.serverPort !== undefined ? - { ipAddress: lookupResult.serverIPAddress, port: lookupResult.serverPort } : undefined; - - const requestFinishedEvent: APIRequestFinishedEvent = { - requestEvent, - httpVersion: lookupResult.httpVersion ?? 'HTTP/1.1', - statusCode: status, - statusMessage: statusText, - headers: toHeadersObject(responseHeaders), - rawHeaders: responseHeaders.flatMap(({ name, value }) => [name, value]), - cookies, - body, - timings: lookupResult.timings ?? { send: -1, wait: -1, receive: -1 }, - serverIPAddress: lookupResult.serverIPAddress, - serverPort: lookupResult.serverPort, - securityDetails: lookupResult.securityDetails, - }; - this.emit(APIRequestContext.Events.RequestFinished, requestFinishedEvent); - - return { - body, - log, - response: { - url: finalUrl, - status, - statusText, - headers: responseHeaders, - securityDetails: lookupResult.securityDetails, - serverAddr, - }, - }; - } - return undefined; - } } @@ -883,25 +769,6 @@ function toHeadersArray(rawHeaders: string[]): types.HeadersArray { return result; } -function toHeadersObject(headers: types.HeadersArray): http.IncomingHttpHeaders { - const result: http.IncomingHttpHeaders = {}; - for (const { name, value } of headers) { - const key = name.toLowerCase(); - // set-cookie is the only multi-valued header Node exposes as an array. - if (key === 'set-cookie') { - const existing = result['set-cookie']; - if (existing) - existing.push(value); - else - result['set-cookie'] = [value]; - } else { - const existing = result[key]; - result[key] = existing ? `${existing}, ${value}` : value; - } - } - return result; -} - const redirectStatus = [301, 302, 303, 307, 308]; function parseCookie(header: string): channels.NetworkCookie | null { diff --git a/packages/playwright-core/src/server/harBackend.ts b/packages/playwright-core/src/server/harBackend.ts index 93fb7febfec18..bff11879e112b 100644 --- a/packages/playwright-core/src/server/harBackend.ts +++ b/packages/playwright-core/src/server/harBackend.ts @@ -26,8 +26,6 @@ import type * as har from '@trace/har'; const redirectStatus = [301, 302, 303, 307, 308]; -class MaxRedirectsExceededError extends Error {} - export class HarBackend { readonly id: string; private _harFile: har.HARFile; @@ -41,30 +39,18 @@ export class HarBackend { this._zipFile = zipFile; } - async lookup(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined, isNavigationRequest: boolean, options: { apiRequestOnly?: boolean, maxRedirects?: number } = {}): Promise<{ + async lookup(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined, isNavigationRequest: boolean): Promise<{ action: 'error' | 'redirect' | 'fulfill' | 'noentry', message?: string, redirectURL?: string, - url?: string, status?: number, - statusText?: string, - httpVersion?: string, headers?: HeadersArray, - cookies?: har.Cookie[], - body?: Buffer, - timings?: har.Timings, - serverIPAddress?: string, - serverPort?: number, - securityDetails?: har.SecurityDetails, + body?: Buffer }> { let entry; try { - entry = await this._harFindResponse(url, method, headers, postData, options); + entry = await this._harFindResponse(url, method, headers, postData); } catch (e) { - // A redirect-limit overflow is a hard error the caller must surface (mirrors the live - // request path), so let it propagate instead of degrading to a soft 'error' result. - if (e instanceof MaxRedirectsExceededError) - throw e; return { action: 'error', message: 'HAR error: ' + e.message }; } @@ -80,17 +66,9 @@ export class HarBackend { const buffer = await this._loadContent(response.content); return { action: 'fulfill', - url: entry.request.url, status: response.status, - statusText: response.statusText, - httpVersion: response.httpVersion, headers: response.headers, - cookies: response.cookies, body: buffer, - timings: entry.timings, - serverIPAddress: entry.serverIPAddress, - serverPort: entry._serverPort, - securityDetails: entry._securityDetails, }; } catch (e) { return { action: 'error', message: e.message }; @@ -115,17 +93,14 @@ export class HarBackend { return buffer; } - private async _harFindResponse(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined, options: { apiRequestOnly?: boolean, maxRedirects?: number } = {}): Promise { + private async _harFindResponse(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined): Promise { const harLog = this._harFile.log; const visited = new Set(); - let maxRedirects = options.maxRedirects; while (true) { const entries: har.Entry[] = []; for (const candidate of harLog.entries) { if (candidate.request.url !== url || candidate.request.method !== method) continue; - if (options.apiRequestOnly && !candidate._apiRequest) - continue; if (method === 'POST' && postData && candidate.request.postData) { const buffer = await this._loadContent(candidate.request.postData); if (!buffer.equals(postData)) { @@ -167,16 +142,6 @@ export class HarBackend { // Follow redirects. const locationHeader = entry.response.headers.find(h => h.name.toLowerCase() === 'location'); if (redirectStatus.includes(entry.response.status) && locationHeader) { - if (maxRedirects !== undefined) { - // Mirror the live request path (see APIRequestContext._sendRequest): a negative - // limit means "do not follow redirects" and returns the 3xx response as-is, while - // a zero limit means the redirect budget has been exhausted. - if (maxRedirects < 0) - return entry; - if (maxRedirects === 0) - throw new MaxRedirectsExceededError('Max redirect count exceeded'); - --maxRedirects; - } const locationURL = new URL(locationHeader.value, url); url = locationURL.toString(); if ((entry.response.status === 301 || entry.response.status === 302) && method === 'POST' || diff --git a/packages/playwright-core/src/server/localUtils.ts b/packages/playwright-core/src/server/localUtils.ts index f82dc1f83bd47..c2b36758a6a94 100644 --- a/packages/playwright-core/src/server/localUtils.ts +++ b/packages/playwright-core/src/server/localUtils.ts @@ -148,16 +148,9 @@ async function deleteStackSession(progress: Progress, stackSessions: Map, params: channels.LocalUtilsHarOpenParams): Promise { - const result = await openHarBackend(progress, params.file); - if ('error' in result) - return { error: result.error }; - harBackends.set(result.harBackend.id, result.harBackend); - return { harId: result.harBackend.id }; -} - -export async function openHarBackend(progress: Progress, file: string): Promise<{ harBackend: HarBackend } | { error: string }> { - if (file.endsWith('.zip')) { - const zipFile = new ZipFile(file); + let harBackend: HarBackend; + if (params.file.endsWith('.zip')) { + const zipFile = new ZipFile(params.file); try { const entryNames = await progress.race(zipFile.entries()); const harEntryName = entryNames.find(e => e.endsWith('.har')); @@ -165,14 +158,17 @@ export async function openHarBackend(progress: Progress, file: string): Promise< return { error: 'Specified archive does not have a .har file' }; const har = await progress.race(zipFile.read(harEntryName)); const harFile = JSON.parse(har.toString()) as har.HARFile; - return { harBackend: new HarBackend(harFile, null, zipFile) }; + harBackend = new HarBackend(harFile, null, zipFile); } catch (error) { zipFile.close(); throw error; } + } else { + const harFile = JSON.parse(await progress.race(fs.promises.readFile(params.file, 'utf-8'))) as har.HARFile; + harBackend = new HarBackend(harFile, path.dirname(params.file), null); } - const harFile = JSON.parse(await progress.race(fs.promises.readFile(file, 'utf-8'))) as har.HARFile; - return { harBackend: new HarBackend(harFile, path.dirname(file), null) }; + harBackends.set(harBackend.id, harBackend); + return { harId: harBackend.id }; } export async function harLookup(progress: Progress, harBackends: Map, params: channels.LocalUtilsHarLookupParams): Promise { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 687087f92814c..101f50fe293a2 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -10258,15 +10258,6 @@ export interface BrowserContext { * @param options */ routeFromHAR(har: string, options?: { - /** - * If set to `true`, requests made via [APIRequestContext](https://playwright.dev/docs/api/class-apirequestcontext) - * (such as [browserContext.request](https://playwright.dev/docs/api/class-browsercontext#browser-context-request) or - * [page.request](https://playwright.dev/docs/api/class-page#page-request)) are also served from the HAR file. By - * default these requests are sent to the network, matching the behavior prior to v1.62. Defaults to `false` for - * backward compatibility. - */ - interceptAPIRequests?: boolean; - /** * - If set to 'abort' any request not found in the HAR file will be aborted. * - If set to 'fallback' falls through to the next route handler in the handler chain. diff --git a/packages/protocol/spec/browserContext.yml b/packages/protocol/spec/browserContext.yml index 50c3b7a99ac76..e3b5700e5ae8b 100644 --- a/packages/protocol/spec/browserContext.yml +++ b/packages/protocol/spec/browserContext.yml @@ -156,26 +156,6 @@ BrowserContext: regexFlags: string? urlPattern: URLPattern? - routeAPIRequestsFromHar: - internal: true - parameters: - harId: string - urlGlob: string? - urlRegexSource: string? - urlRegexFlags: string? - notFound: - type: enum - literals: - - abort - - fallback - returns: - registrationId: string - - unrouteAPIRequestsFromHar: - internal: true - parameters: - registrationId: string - setWebSocketInterceptionPatterns: title: Route WebSockets group: route diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 00ac25e317585..8a17a1ce87f7d 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -824,20 +824,6 @@ scheme.BrowserContextSetNetworkInterceptionPatternsParams = tObject({ })), }); scheme.BrowserContextSetNetworkInterceptionPatternsResult = tOptional(tObject({})); -scheme.BrowserContextRouteAPIRequestsFromHarParams = tObject({ - harId: tString, - urlGlob: tOptional(tString), - urlRegexSource: tOptional(tString), - urlRegexFlags: tOptional(tString), - notFound: tEnum(['abort', 'fallback']), -}); -scheme.BrowserContextRouteAPIRequestsFromHarResult = tObject({ - registrationId: tString, -}); -scheme.BrowserContextUnrouteAPIRequestsFromHarParams = tObject({ - registrationId: tString, -}); -scheme.BrowserContextUnrouteAPIRequestsFromHarResult = tOptional(tObject({})); scheme.BrowserContextSetWebSocketInterceptionPatternsParams = tObject({ patterns: tArray(tObject({ glob: tOptional(tString), diff --git a/tests/library/browsercontext-har.spec.ts b/tests/library/browsercontext-har.spec.ts index 4fc3406516646..98c3e1fe04feb 100644 --- a/tests/library/browsercontext-har.spec.ts +++ b/tests/library/browsercontext-har.spec.ts @@ -610,356 +610,3 @@ it('should ignore aborted requests', async ({ contextFactory, server }) => { expect(result).toBe('timeout'); } }); - -it.describe('interceptAPIRequests', () => { - it('should fulfill APIRequestContext requests from HAR', { - annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22869' } - }, async ({ contextFactory, server }, testInfo) => { - server.setRoute('/api/data', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ hello: 'live' })); - }); - - const harPath = testInfo.outputPath('api.har'); - const context1 = await contextFactory(); - await context1.routeFromHAR(harPath, { update: true }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - const recorded = await page1.request.get(server.PREFIX + '/api/data'); - expect(await recorded.json()).toEqual({ hello: 'live' }); - await context1.close(); - - // Now stop serving on the network side - the request must come from the HAR. - server.setRoute('/api/data', (req, res) => res.end('NOT_FROM_HAR')); - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath, { interceptAPIRequests: true }); - const page2 = await context2.newPage(); - const replayed = await page2.request.get(server.PREFIX + '/api/data'); - expect(await replayed.json()).toEqual({ hello: 'live' }); - expect(replayed.timing()).toEqual({ - startTime: -1, - domainLookupStart: -1, - domainLookupEnd: -1, - connectStart: -1, - secureConnectionStart: -1, - connectEnd: -1, - requestStart: -1, - responseStart: -1, - responseEnd: -1, - }); - await context2.close(); - }); - - it('should not intercept APIRequestContext requests by default (backward compat)', async ({ contextFactory, server }, testInfo) => { - server.setRoute('/api/data', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ hello: 'live' })); - }); - - const harPath = testInfo.outputPath('api.har'); - const context1 = await contextFactory(); - await context1.routeFromHAR(harPath, { update: true }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - await page1.request.get(server.PREFIX + '/api/data'); - await context1.close(); - - // Without the option, the live network is hit. - server.setRoute('/api/data', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ hello: 'fresh' })); - }); - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath, { notFound: 'fallback' }); - const page2 = await context2.newPage(); - const replayed = await page2.request.get(server.PREFIX + '/api/data'); - expect(await replayed.json()).toEqual({ hello: 'fresh' }); - }); - - it('should fall back to the network when interceptAPIRequests + notFound:fallback', async ({ contextFactory, server }, testInfo) => { - const harPath = testInfo.outputPath('api.har'); - const context1 = await contextFactory(); - await context1.routeFromHAR(harPath, { update: true }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - await context1.close(); - - server.setRoute('/api/missing', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ source: 'network' })); - }); - - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath, { interceptAPIRequests: true, notFound: 'fallback' }); - const page2 = await context2.newPage(); - const response = await page2.request.get(server.PREFIX + '/api/missing'); - expect(await response.json()).toEqual({ source: 'network' }); - }); - - it('should abort unmatched APIRequestContext requests when interceptAPIRequests + notFound:abort', async ({ contextFactory, server }, testInfo) => { - const harPath = testInfo.outputPath('api.har'); - const context1 = await contextFactory(); - await context1.routeFromHAR(harPath, { update: true }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - await context1.close(); - - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath, { interceptAPIRequests: true /* default notFound: abort */ }); - const page2 = await context2.newPage(); - const error = await page2.request.get(server.PREFIX + '/api/missing').catch(e => e); - expect(error).toBeInstanceOf(Error); - expect(error.message).toContain('was not found in the HAR file'); - }); - - it('should respect url filter for APIRequestContext requests', async ({ contextFactory, server }, testInfo) => { - server.setRoute('/api/data', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ source: 'hario' })); - }); - server.setRoute('/other', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ source: 'live' })); - }); - - const harPath = testInfo.outputPath('api.har'); - const context1 = await contextFactory(); - await context1.routeFromHAR(harPath, { update: true }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - await page1.request.get(server.PREFIX + '/api/data'); - await page1.request.get(server.PREFIX + '/other'); - await context1.close(); - - // Re-route /api/data so that only the HAR can produce 'hario'. - server.setRoute('/api/data', (req, res) => res.end('NOT_FROM_HAR')); - - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath, { interceptAPIRequests: true, url: '**/api/**', notFound: 'fallback' }); - const page2 = await context2.newPage(); - const fromHar = await page2.request.get(server.PREFIX + '/api/data'); - expect(await fromHar.json()).toEqual({ source: 'hario' }); - // /other does not match the url filter, so it hits the network. - const fromNetwork = await page2.request.get(server.PREFIX + '/other'); - expect(await fromNetwork.json()).toEqual({ source: 'live' }); - }); - - it('should match APIRequestContext POST requests by body', async ({ contextFactory, server }, testInfo) => { - server.setRoute('/echo', (req, res) => { - const chunks: Buffer[] = []; - req.on('data', c => chunks.push(c)); - req.on('end', () => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ echoed: Buffer.concat(chunks).toString() })); - }); - }); - - const harPath = testInfo.outputPath('api.har'); - const context1 = await contextFactory(); - await context1.routeFromHAR(harPath, { update: true }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - await page1.request.post(server.PREFIX + '/echo', { data: 'one' }); - await page1.request.post(server.PREFIX + '/echo', { data: 'two' }); - await context1.close(); - - server.setRoute('/echo', (req, res) => res.end('NOT_FROM_HAR')); - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath, { interceptAPIRequests: true }); - const page2 = await context2.newPage(); - const r1 = await page2.request.post(server.PREFIX + '/echo', { data: 'one' }); - const r2 = await page2.request.post(server.PREFIX + '/echo', { data: 'two' }); - expect(await r1.json()).toEqual({ echoed: 'one' }); - expect(await r2.json()).toEqual({ echoed: 'two' }); - }); - - it('should stop intercepting APIRequestContext requests after unrouteAll', async ({ contextFactory, server }, testInfo) => { - server.setRoute('/api/data', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ source: 'hario' })); - }); - - const harPath = testInfo.outputPath('api.har'); - const context1 = await contextFactory(); - await context1.routeFromHAR(harPath, { update: true }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - await page1.request.get(server.PREFIX + '/api/data'); - await context1.close(); - - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath, { interceptAPIRequests: true }); - const page2 = await context2.newPage(); - // First call: served from HAR. - const first = await page2.request.get(server.PREFIX + '/api/data'); - expect(await first.json()).toEqual({ source: 'hario' }); - - await context2.unrouteAll(); - server.setRoute('/api/data', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ source: 'live' })); - }); - - // After unrouteAll: the registration is gone, the live network is hit. - const second = await page2.request.get(server.PREFIX + '/api/data'); - expect(await second.json()).toEqual({ source: 'live' }); - }); - - it('should only match _apiRequest entries when intercepting APIRequestContext', { - annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/22869' } - }, async ({ contextFactory, server }, testInfo) => { - // The HAR will contain TWO entries for the same URL: one from a browser fetch and one from - // page.request. interceptAPIRequests must serve only the API-request entry. - server.setRoute('/data', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - const fromApi = req.headers['x-from'] === 'api'; - res.end(JSON.stringify({ source: fromApi ? 'recorded-api' : 'recorded-browser' })); - }); - - const harPath = testInfo.outputPath('api.har'); - const context1 = await contextFactory(); - await context1.routeFromHAR(harPath, { update: true }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - // Browser-side fetch — recorded WITHOUT _apiRequest. - await page1.evaluate(url => fetch(url, { headers: { 'x-from': 'browser' } }).then(r => r.json()), server.PREFIX + '/data'); - // API-request — recorded WITH _apiRequest:true. - await page1.request.get(server.PREFIX + '/data', { headers: { 'x-from': 'api' } }); - await context1.close(); - - // Sanity: the HAR must contain at least one _apiRequest entry. - const harText = fs.readFileSync(harPath, 'utf-8'); - expect(harText).toContain('"_apiRequest":true'); - - // Make the live network unreachable for this URL — if interception works correctly - // we never hit the network anyway. - server.setRoute('/data', (req, res) => res.end('NOT_FROM_HAR')); - - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath, { interceptAPIRequests: true }); - const page2 = await context2.newPage(); - const apiResponse = await page2.request.get(server.PREFIX + '/data', { headers: { 'x-from': 'api' } }); - // Must be the recorded API entry, not the recorded browser entry. - expect(await apiResponse.json()).toEqual({ source: 'recorded-api' }); - }); - - it('should apply set-cookie side-effects from intercepted APIRequestContext requests', async ({ contextFactory, server }, testInfo) => { - server.setRoute('/api/login', (req, res) => { - res.setHeader('Set-Cookie', 'session=har-token; Path=/'); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ ok: true })); - }); - - const harPath = testInfo.outputPath('api.har'); - const context1 = await contextFactory(); - await context1.routeFromHAR(harPath, { update: true }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - await page1.request.get(server.PREFIX + '/api/login'); - await context1.close(); - - server.setRoute('/api/login', (req, res) => res.end('NOT_FROM_HAR')); - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath, { interceptAPIRequests: true }); - const page2 = await context2.newPage(); - await page2.request.get(server.PREFIX + '/api/login'); - // The set-cookie from the HAR response must be applied to the browser context. - const cookies = await context2.cookies(server.PREFIX); - expect(cookies.find(c => c.name === 'session')?.value).toBe('har-token'); - }); - - it('should populate statusText and serverAddr for intercepted APIRequestContext requests', async ({ contextFactory, server }, testInfo) => { - server.setRoute('/api/data', (req, res) => { - res.statusCode = 201; - res.statusMessage = 'Created'; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ ok: true })); - }); - - const harPath = testInfo.outputPath('api.har'); - const context1 = await contextFactory(); - // 'full' mode records serverIPAddress/serverPort; the default 'minimal' mode omits them. - await context1.routeFromHAR(harPath, { update: true, updateMode: 'full' }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - await page1.request.get(server.PREFIX + '/api/data'); - await context1.close(); - - server.setRoute('/api/data', (req, res) => res.end('NOT_FROM_HAR')); - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath, { interceptAPIRequests: true }); - const page2 = await context2.newPage(); - const response = await page2.request.get(server.PREFIX + '/api/data'); - expect(response.status()).toBe(201); - expect(response.statusText()).toBe('Created'); - const addr = await response.serverAddr(); - expect(addr!.ipAddress).toMatch(/127\.0\.0\.1|::1/); - expect(addr!.port).toBe(server.PORT); - }); - - it('should re-record intercepted APIRequestContext requests into a new HAR', async ({ contextFactory, server }, testInfo) => { - server.setRoute('/api/data', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ hello: 'live' })); - }); - - // Record the first HAR with the API request. - const harPath1 = testInfo.outputPath('api1.har'); - const context1 = await contextFactory(); - await context1.routeFromHAR(harPath1, { update: true }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - await page1.request.get(server.PREFIX + '/api/data'); - await context1.close(); - - // Replay from the first HAR while recording into a second HAR. The Request/RequestFinished - // events emitted from the HAR-replay path must cause the API request to be captured again. - server.setRoute('/api/data', (req, res) => res.end('NOT_FROM_HAR')); - const harPath2 = testInfo.outputPath('api2.har'); - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath1, { interceptAPIRequests: true }); - await context2.routeFromHAR(harPath2, { update: true }); - const page2 = await context2.newPage(); - await page2.goto(server.EMPTY_PAGE); - const replayed = await page2.request.get(server.PREFIX + '/api/data'); - expect(await replayed.json()).toEqual({ hello: 'live' }); - await context2.close(); - - const harText = fs.readFileSync(harPath2, 'utf-8'); - expect(harText).toContain('"_apiRequest":true'); - expect(harText).toContain('/api/data'); - }); - - it('should throw when intercepted APIRequestContext request exceeds maxRedirects', async ({ contextFactory, server }, testInfo) => { - const redirect = '/api/step1'; - server.setRoute('/api/start', (req, res) => { - res.statusCode = 302; - res.setHeader('Location', server.PREFIX + redirect); - res.end(); - }); - server.setRoute('/api/step1', (req, res) => { - res.statusCode = 302; - res.setHeader('Location', server.PREFIX + '/api/step2'); - res.end(); - }); - server.setRoute('/api/step2', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ done: true })); - }); - - const harPath = testInfo.outputPath('api.har'); - const context1 = await contextFactory(); - await context1.routeFromHAR(harPath, { update: true }); - const page1 = await context1.newPage(); - await page1.goto(server.EMPTY_PAGE); - await page1.request.get(server.PREFIX + '/api/start'); - await context1.close(); - - const context2 = await contextFactory(); - await context2.routeFromHAR(harPath, { interceptAPIRequests: true }); - const page2 = await context2.newPage(); - const error = await page2.request.get(server.PREFIX + '/api/start', { maxRedirects: 1 }).catch(e => e); - expect(error).toBeInstanceOf(Error); - expect(error.message).toContain('Max redirect count exceeded'); - }); -}); From 28e86763ac4218fa8602a845f8dec080346688a0 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Thu, 23 Jul 2026 15:35:32 -0600 Subject: [PATCH 5/5] docs: release notes for v1.62 (#41940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release notes for the upcoming **v1.62** release. ### Highlights - **New component testing model** — stories & galleries with the new `mount` fixture - **AbortSignal** support on operations and web-first assertions (`signal` option) - **WebP screenshots** — store `toHaveScreenshot()` snapshots as `.webp` - **`Reporter.preprocess()`** hook for custom test filtering - **Isolated retries** via `retryStrategy: 'isolated'` ### Also includes - New APIs: WebAuthn passkeys in storage state (`storageState.credentials`), `APIResponse.timing()`, `Locator.waitForFunction()`, functions as `evaluate`/`addInitScript` args, `scroll` action option - CLI/MCP: Playwright now bundles the MCP server and `playwright-cli` (`npx playwright mcp` / `npx playwright cli`) - HTML reporter `mergeFiles` config option - Announcement: Debian 11 is no longer supported - Browser versions: Chromium 151.0.7922.34, Firefox 153.0, WebKit 26.5 Validated locally with `npm run doc`. --- docs/src/release-notes-js.md | 132 +++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index fba238dec22c9..7014d15148c90 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -6,6 +6,138 @@ toc_max_heading_level: 2 import LiteYouTube from '@site/src/components/LiteYouTube'; +## Version 1.62 + +### 🧱 New component testing model + +[Component testing](./test-components.md) moves to a **stories and galleries** model. +A **story** wraps your component in one specific scenario — hard-coded props, mock data, providers — and a +**gallery** page that you serve renders stories on demand. The new [`method: Fixtures.mount`] fixture navigates +to the gallery, mounts a story by id, and returns a [Locator] scoped to the story's root element: + +```js +test('click should expand', async ({ mount }) => { + const component = await mount('components/Expandable/Stateful'); + await component.getByRole('button').click(); + await expect(component.getByTestId('expanded')).toHaveValue('true'); +}); +``` + +Pass a story type as a template argument to type-check its props, and use `update(props)` / +`unmount()` on the returned locator to re-render or tear down within a test. + +### 🛑 Cancel operations with AbortSignal + +Most operations and web-first assertions now accept a `signal` option that takes an +[`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal), letting you +cancel long-running actions, navigations, waits, and assertions: + +```js +const controller = new AbortController(); +setTimeout(() => controller.abort(), 1000); + +await page.getByRole('button', { name: 'Submit' }).click({ signal: controller.signal }); +await expect(page.getByText('Done')).toBeVisible({ signal: controller.signal }); +``` + +Providing a signal does not disable the default timeout; pass `timeout: 0` to disable it. + +### 🖼️ WebP screenshots + +[`method: PageAssertions.toHaveScreenshot#1`] and [`method: LocatorAssertions.toHaveScreenshot#1`] +can now store snapshots in the WebP format — just give the snapshot a `.webp` name: + +```js +// Visual comparisons store the golden snapshot as lossless WebP. +await expect(page).toHaveScreenshot('homepage.webp'); + +// Standalone screenshots can trade quality for size with lossy WebP. +await page.screenshot({ path: 'homepage.webp', quality: 50 }); +``` + +[`method: Page.screenshot`] and [`method: Locator.screenshot`] also accept `webp` as a `type`, +where quality `100` (the default) is lossless and lower values use lossy compression. + +### 🧩 Custom test filtering with Reporter.preprocess() + +New [`method: Reporter.preprocess`] hook runs after the configuration is resolved and before +[`method: Reporter.onBegin`], letting a reporter mark individual tests as skipped, excluded, +fixed, or failing through a [TestRun] object: + +```js +class MyReporter { + async preprocess({ config, suite, testRun }) { + for (const test of suite.allTests()) { + if (shouldSkip(test)) + testRun.skip(test); + } + } +} +``` + +### 🔁 Isolated retries + +New [`property: TestConfig.retryStrategy`] controls when failed tests are retried. The default +`'immediate'` retries as soon as a worker is free; `'isolated'` runs all retries at the end, +one by one in a single worker, to minimize interference with the rest of the suite: + +```js title="playwright.config.ts" +export default defineConfig({ + retries: 2, + retryStrategy: 'isolated', +}); +``` + +### New APIs + +#### Browser and Context + +- New option [`option: BrowserContext.storageState.credentials`] includes the context's virtual WebAuthn [Credentials] (passkeys) in the storage state, so they can be persisted and re-seeded into later contexts. + +#### Actions + +- New `scroll` option (`"auto"` | `"none"`) on actions to opt out of Playwright's automatic scroll-into-view. + +#### Network + +- New [`method: APIResponse.timing`] returns resource timing information for an API response. + +#### Evaluation + +- New [`method: Locator.waitForFunction`] waits until a function — called with the matching element — returns a truthy value. +- [`method: Page.evaluate`] and related methods now accept functions as evaluate arguments. +- [`method: Page.addInitScript`] / [`method: BrowserContext.addInitScript`] now accept functions as init-script arguments. + +#### Command line & MCP + +- Playwright now bundles the [Playwright MCP](./getting-started-mcp.md) server and [`playwright-cli`](./getting-started-cli.md), runnable via `npx playwright mcp` and `npx playwright cli`. + +#### Reporters + +- The HTML report's **Merge files** grouping — previously only a UI toggle — can now be enabled from the config with the new `mergeFiles` reporter option: + +```js title="playwright.config.ts" +export default defineConfig({ + reporter: [['html', { mergeFiles: true }]], +}); +``` + +### Announcements + +* ⚠️ Debian 11 is not supported anymore. + +### Browser Versions + +- Chromium 151.0.7922.34 +- Mozilla Firefox 153.0 +- WebKit 26.5 + +This version was also tested against the following stable channels: + +- Google Chrome 151 +- Microsoft Edge 151 + + ## Version 1.61 ### 🔑 WebAuthn passkeys