← Back to Index

Lesson 4

The Runtime Domain

What You'll Learn

The Runtime domain is the Console panel made programmatic. It lets you:

  1. Evaluate arbitrary JavaScript in the page context
  2. Work with remote objects (references to objects living in the browser)
  3. Call functions on specific objects
  4. Listen for console output (console.log, console.error, etc.)
  5. Catch unhandled exceptions
  6. Understand execution contexts (important for iframes and workers)

For an AI debugging agent, this domain is critical - it's how the agent reads console errors, evaluates assertions, and inspects application state.

Enabling the Runtime Domain

As with most domains, you need to enable it to receive events:

await send('Runtime.enable');

Once enabled, you'll start receiving:

Evaluating Expressions

Simple values with returnByValue

For primitive values (strings, numbers, booleans) or serialisable objects, use returnByValue: true to get the actual data back directly:

const titleResult = await send('Runtime.evaluate', {
  expression: 'document.title',
  returnByValue: true
});
// titleResult = { result: { type: 'string', value: 'Example Domain' } }

const countResult = await send('Runtime.evaluate', {
  expression: 'document.querySelectorAll("p").length',
  returnByValue: true
});
// countResult = { result: { type: 'number', value: 2 } }

Complex objects: remote references

For non-serialisable objects (DOM nodes, class instances, functions), CDP returns a remote object reference - an objectId that points to the object living in the browser's memory:

const bodyResult = await send('Runtime.evaluate', {
  expression: 'document.body'
});
// bodyResult = {
//   result: {
//     type: 'object',
//     subtype: 'node',
//     className: 'HTMLBodyElement',
//     objectId: '{"injectedScriptId":1,"id":5}'  ← reference, not the actual body
//   }
// }

The objectId is an opaque handle. You can't read it directly - you use it with other commands to inspect or interact with the object.

The key decision: returnByValue or not?
  • returnByValue: true - CDP serialises the result and sends it over the wire. Works for primitives and plain objects. Fails (or loses data) for DOM nodes, circular references, and class instances.
  • Without returnByValue - you get an objectId reference. The object stays in the browser; you inspect it with further commands. This is how DevTools shows expandable objects in the Console.

Inspecting Remote Objects

Once you have an objectId, you can inspect its properties with Runtime.getProperties:

// First, get a reference to an object
const objResult = await send('Runtime.evaluate', {
  expression: '({ name: "test", count: 42, nested: { x: 1 } })'
});

const objectId = objResult.result.objectId;

// Now inspect its properties
const props = await send('Runtime.getProperties', {
  objectId: objectId,
  ownProperties: true  // Only own properties, not inherited from prototype
});

// props.result is an array of PropertyDescriptor objects:
// [
//   { name: 'name', value: { type: 'string', value: 'test' } },
//   { name: 'count', value: { type: 'number', value: 42 } },
//   { name: 'nested', value: { type: 'object', objectId: '...' } }
// ]
Depth control. Notice that nested comes back as another objectId, not its contents. CDP doesn't recursively expand objects - you inspect one level at a time. This is the same behaviour as expanding an object in the DevTools Console: you click the arrow to see one level deeper. In practice, if you need the full object, using returnByValue: true with JSON.stringify() in the expression is often simpler.

Calling Functions on Objects

Runtime.callFunctionOn lets you call a function on a specific object - this inside the function refers to that object:

// Get a reference to an element
const elResult = await send('Runtime.evaluate', {
  expression: 'document.querySelector("h1")'
});

// Call a function on it
const textResult = await send('Runtime.callFunctionOn', {
  objectId: elResult.result.objectId,
  functionDeclaration: 'function() { return this.textContent; }',
  returnByValue: true
});
// textResult = { result: { type: 'string', value: 'Example Domain' } }

This is more powerful than Runtime.evaluate when you already have a reference to a specific object and want to call methods on it. It's also how you'd inspect Angular component state:

// Assuming you have an objectId for an Angular component's host element
const componentState = await send('Runtime.callFunctionOn', {
  objectId: hostElementObjectId,
  functionDeclaration: `function() {
    const component = ng.getComponent(this);
    return JSON.stringify(component, null, 2);
  }`,
  returnByValue: true
});

Listening for Console Output

With Runtime.enable active, every console.* call in the page emits a Runtime.consoleAPICalled event:

// The event structure:
{
  "method": "Runtime.consoleAPICalled",
  "params": {
    "type": "log",           // 'log', 'error', 'warning', 'info', 'debug', etc.
    "args": [                // Array of RemoteObject - the arguments passed to console
      {
        "type": "string",
        "value": "User clicked submit"
      }
    ],
    "executionContextId": 1,
    "timestamp": 1719012345.678,
    "stackTrace": {          // Where console was called from
      "callFrames": [
        {
          "functionName": "handleSubmit",
          "scriptId": "42",
          "url": "http://localhost:4200/main.js",
          "lineNumber": 156,
          "columnNumber": 8
        }
      ]
    }
  }
}

Key fields:

For a debugging agent: Filtering consoleAPICalled events where type === 'error' gives you every console.error() in the application - with full stack traces. This alone catches a huge class of bugs without any DOM inspection needed.

Catching Unhandled Exceptions

