← Back to Index

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:

  1. Set breakpoints by URL and line number
  2. Pause on exceptions (all, or just uncaught)
  3. Step through code (step over, step into, step out)
  4. Inspect the call stack and local variables when paused
  5. Evaluate expressions in a specific call frame's scope
  6. Modify variable values mid-execution
  7. 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 }]
// }
Line numbers are 0-indexed in CDP. Line 1 in your editor = line 0 in CDP. This is a common source of off-by-one errors when translating from source map positions.

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:

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 debugging
For AI integration: When paused at an exception, the agent could call evaluateOnCallFrame 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
});
Limitations: Live editing works for function bodies but has restrictions - you can't add new variables to a closure's scope, change function signatures in ways that affect the stack, or modify code that's currently on the call stack. It's equivalent to the "Edit and save" feature in DevTools Sources. Also, changes are lost on page reload.

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 Settings

Pause 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 this
Why this matters for Angular: Angular's change detection, HTTP calls, and event handlers are all async. Without async stack tracking, you'd only see the immediate call site. With it, you see the full chain: "user clicked button → event handler called → HTTP request made → response handler ran → change detection triggered → component rendered → error here."

Key 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.