From e368e503cfe683353a0e100d687053bc88d3462a Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Sat, 14 Mar 2026 14:04:01 +0000 Subject: [PATCH] feat: context.debugger api (#39667) --- docs/src/api/class-browsercontext.md | 7 ++ docs/src/api/class-debugger.md | 50 +++++++++++ packages/playwright-client/types/types.d.ts | 88 ++++++++++++++++++ packages/playwright-core/src/client/api.ts | 1 + .../src/client/browserContext.ts | 3 + .../playwright-core/src/client/connection.ts | 4 + .../playwright-core/src/client/debugger.ts | 51 +++++++++++ packages/playwright-core/src/client/events.ts | 4 + .../playwright-core/src/protocol/validator.ts | 25 ++++++ .../playwright-core/src/server/debugger.ts | 54 +++++------ .../dispatchers/browserContextDispatcher.ts | 4 + .../server/dispatchers/debuggerDispatcher.ts | 59 ++++++++++++ .../playwright-core/src/server/recorder.ts | 11 +-- .../src/utils/isomorphic/protocolMetainfo.ts | 3 + packages/playwright-core/types/types.d.ts | 88 ++++++++++++++++++ packages/protocol/src/channels.d.ts | 49 ++++++++++ packages/protocol/src/protocol.yml | 42 +++++++++ tests/library/channels.spec.ts | 7 ++ tests/library/debugger.spec.ts | 89 +++++++++++++++++++ tests/library/inspector/pause.spec.ts | 23 +++-- 20 files changed, 620 insertions(+), 42 deletions(-) create mode 100644 docs/src/api/class-debugger.md create mode 100644 packages/playwright-core/src/client/debugger.ts create mode 100644 packages/playwright-core/src/server/dispatchers/debuggerDispatcher.ts create mode 100644 tests/library/debugger.spec.ts diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 72a8f13829833..ea7afa54fc259 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -74,6 +74,13 @@ This event is not emitted. Playwright has ability to mock clock and passage of time. +## property: BrowserContext.debugger +* since: v1.59 +* langs: js +- type: <[Debugger]> + +Debugger allows to pause and resume the execution. + ## event: BrowserContext.close * since: v1.8 - argument: <[BrowserContext]> diff --git a/docs/src/api/class-debugger.md b/docs/src/api/class-debugger.md new file mode 100644 index 0000000000000..4f3068f7cce68 --- /dev/null +++ b/docs/src/api/class-debugger.md @@ -0,0 +1,50 @@ +# class: Debugger +* since: v1.59 +* langs: js + +API for controlling the Playwright debugger. The debugger allows pausing script execution and inspecting the page. +Obtain the debugger instance via [`property: BrowserContext.debugger`]. + +See also [`method: Page.pause`] for a simple way to pause script execution. + +## event: Debugger.pausedStateChanged +* since: v1.59 + +Emitted when the debugger pauses or resumes. + +## method: Debugger.pausedDetails +* since: v1.59 +- returns: <[Array]<[Object]>> + - `location` <[Object]> + - `file` <[string]> + - `line` ?<[int]> + - `column` ?<[int]> + - `title` <[string]> + +Returns details about the currently paused calls. Returns an empty array if the debugger is not paused. + +## async method: Debugger.resume +* since: v1.59 + +Resumes script execution if the debugger is paused. + +## async method: Debugger.setPauseAt +* since: v1.59 + +Configures the debugger to pause at the next action or at a specific source location. +Call without arguments to reset the pausing behavior. + +### option: Debugger.setPauseAt.next +* since: v1.59 +- `next` <[boolean]> + +When `true`, the debugger will pause before the next action. + +### option: Debugger.setPauseAt.location +* since: v1.59 +- `location` <[Object]> + - `file` <[string]> + - `line` ?<[int]> + - `column` ?<[int]> + +When specified, the debugger will pause when the action originates from the given source location. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 8c1d25aa20a21..2449c65b39211 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -9695,6 +9695,11 @@ export interface BrowserContext { */ clock: Clock; + /** + * Debugger allows to pause and resume the execution. + */ + debugger: Debugger; + /** * API testing helper associated with this context. Requests made with this API will use context cookies. */ @@ -19404,6 +19409,89 @@ export interface Coverage { }>>; } +/** + * API for controlling the Playwright debugger. The debugger allows pausing script execution and inspecting the page. + * Obtain the debugger instance via + * [browserContext.debugger](https://playwright.dev/docs/api/class-browsercontext#browser-context-debugger). + * + * See also [page.pause()](https://playwright.dev/docs/api/class-page#page-pause) for a simple way to pause script + * execution. + */ +export interface Debugger { + /** + * Emitted when the debugger pauses or resumes. + */ + on(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Emitted when the debugger pauses or resumes. + */ + addListener(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Emitted when the debugger pauses or resumes. + */ + prependListener(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Returns details about the currently paused calls. Returns an empty array if the debugger is not paused. + */ + pausedDetails(): Array<{ + location: { + file: string; + + line?: number; + + column?: number; + }; + + title: string; + }>; + + /** + * Resumes script execution if the debugger is paused. + */ + resume(): Promise; + + /** + * Configures the debugger to pause at the next action or at a specific source location. Call without arguments to + * reset the pausing behavior. + * @param options + */ + setPauseAt(options?: { + /** + * When specified, the debugger will pause when the action originates from the given source location. + */ + location?: { + file: string; + + line?: number; + + column?: number; + }; + + /** + * When `true`, the debugger will pause before the next action. + */ + next?: boolean; + }): Promise; +} + /** * [Dialog](https://playwright.dev/docs/api/class-dialog) objects are dispatched by page via the * [page.on('dialog')](https://playwright.dev/docs/api/class-page#page-event-dialog) event. diff --git a/packages/playwright-core/src/client/api.ts b/packages/playwright-core/src/client/api.ts index fba51a2dd1ac5..3a36d03285cef 100644 --- a/packages/playwright-core/src/client/api.ts +++ b/packages/playwright-core/src/client/api.ts @@ -22,6 +22,7 @@ export { BrowserType } from './browserType'; export { Clock } from './clock'; export { ConsoleMessage } from './consoleMessage'; export { Coverage } from './coverage'; +export { Debugger } from './debugger'; export { Dialog } from './dialog'; export type { Disposable } from './disposable'; export { Download } from './download'; diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index c0b5b49a8a964..3a06890167776 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -22,6 +22,7 @@ import { ChannelOwner } from './channelOwner'; import { evaluationScript } from './clientHelper'; import { Clock } from './clock'; import { ConsoleMessage } from './consoleMessage'; +import { Debugger } from './debugger'; import { Dialog } from './dialog'; import { DisposableObject, DisposableStub } from './disposable'; import { TargetClosedError, parseError } from './errors'; @@ -69,6 +70,7 @@ export class BrowserContext extends ChannelOwner private _closedPromise: Promise; readonly _options: channels.BrowserNewContextParams; + readonly debugger: Debugger; readonly request: APIRequestContext; readonly tracing: Tracing; readonly clock: Clock; @@ -93,6 +95,7 @@ export class BrowserContext extends ChannelOwner super(parent, type, guid, initializer); this._options = initializer.options; this._timeoutSettings = new TimeoutSettings(this._platform); + this.debugger = Debugger.from(initializer.debugger); this.tracing = Tracing.from(initializer.tracing); this.request = APIRequestContext.from(initializer.requestContext); this.request._timeoutSettings = this._timeoutSettings; diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index d727fe3590255..ad0de51c09692 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -23,6 +23,7 @@ import { BrowserType } from './browserType'; import { CDPSession } from './cdpSession'; import { ChannelOwner } from './channelOwner'; import { createInstrumentation } from './clientInstrumentation'; +import { Debugger } from './debugger'; import { Dialog } from './dialog'; import { DisposableObject } from './disposable'; import { Electron, ElectronApplication } from './electron'; @@ -266,6 +267,9 @@ export class Connection extends EventEmitter { case 'CDPSession': result = new CDPSession(parent, type, guid, initializer); break; + case 'Debugger': + result = new Debugger(parent, type, guid, initializer); + break; case 'Dialog': result = new Dialog(parent, type, guid, initializer); break; diff --git a/packages/playwright-core/src/client/debugger.ts b/packages/playwright-core/src/client/debugger.ts new file mode 100644 index 0000000000000..013e3c41735b8 --- /dev/null +++ b/packages/playwright-core/src/client/debugger.ts @@ -0,0 +1,51 @@ +/** + * 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 { ChannelOwner } from './channelOwner'; +import { Events } from './events'; + +import type * as api from '../../types/types'; +import type * as channels from '@protocol/channels'; + +type PausedDetail = { location: { file: string, line?: number, column?: number }, title: string }; + +export class Debugger extends ChannelOwner implements api.Debugger { + private _pausedDetails: PausedDetail[] = []; + + static from(channel: channels.DebuggerChannel): Debugger { + return (channel as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.DebuggerInitializer) { + super(parent, type, guid, initializer); + this._channel.on('pausedStateChanged', ({ pausedDetails }) => { + this._pausedDetails = pausedDetails; + this.emit(Events.Debugger.PausedStateChanged); + }); + } + + async setPauseAt(options: { next?: boolean, location?: { file: string, line?: number, column?: number } } = {}) { + await this._channel.setPauseAt(options); + } + + async resume(): Promise { + await this._channel.resume(); + } + + pausedDetails(): PausedDetail[] { + return this._pausedDetails; + } +} diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index cede73640cf3b..5eb1cb711783d 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -34,6 +34,10 @@ export const Events = { Disconnected: 'disconnected' }, + Debugger: { + PausedStateChanged: 'pausedstatechanged' + }, + BrowserContext: { Console: 'console', Close: 'close', diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 65b8d843f452d..a22619d8dc5a6 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -844,6 +844,7 @@ scheme.BrowserContextWaitForEventInfoParams = tType('EventTargetWaitForEventInfo scheme.PageWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.WorkerWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.WebSocketWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.DebuggerWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.ElectronApplicationWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.AndroidDeviceWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.EventTargetWaitForEventInfoResult = tOptional(tObject({})); @@ -851,9 +852,11 @@ scheme.BrowserContextWaitForEventInfoResult = tType('EventTargetWaitForEventInfo scheme.PageWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.WorkerWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.WebSocketWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.DebuggerWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.ElectronApplicationWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.AndroidDeviceWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.BrowserContextInitializer = tObject({ + debugger: tChannel(['Debugger']), requestContext: tChannel(['APIRequestContext']), tracing: tChannel(['Tracing']), options: tObject({ @@ -2494,6 +2497,28 @@ scheme.BindingCallResolveParams = tObject({ result: tType('SerializedArgument'), }); scheme.BindingCallResolveResult = tOptional(tObject({})); +scheme.DebuggerInitializer = tOptional(tObject({})); +scheme.DebuggerPausedStateChangedEvent = tObject({ + pausedDetails: tArray(tObject({ + location: tObject({ + file: tString, + line: tOptional(tInt), + column: tOptional(tInt), + }), + title: tString, + })), +}); +scheme.DebuggerSetPauseAtParams = tObject({ + next: tOptional(tBoolean), + location: tOptional(tObject({ + file: tString, + line: tOptional(tInt), + column: tOptional(tInt), + })), +}); +scheme.DebuggerSetPauseAtResult = tOptional(tObject({})); +scheme.DebuggerResumeParams = tOptional(tObject({})); +scheme.DebuggerResumeResult = tOptional(tObject({})); scheme.DialogInitializer = tObject({ page: tOptional(tChannel(['Page'])), type: tString, diff --git a/packages/playwright-core/src/server/debugger.ts b/packages/playwright-core/src/server/debugger.ts index 7ca2ed6e15827..d8f76990d631d 100644 --- a/packages/playwright-core/src/server/debugger.ts +++ b/packages/playwright-core/src/server/debugger.ts @@ -14,18 +14,19 @@ * limitations under the License. */ -import { EventEmitter } from 'events'; - +import { SdkObject } from './instrumentation'; import { debugMode, isUnderTest, monotonicTime } from '../utils'; import { BrowserContext } from './browserContext'; import { methodMetainfo } from '../utils/isomorphic/protocolMetainfo'; -import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; +import type { CallMetadata, InstrumentationListener } from './instrumentation'; const symbol = Symbol('Debugger'); -export class Debugger extends EventEmitter implements InstrumentationListener { - private _pauseOnNextStatement = false; +type PauseAt = { next?: boolean, location?: { file: string, line?: number, column?: number } }; + +export class Debugger extends SdkObject implements InstrumentationListener { + private _pauseAt: PauseAt = {}; private _pausedCallsMetadata = new Map void, sdkObject: SdkObject }>(); private _enabled: boolean; private _context: BrowserContext; @@ -36,12 +37,12 @@ export class Debugger extends EventEmitter implements InstrumentationListener { private _muted = false; constructor(context: BrowserContext) { - super(); + super(context, 'debugger'); this._context = context; (this._context as any)[symbol] = this; - this._enabled = debugMode() === 'inspector'; - if (this._enabled) - this.pauseOnNextStatement(); + this._enabled = !context.attribution.playwright.options.isServer && (isUnderTest() || !!context._browser.options.headful); + if (debugMode() === 'inspector') + this.setPauseAt({ next: true }); context.instrumentation.addListener(this, context); this._context.once(BrowserContext.Events.Close, () => { this._context.instrumentation.removeListener(this); @@ -55,21 +56,26 @@ export class Debugger extends EventEmitter implements InstrumentationListener { async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { if (this._muted) return; - if (shouldPauseOnCall(sdkObject, metadata) || (this._pauseOnNextStatement && shouldPauseBeforeStep(metadata))) - await this.pause(sdkObject, metadata); + const pauseOnPauseCall = this._enabled && metadata.method === 'pause'; + const pauseOnNextStep = !!this._pauseAt.next && shouldPauseBeforeStep(metadata); + const pauseOnLocation = !!this._pauseAt.location && matchesLocation(metadata, this._pauseAt.location); + if (pauseOnPauseCall || pauseOnNextStep || pauseOnLocation) + await this._pause(sdkObject, metadata); } async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { if (this._muted) return; - if (this._enabled && this._pauseOnNextStatement) - await this.pause(sdkObject, metadata); + const pauseOnNextStep = !!this._pauseAt.next; + const pauseOnLocation = !!this._pauseAt.location && matchesLocation(metadata, this._pauseAt.location); + if (pauseOnNextStep || pauseOnLocation) + await this._pause(sdkObject, metadata); } - async pause(sdkObject: SdkObject, metadata: CallMetadata) { + private async _pause(sdkObject: SdkObject, metadata: CallMetadata) { if (this._muted) return; - this._enabled = true; + this._pauseAt = {}; metadata.pauseStartTime = monotonicTime(); const result = new Promise(resolve => { this._pausedCallsMetadata.set(metadata, { resolve, sdkObject }); @@ -78,11 +84,10 @@ export class Debugger extends EventEmitter implements InstrumentationListener { return result; } - resume(step: boolean) { + resume() { if (!this.isPaused()) return; - this._pauseOnNextStatement = step; const endTime = monotonicTime(); for (const [metadata, { resolve }] of this._pausedCallsMetadata) { metadata.pauseEndTime = endTime; @@ -92,8 +97,9 @@ export class Debugger extends EventEmitter implements InstrumentationListener { this.emit(Debugger.Events.PausedStateChanged); } - pauseOnNextStatement() { - this._pauseOnNextStatement = true; + setPauseAt(at: { next?: boolean, location?: { file: string, line?: number, column?: number } } = {}) { + this._enabled = true; + this._pauseAt = at; } isPaused(metadata?: CallMetadata): boolean { @@ -110,12 +116,10 @@ export class Debugger extends EventEmitter implements InstrumentationListener { } } -function shouldPauseOnCall(sdkObject: SdkObject, metadata: CallMetadata): boolean { - if (sdkObject.attribution.playwright.options.isServer) - return false; - if (!sdkObject.attribution.browser?.options.headful && !isUnderTest()) - return false; - return metadata.method === 'pause'; +function matchesLocation(metadata: CallMetadata, location: { file: string, line?: number, column?: number }): boolean { + return metadata.location?.file === location.file && + (location.line === undefined || metadata.location.line === location.line) && + (location.column === undefined || metadata.location.column === location.column); } function shouldPauseBeforeStep(metadata: CallMetadata): boolean { diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 5a60a15b992d0..af4fc87ce5b78 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -20,6 +20,7 @@ import path from 'path'; import { BrowserContext } from '../browserContext'; import { ArtifactDispatcher } from './artifactDispatcher'; import { CDPSessionDispatcher } from './cdpSessionDispatcher'; +import { DebuggerDispatcher } from './debuggerDispatcher'; import { DialogDispatcher } from './dialogDispatcher'; import { Dispatcher } from './dispatcher'; import { FrameDispatcher } from './frameDispatcher'; @@ -69,15 +70,18 @@ export class BrowserContextDispatcher extends Dispatcher implements channels.DebuggerChannel { + _type_EventTarget = true; + _type_Debugger = true; + + static from(scope: BrowserContextDispatcher, debugger_: Debugger): DebuggerDispatcher { + const result = scope.connection.existingDispatcher(debugger_); + return result || new DebuggerDispatcher(scope, debugger_); + } + + constructor(scope: BrowserContextDispatcher, debugger_: Debugger) { + super(scope, debugger_, 'Debugger', {}); + this.addObjectListener(Debugger.Events.PausedStateChanged, () => { + this._dispatchEvent('pausedStateChanged', { pausedDetails: this._serializePausedDetails() }); + }); + } + + private _serializePausedDetails(): channels.DebuggerPausedStateChangedEvent['pausedDetails'] { + return this._object.pausedDetails().map(({ metadata, sdkObject }) => ({ + location: { + file: metadata.location?.file ?? '', + line: metadata.location?.line, + column: metadata.location?.column, + }, + title: renderTitleForCall(metadata), + })); + } + + async setPauseAt(params: channels.DebuggerSetPauseAtParams, progress: Progress): Promise { + this._object.setPauseAt(params); + } + + async resume(params: channels.DebuggerResumeParams, progress: Progress): Promise { + this._object.resume(); + } +} diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 5f976cb80c665..08fcb4a6d60e3 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -212,7 +212,7 @@ export class Recorder extends EventEmitter implements Instrume }); await this._context.exposeBinding(progress, '__pw_resume', false, () => { - this._debugger.resume(false); + this._debugger.resume(); }); this._context.on(BrowserContext.Events.Page, (page: Page) => this._onPage(page)); @@ -325,7 +325,8 @@ export class Recorder extends EventEmitter implements Instrume } step() { - this._debugger.resume(true); + this._debugger.setPauseAt({ next: true }); + this._debugger.resume(); } async setLanguage(language: Language) { @@ -334,11 +335,11 @@ export class Recorder extends EventEmitter implements Instrume } resume() { - this._debugger.resume(false); + this._debugger.resume(); } pause() { - this._debugger.pauseOnNextStatement(); + this._debugger.setPauseAt({ next: true }); } paused() { @@ -346,7 +347,7 @@ export class Recorder extends EventEmitter implements Instrume } close() { - this._debugger.resume(false); + this._debugger.resume(); } async hideHighlightedSelector() { diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index 3104b4c4c740a..a8d7ee1f9d56f 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -67,6 +67,7 @@ export const methodMetainfo = new Map>; } +/** + * API for controlling the Playwright debugger. The debugger allows pausing script execution and inspecting the page. + * Obtain the debugger instance via + * [browserContext.debugger](https://playwright.dev/docs/api/class-browsercontext#browser-context-debugger). + * + * See also [page.pause()](https://playwright.dev/docs/api/class-page#page-pause) for a simple way to pause script + * execution. + */ +export interface Debugger { + /** + * Emitted when the debugger pauses or resumes. + */ + on(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Emitted when the debugger pauses or resumes. + */ + addListener(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Emitted when the debugger pauses or resumes. + */ + prependListener(event: 'pausedstatechanged', listener: () => any): this; + + /** + * Returns details about the currently paused calls. Returns an empty array if the debugger is not paused. + */ + pausedDetails(): Array<{ + location: { + file: string; + + line?: number; + + column?: number; + }; + + title: string; + }>; + + /** + * Resumes script execution if the debugger is paused. + */ + resume(): Promise; + + /** + * Configures the debugger to pause at the next action or at a specific source location. Call without arguments to + * reset the pausing behavior. + * @param options + */ + setPauseAt(options?: { + /** + * When specified, the debugger will pause when the action originates from the given source location. + */ + location?: { + file: string; + + line?: number; + + column?: number; + }; + + /** + * When `true`, the debugger will pause before the next action. + */ + next?: boolean; + }): Promise; +} + /** * [Dialog](https://playwright.dev/docs/api/class-dialog) objects are dispatched by page via the * [page.on('dialog')](https://playwright.dev/docs/api/class-page#page-event-dialog) event. diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index c0a9f5c6c2544..f9d1d4613fd42 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -38,6 +38,7 @@ export type InitializerTraits = T extends ArtifactChannel ? ArtifactInitializer : T extends TracingChannel ? TracingInitializer : T extends DialogChannel ? DialogInitializer : + T extends DebuggerChannel ? DebuggerInitializer : T extends BindingCallChannel ? BindingCallInitializer : T extends WebSocketChannel ? WebSocketInitializer : T extends ResponseChannel ? ResponseInitializer : @@ -76,6 +77,7 @@ export type EventsTraits = T extends ArtifactChannel ? ArtifactEvents : T extends TracingChannel ? TracingEvents : T extends DialogChannel ? DialogEvents : + T extends DebuggerChannel ? DebuggerEvents : T extends BindingCallChannel ? BindingCallEvents : T extends WebSocketChannel ? WebSocketEvents : T extends ResponseChannel ? ResponseEvents : @@ -114,6 +116,7 @@ export type EventTargetTraits = T extends ArtifactChannel ? ArtifactEventTarget : T extends TracingChannel ? TracingEventTarget : T extends DialogChannel ? DialogEventTarget : + T extends DebuggerChannel ? DebuggerEventTarget : T extends BindingCallChannel ? BindingCallEventTarget : T extends WebSocketChannel ? WebSocketEventTarget : T extends ResponseChannel ? ResponseEventTarget : @@ -1545,6 +1548,7 @@ export interface EventTargetEvents { // ----------- BrowserContext ----------- export type BrowserContextInitializer = { + debugger: DebuggerChannel, requestContext: APIRequestContextChannel, tracing: TracingChannel, options: { @@ -4309,6 +4313,51 @@ export type BindingCallResolveResult = void; export interface BindingCallEvents { } +// ----------- Debugger ----------- +export type DebuggerInitializer = {}; +export interface DebuggerEventTarget { + on(event: 'pausedStateChanged', callback: (params: DebuggerPausedStateChangedEvent) => void): this; +} +export interface DebuggerChannel extends DebuggerEventTarget, EventTargetChannel { + _type_Debugger: boolean; + setPauseAt(params: DebuggerSetPauseAtParams, progress?: Progress): Promise; + resume(params?: DebuggerResumeParams, progress?: Progress): Promise; +} +export type DebuggerPausedStateChangedEvent = { + pausedDetails: { + location: { + file: string, + line?: number, + column?: number, + }, + title: string, + }[], +}; +export type DebuggerSetPauseAtParams = { + next?: boolean, + location?: { + file: string, + line?: number, + column?: number, + }, +}; +export type DebuggerSetPauseAtOptions = { + next?: boolean, + location?: { + file: string, + line?: number, + column?: number, + }, +}; +export type DebuggerSetPauseAtResult = void; +export type DebuggerResumeParams = {}; +export type DebuggerResumeOptions = {}; +export type DebuggerResumeResult = void; + +export interface DebuggerEvents { + 'pausedStateChanged': DebuggerPausedStateChangedEvent; +} + // ----------- Dialog ----------- export type DialogInitializer = { page?: PageChannel, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index e53c57c5f83ad..5cb737443885b 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1193,6 +1193,7 @@ BrowserContext: extends: EventTarget initializer: + debugger: Debugger requestContext: APIRequestContext tracing: Tracing options: @@ -3795,6 +3796,47 @@ BindingCall: +Debugger: + type: interface + + extends: EventTarget + + commands: + + setPauseAt: + title: Configure pause behavior + group: configuration + parameters: + next: boolean? + location: + type: object? + properties: + file: string + line: int? + column: int? + + resume: + title: Resume + group: configuration + + events: + + pausedStateChanged: + parameters: + pausedDetails: + type: array + items: + type: object + properties: + location: + type: object + properties: + file: string + line: int? + column: int? + title: string + + Dialog: type: interface diff --git a/tests/library/channels.spec.ts b/tests/library/channels.spec.ts index 63741d7555f80..7bff98362dc15 100644 --- a/tests/library/channels.spec.ts +++ b/tests/library/channels.spec.ts @@ -75,6 +75,7 @@ it('should scope context handles', async ({ browserType, server, expectScopeStat { _guid: 'response', objects: [] }, ] }, ] }, + { _guid: 'debugger', objects: [] }, { _guid: 'request-context', objects: [] }, { _guid: 'tracing', objects: [] } ] }, @@ -162,6 +163,7 @@ it('should scope browser handles', async ({ browserType, expectScopeState }) => { _guid: 'browser', objects: [ { _guid: 'browser-context', objects: [ + { _guid: 'debugger', objects: [] }, { _guid: 'request-context', objects: [] }, { _guid: 'tracing', objects: [] }, ] }, @@ -205,6 +207,7 @@ it('should not generate dispatchers for subresources w/o listeners', async ({ pa ] }, ] }, + { _guid: 'debugger', objects: [] }, { _guid: 'request-context', objects: [] }, { _guid: 'tracing', objects: [] } ] }, @@ -302,6 +305,10 @@ it('exposeFunction should not leak', async ({ page, expectScopeState, server }) }, ], }, + { + '_guid': 'debugger', + 'objects': [], + }, { '_guid': 'request-context', 'objects': [], diff --git a/tests/library/debugger.spec.ts b/tests/library/debugger.spec.ts new file mode 100644 index 0000000000000..0efd9d1f6a335 --- /dev/null +++ b/tests/library/debugger.spec.ts @@ -0,0 +1,89 @@ +/** + * 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 { contextTest as it, expect } from '../config/browserTest'; + +it('should pause at next and resume', async ({ context, server }) => { + const page = await context.newPage(); + await page.setContent('
click me
'); + const dbg = context.debugger; + expect(dbg.pausedDetails()).toEqual([]); + + await dbg.setPauseAt({ next: true }); + const clickPromise = page.click('div'); + await new Promise(resolve => dbg.once('pausedstatechanged', resolve)); + + expect(dbg.pausedDetails()).toEqual([ + expect.objectContaining({ + title: expect.stringContaining('Click'), + location: expect.objectContaining({ + file: expect.stringContaining('debugger.spec'), + line: expect.any(Number), + column: expect.any(Number), + }), + }), + ]); + + await Promise.all([ + dbg.resume(), + new Promise(resolve => dbg.once('pausedstatechanged', resolve)), + clickPromise, + ]); + expect(dbg.pausedDetails()).toEqual([]); +}); + +it('should pause at pause call', async ({ context, server }) => { + const page = await context.newPage(); + await page.setContent('
click me
'); + const dbg = context.debugger; + expect(dbg.pausedDetails()).toEqual([]); + + await dbg.setPauseAt(); + const pausePromise = page.pause(); + await new Promise(resolve => dbg.once('pausedstatechanged', resolve)); + + expect(dbg.pausedDetails()).toEqual([ + expect.objectContaining({ + title: expect.stringContaining('Pause'), + }), + ]); + + await dbg.resume(); + await pausePromise; +}); + +it('should pause at location', async ({ context, server }) => { + const page = await context.newPage(); + await page.setContent('
click me
'); + const dbg = context.debugger; + expect(dbg.pausedDetails()).toEqual([]); + + const line = +(() => { return new Error('').stack.match(/debugger.spec.ts:(\d+)/)[1]; })(); + // Note: careful with the line offset below. + await dbg.setPauseAt({ location: { file: __filename, line: line + 4 } }); + await page.content(); // should not pause here + const clickPromise = page.click('div'); // should pause here + await new Promise(resolve => dbg.once('pausedstatechanged', resolve)); + + expect(dbg.pausedDetails()).toEqual([ + expect.objectContaining({ + title: expect.stringContaining('Click'), + }), + ]); + + await dbg.resume(); + await clickPromise; +}); diff --git a/tests/library/inspector/pause.spec.ts b/tests/library/inspector/pause.spec.ts index 3406e90f6a5ee..a7c820343dbb6 100644 --- a/tests/library/inspector/pause.spec.ts +++ b/tests/library/inspector/pause.spec.ts @@ -219,22 +219,21 @@ it.describe('pause', () => { const recorderPage = await recorderPageGetter(); await recorderPage.click('[title="Step over (F10)"]'); - const iframe = page.frames()[1]; - const button = await iframe.waitForSelector('button'); - const box1Promise = button.boundingBox(); + const { box1, box2 } = await (page as any)._wrapApiCall(async () => { + const iframe = page.frames()[1]; + const button = await iframe.waitForSelector('button'); + const box1 = await button.boundingBox(); - const actionPoint = await page.waitForSelector('x-pw-action-point'); - const box2Promise = actionPoint.boundingBox(); - await recorderPage.click('[title="Step over (F10)"]'); + const actionPoint = await page.waitForSelector('x-pw-action-point'); + const box2 = await actionPoint.boundingBox(); - const box1 = await box1Promise; - const box2 = await box2Promise; + const iframeActionPoint = await iframe.$('x-pw-action-point'); + expect(await iframeActionPoint?.isVisible()).toBeFalsy(); - const iframeActionPoint = await iframe.$('x-pw-action-point'); - const iframeActionPointPromise = iframeActionPoint?.boundingBox(); - await recorderPage.click('[title="Resume (F8)"]'); + return { box1, box2 }; + }, { internal: true }); - expect(await iframeActionPointPromise).toBeFalsy(); + await recorderPage.click('[title="Resume (F8)"]'); const x1 = box1!.x + box1!.width / 2; const y1 = box1!.y + box1!.height / 2;