From 6dadc977792712b6299c7a8924b53b3c98b1630b Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Thu, 25 Dec 2025 22:04:19 +0100 Subject: [PATCH] nicer debugger api --- playwriter/src/debugger-examples.ts | 2 +- playwriter/src/debugger.ts | 112 ++++++++++++--------- playwriter/src/mcp.test.ts | 148 +++++++++++++++++++++++++++- 3 files changed, 211 insertions(+), 51 deletions(-) diff --git a/playwriter/src/debugger-examples.ts b/playwriter/src/debugger-examples.ts index 938ef33..40652ec 100644 --- a/playwriter/src/debugger-examples.ts +++ b/playwriter/src/debugger-examples.ts @@ -26,7 +26,7 @@ async function inspectWhenPaused() { console.log('Paused at:', loc.url, 'line', loc.lineNumber) console.log('Source:', loc.sourceContext) - const vars = await dbg.inspectVariables({ scope: 'local' }) + const vars = await dbg.inspectLocalVariables() console.log('Variables:', vars) const result = await dbg.evaluate({ expression: 'myVar.length' }) diff --git a/playwriter/src/debugger.ts b/playwriter/src/debugger.ts index 7926442..dfab9e7 100644 --- a/playwriter/src/debugger.ts +++ b/playwriter/src/debugger.ts @@ -24,9 +24,7 @@ export interface EvaluateResult { value: unknown } -export interface VariablesResult { - [scope: string]: Record -} + export interface ScriptInfo { scriptId: string @@ -45,7 +43,7 @@ export interface ScriptInfo { * await dbg.setBreakpoint({ file: 'https://example.com/app.js', line: 42 }) * // trigger the code path, then: * const location = await dbg.getLocation() - * const vars = await dbg.inspectVariables() + * const vars = await dbg.inspectLocalVariables() * await dbg.resume() * ``` */ @@ -133,14 +131,9 @@ export class Debugger { async setBreakpoint({ file, line }: { file: string; line: number }): Promise { await this.enable() - let fileUrl = file - if (!file.startsWith('file://') && !file.startsWith('http://') && !file.startsWith('https://')) { - fileUrl = `file://${file.startsWith('/') ? '' : '/'}${file}` - } - const response = await this.cdp.send('Debugger.setBreakpointByUrl', { lineNumber: line - 1, - urlRegex: fileUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + urlRegex: file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), columnNumber: 0, }) @@ -181,47 +174,28 @@ export class Debugger { } /** - * Inspects variables in the current scope. - * When paused at a breakpoint with scope='local', returns variables from the call frame. - * Otherwise returns global scope information. + * Inspects local variables in the current call frame. + * Must be paused at a breakpoint. String values over 1000 chars are truncated. + * Use evaluate() for full control over reading specific values. * - * @param options - Options - * @param options.scope - 'local' for call frame variables, 'global' for global scope - * @returns Variables grouped by scope type + * @returns Record of variable names to values + * @throws Error if not paused or no active call frames * * @example * ```ts - * // When paused at a breakpoint: - * const vars = await dbg.inspectVariables({ scope: 'local' }) - * // { local: { myVar: 'hello', count: 42 }, closure: { captured: true } } - * - * // Global scope: - * const globals = await dbg.inspectVariables({ scope: 'global' }) - * // { lexicalNames: [...], globalThis: { ... } } + * const vars = await dbg.inspectLocalVariables() + * // { myVar: 'hello', count: 42 } * ``` */ - async inspectVariables({ scope = 'local' }: { scope?: 'local' | 'global' } = {}): Promise { + async inspectLocalVariables(): Promise> { await this.enable() - if (scope === 'global' || !this.paused) { - const response = await this.cdp.send('Runtime.globalLexicalScopeNames', {}) - const globalObjResponse = await this.cdp.send('Runtime.evaluate', { - expression: 'this', - returnByValue: true, - }) - - return { - lexicalNames: response.names as unknown as Record, - globalThis: globalObjResponse.result.value as Record, - } - } - - if (this.currentCallFrames.length === 0) { - throw new Error('No active call frames') + if (!this.paused || this.currentCallFrames.length === 0) { + throw new Error('Debugger is not paused at a breakpoint') } const frame = this.currentCallFrames[0] - const result: VariablesResult = {} + const result: Record = {} for (const scopeObj of frame.scopeChain) { if (scopeObj.type === 'global') { @@ -239,19 +213,35 @@ export class Debugger { generatePreview: true, }) - const variables: Record = {} for (const prop of objProperties.result) { if (prop.value && prop.configurable) { - variables[prop.name] = this.formatPropertyValue(prop.value) + result[prop.name] = this.formatPropertyValue(prop.value) } } - - result[scopeObj.type] = variables } return result } + /** + * Returns global lexical scope variable names. + * + * @returns Array of global variable names + * + * @example + * ```ts + * const globals = await dbg.inspectGlobalVariables() + * // ['myGlobal', 'CONFIG'] + * ``` + */ + async inspectGlobalVariables(): Promise { + await this.enable() + + const response = await this.cdp.send('Runtime.globalLexicalScopeNames', {}) + + return response.names + } + /** * Evaluates a JavaScript expression in the current context. * When paused at a breakpoint, evaluates in the call frame context (can access local variables). @@ -459,7 +449,7 @@ export class Debugger { * @example * ```ts * if (dbg.isPaused()) { - * const vars = await dbg.inspectVariables() + * const vars = await dbg.inspectLocalVariables() * } * ``` */ @@ -467,6 +457,29 @@ export class Debugger { return this.paused } + /** + * Configures the debugger to pause on exceptions. + * + * @param options - Options + * @param options.state - When to pause: 'none' (never), 'uncaught' (only uncaught), or 'all' (all exceptions) + * + * @example + * ```ts + * // Pause only on uncaught exceptions + * await dbg.setPauseOnExceptions({ state: 'uncaught' }) + * + * // Pause on all exceptions (caught and uncaught) + * await dbg.setPauseOnExceptions({ state: 'all' }) + * + * // Disable pausing on exceptions + * await dbg.setPauseOnExceptions({ state: 'none' }) + * ``` + */ + async setPauseOnExceptions({ state }: { state: 'none' | 'uncaught' | 'all' }): Promise { + await this.enable() + await this.cdp.send('Debugger.setPauseOnExceptions', { state }) + } + /** * Lists available scripts where breakpoints can be set. * Scripts are collected from Debugger.scriptParsed events after enable() is called. @@ -495,6 +508,13 @@ export class Debugger { return filtered.slice(0, 20) } + private truncateValue(value: unknown): unknown { + if (typeof value === 'string' && value.length > 1000) { + return value.slice(0, 1000) + `... (${value.length} chars)` + } + return value + } + private formatPropertyValue(value: Protocol.Runtime.RemoteObject): unknown { if (value.type === 'object' && value.subtype !== 'null') { return `[${value.subtype || value.type}]` @@ -503,7 +523,7 @@ export class Debugger { return '[function]' } if (value.value !== undefined) { - return value.value + return this.truncateValue(value.value) } return `[${value.type}]` } diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index 515fc90..6fb4c26 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -1834,8 +1834,8 @@ describe('CDP Session Tests', () => { expect(location.callstack[0].functionName).toBe('testFunction') expect(location.sourceContext).toContain('debugger') - const vars = await dbg.inspectVariables({ scope: 'local' }) - expect(vars.local).toMatchInlineSnapshot(` + const vars = await dbg.inspectLocalVariables() + expect(vars).toMatchInlineSnapshot(` { "localVar": "hello", "numberVar": 42, @@ -2077,9 +2077,9 @@ describe('CDP Session Tests', () => { sampleFunctionNames: functionNames, }).toMatchInlineSnapshot(` { - "durationMicroseconds": 11251, + "durationMicroseconds": 10698, "hasNodes": true, - "nodeCount": 18, + "nodeCount": 19, "sampleFunctionNames": [ "(root)", "(program)", @@ -2288,6 +2288,146 @@ describe('CDP Session Tests', () => { await page.close() }, 60000) + it('should pause on all exceptions with setPauseOnExceptions', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const page = await browserContext.newPage() + await page.goto('https://example.com/') + await page.bringToFront() + + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise(r => setTimeout(r, 500)) + + const browser = await chromium.connectOverCDP(getCdpUrl()) + const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) + expect(cdpPage).toBeDefined() + + const wsUrl = getCdpUrl() + const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) + const dbg = new Debugger({ cdp: cdpSession }) + + await dbg.enable() + await dbg.setPauseOnExceptions({ state: 'all' }) + + const pausedPromise = new Promise((resolve) => { + cdpSession.on('Debugger.paused', () => resolve()) + }) + + cdpPage!.evaluate(` + (function() { + try { + throw new Error('Caught test error'); + } catch (e) { + // caught but should still pause with state 'all' + } + })() + `).catch(() => {}) + + await Promise.race([ + pausedPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000)) + ]) + + expect(dbg.isPaused()).toBe(true) + + const location = await dbg.getLocation() + expect(location.sourceContext).toContain('throw') + + await dbg.resume() + + await dbg.setPauseOnExceptions({ state: 'none' }) + + cdpSession.close() + await browser.close() + await page.close() + }, 60000) + + it('should inspect local and global variables with inline snapshots', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const page = await browserContext.newPage() + await page.setContent(` + + + + + + + + + `) + await page.bringToFront() + + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise(r => setTimeout(r, 500)) + + const browser = await chromium.connectOverCDP(getCdpUrl()) + let cdpPage + for (const p of browser.contexts()[0].pages()) { + const html = await p.content() + if (html.includes('runTest')) { + cdpPage = p + break + } + } + expect(cdpPage).toBeDefined() + + const wsUrl = getCdpUrl() + const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) + const dbg = new Debugger({ cdp: cdpSession }) + + await dbg.enable() + + const globalVars = await dbg.inspectGlobalVariables() + expect(globalVars).toMatchInlineSnapshot(` + [ + "GLOBAL_CONFIG", + ] + `) + + const pausedPromise = new Promise((resolve) => { + cdpSession.on('Debugger.paused', () => resolve()) + }) + + cdpPage!.evaluate('runTest()') + + await pausedPromise + expect(dbg.isPaused()).toBe(true) + + const localVars = await dbg.inspectLocalVariables() + expect(localVars).toMatchInlineSnapshot(` + { + "GLOBAL_CONFIG": "production", + "scores": "[array]", + "settings": "[object]", + "userAge": 25, + "userName": "Alice", + } + `) + + await dbg.resume() + + cdpSession.close() + await browser.close() + await page.close() + }, 60000) + it('should click at correct coordinates on high-DPI simulation', async () => { const browserContext = getBrowserContext() const serviceWorker = await getExtensionServiceWorker(browserContext)