Even better than console errors: Runtime.exceptionThrown fires for any unhandled exception (thrown errors that don't get caught):

// The event structure:
{
  "method": "Runtime.exceptionThrown",
  "params": {
    "timestamp": 1719012345.678,
    "exceptionDetails": {
      "exceptionId": 1,
      "text": "Uncaught TypeError: Cannot read properties of null",
      "lineNumber": 42,
      "columnNumber": 15,
      "scriptId": "7",
      "url": "http://localhost:4200/main.js",
      "stackTrace": {
        "callFrames": [
          {
            "functionName": "renderComponent",
            "scriptId": "7",
            "url": "http://localhost:4200/main.js",
            "lineNumber": 42,
            "columnNumber": 15
          }
        ]
      },
      "exception": {
        "type": "object",
        "subtype": "error",
        "className": "TypeError",
        "description": "TypeError: Cannot read properties of null (reading 'nativeElement')"
      }
    }
  }
}

This gives you:

For Angular apps, you'll commonly see TypeErrors from template bindings hitting null/undefined values, HttpErrorResponse from failed API calls, and ExpressionChangedAfterItHasBeenCheckedError in dev mode.

Source Maps: Getting TypeScript Locations

The exception events give you compiled JavaScript locations (main.js, line 42). But you write TypeScript. DevTools resolves this transparently using source maps - CDP does not. You need to do it yourself:

  1. Fetch the source map from the dev server (Angular serves them at ${url}.map by default during ng serve)
  2. Parse it with a library like source-map (npm)
  3. Map the JS line/column to the original TS file and line
import { SourceMapConsumer } from 'source-map';

// Fetch the source map served alongside the compiled JS
const mapResponse = await fetch('http://localhost:4200/main.js.map');
const sourceMap = await mapResponse.json();
const consumer = await new SourceMapConsumer(sourceMap);

// Translate compiled JS coordinates → original TypeScript coordinates
const original = consumer.originalPositionFor({
  line: 42,
  column: 15
});

// original = {
//   source: 'src/app/my-component.ts',
//   line: 28,
//   column: 4,
//   name: 'nativeElement'
// }
For a debugging agent: After catching an exception, the agent could resolve the source map, identify the exact .ts file and line, then read that file directly from disk to understand the context around the error - all without human involvement. This is the same trick DevTools performs invisibly when it shows you TypeScript locations in the Console.

Execution Contexts

A page can have multiple JavaScript execution contexts:

When Runtime.enable is active, you receive Runtime.executionContextCreated for each context:

{
  "method": "Runtime.executionContextCreated",
  "params": {
    "context": {
      "id": 1,
      "origin": "https://example.com",
      "name": "",
      "uniqueId": "context-unique-id"
    }
  }
}

By default, Runtime.evaluate runs in the main page context. To evaluate in a specific context (e.g., an iframe), pass contextId:

// Evaluate in a specific execution context (e.g., an iframe)
const result = await send('Runtime.evaluate', {
  expression: 'document.title',
  contextId: 2,  // The iframe's execution context ID
  returnByValue: true
});
SPA relevance: In a typical Angular app, you'll mostly work with context ID 1 (the main page). But if the application uses iframes (e.g., embedded content, payment forms, third-party widgets), each one gets its own context. Runtime.executionContextDestroyed fires when a context goes away - useful for knowing when an iframe was removed.

Awaiting Promises

If your expression returns a Promise, you can ask CDP to wait for it to resolve using awaitPromise: true:

const result = await send('Runtime.evaluate', {
  expression: 'fetch("https://jsonplaceholder.typicode.com/todos/1").then(r => r.json())',
  awaitPromise: true,
  returnByValue: true
});
// result = { result: { type: 'object', value: { userId: 1, id: 1, title: '...', completed: false } } }

Without awaitPromise, you'd get back a Promise object reference rather than the resolved value. This is essential for testing async operations - API calls, Angular's HttpClient, timer-based logic, etc.

Object Groups and Cleanup

Every remote object reference consumes memory in the browser. CDP provides two cleanup mechanisms:

You can assign objects to groups when evaluating:

// Assign to a named group
const result = await send('Runtime.evaluate', {
  expression: 'document.body',
  objectGroup: 'my-inspection'
});

// ... do your inspection work ...

// Release everything in the group at once
await send('Runtime.releaseObjectGroup', {
  objectGroup: 'my-inspection'
});

For short-lived scripts this doesn't matter much (disconnecting releases everything). But for a long-running agent that inspects many pages over time, proper cleanup prevents memory leaks in the browser.

Key Commands Reference

Command Purpose
Runtime.enable Start receiving console and exception events
Runtime.evaluate Run an expression in the page context
Runtime.callFunctionOn Call a function on a specific object (by objectId)
Runtime.getProperties Inspect properties of a remote object
Runtime.awaitPromise Wait for a promise to resolve and get its value
Runtime.releaseObject Release a remote object reference (free memory)
Runtime.releaseObjectGroup Release all objects in a named group
Runtime.discardConsoleEntries Clear accumulated console messages

For the full list, see the Runtime section of the complete reference.

Primary Source

Read the Runtime domain documentation. Focus on the RemoteObject type definition - it's the return type of almost every Runtime command, and understanding its shape (type, subtype, value, objectId, description) is essential.