Lesson 9
The Debugger Domain
What You'll Learn
The Debugger domain goes beyond what Puppeteer exposes. It gives you the full power of the Sources panel's debugger - programmatically. You can:
- Set breakpoints by URL and line number
- Pause on exceptions (all, or just uncaught)
- Step through code (step over, step into, step out)
- Inspect the call stack and local variables when paused
- Evaluate expressions in a specific call frame's scope
- Modify variable values mid-execution
- Edit source code live (hot-patching)
This is genuinely new territory compared to Puppeteer - and potentially powerful for an AI debugging agent. Imagine the AI setting a breakpoint at the line where an error occurs, inspecting the local variables, and diagnosing the issue from the actual runtime state.
Enabling the Debugger
await send('Debugger.enable');
// You'll immediately receive Debugger.scriptParsed events for every script the page has loaded:
// { method: 'Debugger.scriptParsed', params: { scriptId: '1', url: 'http://localhost:4200/main.js', ... } }
// { method: 'Debugger.scriptParsed', params: { scriptId: '2', url: 'http://localhost:4200/polyfills.js', ... } }
// ...Each Debugger.scriptParsed event gives you a scriptId, the script's URL, and its sourceMapURL. You'd track these as they arrive so you can look up the ID for any script later:
// Build a lookup table as scriptParsed events arrive
const scriptsByUrl = new Map();
// In your message handler:
if (msg.method === 'Debugger.scriptParsed') {
const { scriptId, url, sourceMapURL } = msg.params;
scriptsByUrl.set(url, { scriptId, sourceMapURL });
}
// Later, when you need a scriptId:
const mainScript = scriptsByUrl.get('http://localhost:4200/main.js');
// mainScript = { scriptId: '7', sourceMapURL: 'main.js.map' }Setting Breakpoints
By URL and line number (most common)
// Set a breakpoint at line 42 of main.js
const bp = await send('Debugger.setBreakpointByUrl', {
lineNumber: 42, // 0-indexed
url: 'http://localhost:4200/main.js',
columnNumber: 0, // Optional - start of line
condition: '' // Optional - conditional breakpoint expression
});
// bp = {
// breakpointId: 'bp-1',
// locations: [{ scriptId: '7', lineNumber: 42, columnNumber: 0 }]
// }Conditional breakpoints
Only pause when a condition is true - exactly like right-clicking a breakpoint in DevTools and adding a condition:
// Only pause when userId is undefined
await send('Debugger.setBreakpointByUrl', {
lineNumber: 42,
url: 'http://localhost:4200/main.js',
condition: 'userId === undefined'
});By script ID and exact location
// If you already have the scriptId (from Debugger.scriptParsed)
await send('Debugger.setBreakpoint', {
location: {
scriptId: '7',
lineNumber: 42,
columnNumber: 0
},
condition: ''
});Setting breakpoints from TypeScript locations
CDP only knows about the compiled JavaScript running in the browser. To set a breakpoint at a TypeScript line, you need to resolve the source map in the opposite direction (TS → JS):
import { SourceMapConsumer } from 'source-map';
// You want to break at src/app/my-component.ts, line 28
const mapResponse = await fetch('http://localhost:4200/main.js.map');
const consumer = await new SourceMapConsumer(await mapResponse.json());
// Map TS location → compiled JS location
const generated = consumer.generatedPositionFor({
source: 'src/app/my-component.ts',
line: 28,
column: 0
});
// Set breakpoint at the JS location
// Note: source-map library uses 1-indexed lines, CDP uses 0-indexed
await send('Debugger.setBreakpointByUrl', {
lineNumber: generated.line - 1,
url: 'http://localhost:4200/main.js',
columnNumber: generated.column
});DevTools does this transparently when you click a line in a .ts file. With raw CDP, you handle the mapping yourself.
Pause on exceptions
// Pause on all exceptions (caught and uncaught)
await send('Debugger.setPauseOnExceptions', {
state: 'all' // 'none', 'uncaught', or 'all'
});
// More useful: pause only on uncaught exceptions
await send('Debugger.setPauseOnExceptions', {
state: 'uncaught'
});Removing breakpoints
await send('Debugger.removeBreakpoint', {
breakpointId: 'bp-1'
});The Paused Event
When execution hits a breakpoint (or pauses for another reason), CDP fires Debugger.paused. This is the richest event in the entire protocol - it gives you the complete execution state:
{
"method": "Debugger.paused",
"params": {
"callFrames": [
{
"callFrameId": "0",
"functionName": "renderComponent",
"location": {
"scriptId": "7",
"lineNumber": 42,
"columnNumber": 15
},
"url": "http://localhost:4200/main.js",
"scopeChain": [
{
"type": "local",
"object": { "objectId": "scope-obj-1" },
"name": "renderComponent"
},
{
"type": "closure",
"object": { "objectId": "scope-obj-2" },
"name": "AppComponent"
},
{
"type": "global",
"object": { "objectId": "scope-obj-3" }
}
],
"this": { "type": "object", "objectId": "this-obj-1" }
},
{
"callFrameId": "1",
"functionName": "detectChanges",
"location": { "scriptId": "3", "lineNumber": 891 },
"url": "http://localhost:4200/vendor.js",
"scopeChain": [ ... ]
}
],
"reason": "breakpoint", // 'breakpoint', 'exception', 'debugCommand', etc.
"hitBreakpoints": ["bp-1"]
}
}Key fields:
callFrames- the full call stack. Frame 0 is where execution paused; frames 1+ are the callers.scopeChain- each frame has a scope chain (local, closure, global). Each scope has anobjectIdyou can inspect withRuntime.getProperties.reason- why execution paused:breakpoint,exception,debugCommand(fromDebugger.pause),OOM, etc.this- the value ofthisat that point in execution.
Inspecting Variables When Paused
With execution paused, you can inspect any scope's variables using the objectId from the scope chain:
// Get local variables from the current frame
const localScope = pausedEvent.params.callFrames[0].scopeChain
.find(scope => scope.type === 'local');
const localVars = await send('Runtime.getProperties', {
objectId: localScope.object.objectId,
ownProperties: true
});
// localVars.result = [
// { name: 'userId', value: { type: 'undefined' } }, ← the bug!
// { name: 'componentRef', value: { type: 'object', ... } },
// { name: 'template', value: { type: 'string', value: '...' } }
// ]Evaluating in a Call Frame's Scope
Debugger.evaluateOnCallFrame lets you run an expression with access to that frame's local variables - like typing in the Console while paused at a breakpoint:
// Evaluate an expression in the context of the paused frame
const result = await send('Debugger.evaluateOnCallFrame', {
callFrameId: '0', // The frame we want to evaluate in
expression: 'userId',
returnByValue: true
});
// result = { result: { type: 'undefined' } }
// Or something more complex
const diagnostics = await send('Debugger.evaluateOnCallFrame', {
callFrameId: '0',
expression: 'JSON.stringify({ userId, componentRef: !!componentRef, thisType: typeof this })',
returnByValue: true
});
// Get a snapshot of the local state for debuggingevaluateOnCallFrame to inspect the variables that caused the error - without needing you to manually check the Sources panel. It sees the exact state that led to the crash.
Stepping Through Code
Once paused, you can control execution step by step:
// Step over - execute the current line, pause at the next line
await send('Debugger.stepOver');
// Step into - if the current line has a function call, enter that function
await send('Debugger.stepInto');
// Step out - run until the current function returns, pause at the caller
await send('Debugger.stepOut');
// Resume - continue execution normally until the next breakpoint or exception
await send('Debugger.resume');
// Continue to a specific location (skip intermediate breakpoints)
await send('Debugger.continueToLocation', {
location: {
scriptId: '7',
lineNumber: 55
}
});After each step command, a new Debugger.paused event fires with the updated call stack and scope - letting you inspect the new state.
When you resume (or the page finishes), Debugger.resumed fires.
Reading Script Source
You can retrieve the actual source code of any loaded script:
const source = await send('Debugger.getScriptSource', {
scriptId: '7'
});
// source.scriptSource = 'the entire contents of main.js...'Combined with source maps, the AI could read the exact TypeScript source where the error occurred.
Live Editing (Hot Patching)
Debugger.setScriptSource lets you modify a script's source while the page is running - changes take effect immediately for future calls to those functions:
// Get the current source
const source = await send('Debugger.getScriptSource', { scriptId: '7' });
// Modify it
const modifiedSource = source.scriptSource.replace(
'return this.userData.name;',
'return this.userData?.name ?? "Unknown";'
);
// Apply the change - takes effect immediately
await send('Debugger.setScriptSource', {
scriptId: '7',
scriptSource: modifiedSource
});Blackboxing (Skipping Framework Code)
When stepping through Angular code, you don't want to step through zone.js, RxJS internals, or Angular's change detection. Blackboxing tells the debugger to skip specific scripts:
// Skip framework files when stepping
await send('Debugger.setBlackboxPatterns', {
patterns: [
'node_modules/zone\\.js/.*',
'node_modules/@angular/.*',
'node_modules/rxjs/.*',
'polyfills\\.js'
]
});
// Now stepInto/stepOver will skip over these files entirely
// - exactly like blackboxing in DevTools SettingsPause on Next Statement
You can pause execution at the very next JavaScript statement - useful when you want to examine what happens next after a user interaction:
// Pause at the very next statement executed
await send('Debugger.pause');
// Then trigger something (a click, a timer, etc.)
// Execution will pause at the first statement that runs
// You could also set a function-level breakpoint (experimental)
await send('Debugger.setBreakpointOnFunctionCall', {
objectId: functionObjectId // A Runtime objectId pointing to a function
});Async Call Stacks
Modern JavaScript is heavily asynchronous. By default, when you pause inside a then() or async/await, the call stack only shows the current microtask - not what scheduled it. Enable async stack tracking to see the full chain:
// Track up to 32 frames of async call stack
await send('Debugger.setAsyncCallStackDepth', {
maxDepth: 32
});
// Now when paused inside an async function, callFrames will include
// the full chain: who awaited what, which setTimeout/Promise/Observable scheduled thisKey Commands Reference
| Command | Purpose |
|---|---|
Debugger.enable |
Enable debugger and receive scriptParsed events |
Debugger.setBreakpointByUrl |
Set a breakpoint by file URL and line number |
Debugger.setPauseOnExceptions |
Pause on exceptions (all, uncaught, or none) |
Debugger.resume |
Resume execution |
Debugger.stepOver |
Execute current line, pause at next |
Debugger.stepInto |
Enter a function call |
Debugger.stepOut |
Run to end of current function |
Debugger.evaluateOnCallFrame |
Evaluate expression in a paused frame's scope |
Debugger.getScriptSource |
Get a script's source code |
Debugger.setScriptSource |
Hot-patch a script's source (live editing) |
Debugger.setBlackboxPatterns |
Skip framework code when stepping |
Debugger.setAsyncCallStackDepth |
Track async call chains (Promises, timers, etc.) |
For the full list, see the Debugger section of the complete reference.
Primary Source
Read the Debugger domain documentation. Pay attention to the CallFrame type definition - it's the key data structure for everything you can do when paused.