nicer debugger api

This commit is contained in:
Tommy D. Rossi
2025-12-25 22:04:19 +01:00
parent eb58952578
commit 6dadc97779
3 changed files with 211 additions and 51 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ async function inspectWhenPaused() {
console.log('Paused at:', loc.url, 'line', loc.lineNumber) console.log('Paused at:', loc.url, 'line', loc.lineNumber)
console.log('Source:', loc.sourceContext) console.log('Source:', loc.sourceContext)
const vars = await dbg.inspectVariables({ scope: 'local' }) const vars = await dbg.inspectLocalVariables()
console.log('Variables:', vars) console.log('Variables:', vars)
const result = await dbg.evaluate({ expression: 'myVar.length' }) const result = await dbg.evaluate({ expression: 'myVar.length' })
+66 -46
View File
@@ -24,9 +24,7 @@ export interface EvaluateResult {
value: unknown value: unknown
} }
export interface VariablesResult {
[scope: string]: Record<string, unknown>
}
export interface ScriptInfo { export interface ScriptInfo {
scriptId: string scriptId: string
@@ -45,7 +43,7 @@ export interface ScriptInfo {
* await dbg.setBreakpoint({ file: 'https://example.com/app.js', line: 42 }) * await dbg.setBreakpoint({ file: 'https://example.com/app.js', line: 42 })
* // trigger the code path, then: * // trigger the code path, then:
* const location = await dbg.getLocation() * const location = await dbg.getLocation()
* const vars = await dbg.inspectVariables() * const vars = await dbg.inspectLocalVariables()
* await dbg.resume() * await dbg.resume()
* ``` * ```
*/ */
@@ -133,14 +131,9 @@ export class Debugger {
async setBreakpoint({ file, line }: { file: string; line: number }): Promise<string> { async setBreakpoint({ file, line }: { file: string; line: number }): Promise<string> {
await this.enable() 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', { const response = await this.cdp.send('Debugger.setBreakpointByUrl', {
lineNumber: line - 1, lineNumber: line - 1,
urlRegex: fileUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), urlRegex: file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
columnNumber: 0, columnNumber: 0,
}) })
@@ -181,47 +174,28 @@ export class Debugger {
} }
/** /**
* Inspects variables in the current scope. * Inspects local variables in the current call frame.
* When paused at a breakpoint with scope='local', returns variables from the call frame. * Must be paused at a breakpoint. String values over 1000 chars are truncated.
* Otherwise returns global scope information. * Use evaluate() for full control over reading specific values.
* *
* @param options - Options * @returns Record of variable names to values
* @param options.scope - 'local' for call frame variables, 'global' for global scope * @throws Error if not paused or no active call frames
* @returns Variables grouped by scope type
* *
* @example * @example
* ```ts * ```ts
* // When paused at a breakpoint: * const vars = await dbg.inspectLocalVariables()
* const vars = await dbg.inspectVariables({ scope: 'local' }) * // { myVar: 'hello', count: 42 }
* // { local: { myVar: 'hello', count: 42 }, closure: { captured: true } }
*
* // Global scope:
* const globals = await dbg.inspectVariables({ scope: 'global' })
* // { lexicalNames: [...], globalThis: { ... } }
* ``` * ```
*/ */
async inspectVariables({ scope = 'local' }: { scope?: 'local' | 'global' } = {}): Promise<VariablesResult> { async inspectLocalVariables(): Promise<Record<string, unknown>> {
await this.enable() await this.enable()
if (scope === 'global' || !this.paused) { if (!this.paused || this.currentCallFrames.length === 0) {
const response = await this.cdp.send('Runtime.globalLexicalScopeNames', {}) throw new Error('Debugger is not paused at a breakpoint')
const globalObjResponse = await this.cdp.send('Runtime.evaluate', {
expression: 'this',
returnByValue: true,
})
return {
lexicalNames: response.names as unknown as Record<string, unknown>,
globalThis: globalObjResponse.result.value as Record<string, unknown>,
}
}
if (this.currentCallFrames.length === 0) {
throw new Error('No active call frames')
} }
const frame = this.currentCallFrames[0] const frame = this.currentCallFrames[0]
const result: VariablesResult = {} const result: Record<string, unknown> = {}
for (const scopeObj of frame.scopeChain) { for (const scopeObj of frame.scopeChain) {
if (scopeObj.type === 'global') { if (scopeObj.type === 'global') {
@@ -239,19 +213,35 @@ export class Debugger {
generatePreview: true, generatePreview: true,
}) })
const variables: Record<string, unknown> = {}
for (const prop of objProperties.result) { for (const prop of objProperties.result) {
if (prop.value && prop.configurable) { 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 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<string[]> {
await this.enable()
const response = await this.cdp.send('Runtime.globalLexicalScopeNames', {})
return response.names
}
/** /**
* Evaluates a JavaScript expression in the current context. * Evaluates a JavaScript expression in the current context.
* When paused at a breakpoint, evaluates in the call frame context (can access local variables). * When paused at a breakpoint, evaluates in the call frame context (can access local variables).
@@ -459,7 +449,7 @@ export class Debugger {
* @example * @example
* ```ts * ```ts
* if (dbg.isPaused()) { * if (dbg.isPaused()) {
* const vars = await dbg.inspectVariables() * const vars = await dbg.inspectLocalVariables()
* } * }
* ``` * ```
*/ */
@@ -467,6 +457,29 @@ export class Debugger {
return this.paused 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<void> {
await this.enable()
await this.cdp.send('Debugger.setPauseOnExceptions', { state })
}
/** /**
* Lists available scripts where breakpoints can be set. * Lists available scripts where breakpoints can be set.
* Scripts are collected from Debugger.scriptParsed events after enable() is called. * Scripts are collected from Debugger.scriptParsed events after enable() is called.
@@ -495,6 +508,13 @@ export class Debugger {
return filtered.slice(0, 20) 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 { private formatPropertyValue(value: Protocol.Runtime.RemoteObject): unknown {
if (value.type === 'object' && value.subtype !== 'null') { if (value.type === 'object' && value.subtype !== 'null') {
return `[${value.subtype || value.type}]` return `[${value.subtype || value.type}]`
@@ -503,7 +523,7 @@ export class Debugger {
return '[function]' return '[function]'
} }
if (value.value !== undefined) { if (value.value !== undefined) {
return value.value return this.truncateValue(value.value)
} }
return `[${value.type}]` return `[${value.type}]`
} }
+144 -4
View File
@@ -1834,8 +1834,8 @@ describe('CDP Session Tests', () => {
expect(location.callstack[0].functionName).toBe('testFunction') expect(location.callstack[0].functionName).toBe('testFunction')
expect(location.sourceContext).toContain('debugger') expect(location.sourceContext).toContain('debugger')
const vars = await dbg.inspectVariables({ scope: 'local' }) const vars = await dbg.inspectLocalVariables()
expect(vars.local).toMatchInlineSnapshot(` expect(vars).toMatchInlineSnapshot(`
{ {
"localVar": "hello", "localVar": "hello",
"numberVar": 42, "numberVar": 42,
@@ -2077,9 +2077,9 @@ describe('CDP Session Tests', () => {
sampleFunctionNames: functionNames, sampleFunctionNames: functionNames,
}).toMatchInlineSnapshot(` }).toMatchInlineSnapshot(`
{ {
"durationMicroseconds": 11251, "durationMicroseconds": 10698,
"hasNodes": true, "hasNodes": true,
"nodeCount": 18, "nodeCount": 19,
"sampleFunctionNames": [ "sampleFunctionNames": [
"(root)", "(root)",
"(program)", "(program)",
@@ -2288,6 +2288,146 @@ describe('CDP Session Tests', () => {
await page.close() await page.close()
}, 60000) }, 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<void>((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<never>((_, 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(`
<html>
<head>
<script>
const GLOBAL_CONFIG = 'production';
function runTest() {
const userName = 'Alice';
const userAge = 25;
const settings = { theme: 'dark', lang: 'en' };
const scores = [10, 20, 30];
debugger;
return userName;
}
</script>
</head>
<body>
<button onclick="runTest()">Run</button>
</body>
</html>
`)
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<void>((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 () => { it('should click at correct coordinates on high-DPI simulation', async () => {
const browserContext = getBrowserContext() const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext) const serviceWorker = await getExtensionServiceWorker(browserContext)