Lesson 4
The Runtime Domain
What You'll Learn
The Runtime domain is the Console panel made programmatic. It lets you:
- Evaluate arbitrary JavaScript in the page context
- Work with remote objects (references to objects living in the browser)
- Call functions on specific objects
- Listen for console output (
console.log,console.error, etc.) - Catch unhandled exceptions
- 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:
Runtime.executionContextCreated- when a new JS context appears (page load, iframe, worker)Runtime.consoleAPICalled- everyconsole.log,console.error, etc.Runtime.exceptionThrown- unhandled exceptions
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.
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 anobjectIdreference. 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: '...' } }
// ]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:
type- the console method:log,error,warning,info,debug,table,trace, etc.args- array ofRemoteObjectvalues. For primitives you get the value directly. For objects you get anobjectIdyou can inspect further.stackTrace- tells you exactly where in the code the console call happened. Invaluable for debugging.
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:
- The error message and type
- The exact file, line, and column
- The full call stack
- A reference to the exception object itself (if you want to inspect it further)
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:
- Fetch the source map from the dev server (Angular serves them at
${url}.mapby default duringng serve) - Parse it with a library like
source-map(npm) - 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'
// }.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:
- The main page context (where an Angular app runs)
- iframe contexts (each iframe has its own)
- Web Worker contexts
- Extension content script 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
});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:
Runtime.releaseObject({ objectId })- release a specific referenceRuntime.releaseObjectGroup({ objectGroup })- release all references in a named group
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.