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/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 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..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', }], @@ -331,6 +329,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-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 5f00e885856c3..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, @@ -5000,6 +4976,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 +4984,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 +4997,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 +5032,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/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 bb47a21310a93..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, @@ -5001,6 +4977,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 +4985,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 +4998,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 +5033,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/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/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/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/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/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/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/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/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/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/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..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), @@ -2939,6 +2925,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 +2954,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/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'); - }); -}); 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); +}); 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(); +}); 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[] = [];