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('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' })
+66 -46
View File
@@ -24,9 +24,7 @@ export interface EvaluateResult {
value: unknown
}
export interface VariablesResult {
[scope: string]: Record<string, unknown>
}
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<string> {
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<VariablesResult> {
async inspectLocalVariables(): Promise<Record<string, unknown>> {
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<string, unknown>,
globalThis: globalObjResponse.result.value as Record<string, unknown>,
}
}
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<string, unknown> = {}
for (const scopeObj of frame.scopeChain) {
if (scopeObj.type === 'global') {
@@ -239,19 +213,35 @@ export class Debugger {
generatePreview: true,
})
const variables: Record<string, unknown> = {}
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<string[]> {
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<void> {
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}]`
}
+144 -4
View File
@@ -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<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 () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)