Lesson 3
The DOM Domain
What You'll Learn
The DOM domain gives you programmatic access to the same tree you see in the Elements panel. In this lesson you'll:
- Retrieve the document root
- Query for specific nodes using CSS selectors
- Read node attributes and content
- Modify the DOM (set attributes, change content, remove nodes)
- Watch for DOM mutations in real time
A practical application: an AI agent checking whether Angular rendered the expected elements, in the expected state.
The DOM Model in CDP
Before we touch code, understand how CDP represents the DOM. It's different from the browser's in-memory DOM - it's a mirrored subset.
- Each node has a
nodeId- an integer assigned by the CDP server. These are session-scoped (they reset if you disconnect). - CDP doesn't send you the entire DOM tree upfront. It gives you the top-level nodes and you request children on demand. This keeps the wire traffic manageable for large pages.
- Nodes have a
nodeType(1 = element, 3 = text, 9 = document - same as the standard DOMNode.nodeTypevalues you already know).
nodeId vs backendNodeId vs objectId. CDP has three ways to reference a node:
nodeId- assigned by CDP for the session's use. Invalidated when you disconnect, or when CDP decides to reassign them (e.g., after DOM mutations). Very ephemeral - treat as disposable.backendNodeId- tied to the DOM node's lifetime in the browser, independent of your CDP session. If you disconnect and reconnect, the same node still has the samebackendNodeId. But if the node is removed from the DOM (navigation, Angular destroying a component), the ID dies with it. Tied to the node's existence, not your connection.objectId- a Runtime domain reference (a JS object handle). Tied to a specific execution context, destroyed on page navigation (because navigation creates a new context).
*ngIf, *ngFor). When Angular destroys a component, its DOM nodes are removed and any nodeId or backendNodeId referencing them becomes stale. The pattern: query by selector at the moment you need to inspect, not ahead of time.
Setup: Connect and Navigate
We'll reuse the connection pattern from Lesson 2. Create dom-exploration.mjs:
import WebSocket from 'ws';
// Connect to the first page target
const targets = await fetch('http://localhost:9222/json/list').then(response => response.json());
const page = targets.find(target => target.type === 'page');
const ws = new WebSocket(page.webSocketDebuggerUrl);
// Promise-based send (from Lesson 2)
let nextId = 1;
const pending = new Map();
function send(method, params = {}) {
const id = nextId++;
const promise = new Promise((resolve) => {
pending.set(id, resolve);
});
ws.send(JSON.stringify({ id, method, params }));
return promise;
}
// Collect events for later inspection
const events = [];
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (!msg.id || !pending.has(msg.id)) {
// It's an event - store it
if (msg.method) events.push(msg);
return;
}
const resolvePromise = pending.get(msg.id);
resolvePromise(msg.result || msg.error);
pending.delete(msg.id);
});
// Wait for connection, then start exploring
ws.on('open', async () => {
console.log('Connected. Navigating to example.com...\n');
await send('Page.enable');
await send('DOM.enable');
await send('Page.navigate', { url: 'https://example.com' });
// Wait for page load
await new Promise((resolve) => {
const checkForLoad = setInterval(() => {
const loadEvent = events.find(event => event.method === 'Page.loadEventFired');
if (loadEvent) {
clearInterval(checkForLoad);
resolve();
}
}, 100);
});
console.log('Page loaded. Starting DOM exploration...\n');
// --- DOM exploration begins here ---
await exploreDOM();
ws.close();
});
async function exploreDOM() {
// We'll fill this in step by step below
}Step 1: Get the Document Root
Everything starts with DOM.getDocument. This returns the root node of the DOM tree:
async function exploreDOM() {
// Get the document root node
const doc = await send('DOM.getDocument', { depth: 0 });
console.log('Document root nodeId:', doc.root.nodeId);
console.log('Document URL:', doc.root.documentURL);
console.log('Children count:', doc.root.childNodeCount);
}The response looks like:
{
"root": {
"nodeId": 1,
"backendNodeId": 2,
"nodeType": 9, // Document node
"nodeName": "#document",
"childNodeCount": 2, // Usually DOCTYPE + html element
"documentURL": "https://example.com/",
"baseURL": "https://example.com/"
}
}depth parameter. By default, DOM.getDocument returns 2 levels of children. Set depth: -1 to get the entire tree at once (useful for small pages), or depth: 0 to get just the root (then request children as needed). For large pages, requesting the full tree can be slow - request on demand instead.
Step 2: Query Nodes with CSS Selectors
The most common operation: find nodes by CSS selector. This is DOM.querySelector (single result) and DOM.querySelectorAll (all matches):
async function exploreDOM() {
const doc = await send('DOM.getDocument', { depth: -1 });
const rootNodeId = doc.root.nodeId;
// Find a single element - like document.querySelector('h1')
const h1Result = await send('DOM.querySelector', {
nodeId: rootNodeId,
selector: 'h1'
});
console.log('h1 nodeId:', h1Result.nodeId);
// Find all matching elements - like document.querySelectorAll('p')
const paragraphs = await send('DOM.querySelectorAll', {
nodeId: rootNodeId,
selector: 'p'
});
console.log('Paragraph nodeIds:', paragraphs.nodeIds);
console.log('Number of paragraphs:', paragraphs.nodeIds.length);
}DOM.querySelector returns a single nodeId. DOM.querySelectorAll returns an array of nodeIds. If no match is found, querySelector returns nodeId: 0.
Step 3: Read Node Content and Attributes
Once you have a nodeId, you can inspect it:
async function exploreDOM() {
const doc = await send('DOM.getDocument', { depth: -1 });
const rootNodeId = doc.root.nodeId;
// Find the h1
const h1Result = await send('DOM.querySelector', {
nodeId: rootNodeId,
selector: 'h1'
});
// Get its outer HTML (the element itself + its content)
const outerHTML = await send('DOM.getOuterHTML', {
nodeId: h1Result.nodeId
});
console.log('h1 outerHTML:', outerHTML.outerHTML);
// → '<h1>Example Domain</h1>'
// Get attributes of the <div> element
const divResult = await send('DOM.querySelector', {
nodeId: rootNodeId,
selector: 'div'
});
const attrs = await send('DOM.getAttributes', {
nodeId: divResult.nodeId
});
console.log('div attributes:', attrs.attributes);
// → ['class', 'container', 'id', 'main'] (flat array of name, value pairs)
}DOM.getAttributes returns a flat interleaved array: [name1, value1, name2, value2, ...]. Not an object. A quirky CDP design choice - likely an early performance optimisation. To work with it more naturally:
function parseAttributes(flatArray) {
return flatArray.reduce((result, value, index, array) => {
if (index % 2 === 0) {
result[value] = array[index + 1];
}
return result;
}, {});
}Step 4: Modify the DOM
You can change the page in real time - exactly like editing in the Elements panel:
async function exploreDOM() {
const doc = await send('DOM.getDocument', { depth: -1 });
const rootNodeId = doc.root.nodeId;
const h1Result = await send('DOM.querySelector', {
nodeId: rootNodeId,
selector: 'h1'
});
// Change the text content of the h1
await send('DOM.setOuterHTML', {
nodeId: h1Result.nodeId,
outerHTML: '<h1 style="color: red;">Modified by CDP!</h1>'
});
console.log('h1 content changed!');
// Set an attribute on an element
const divResult = await send('DOM.querySelector', {
nodeId: rootNodeId,
selector: 'div'
});
await send('DOM.setAttributeValue', {
nodeId: divResult.nodeId,
name: 'data-tested',
value: 'true'
});
console.log('Attribute set!');
// Remove a node entirely
const paragraphs = await send('DOM.querySelectorAll', {
nodeId: rootNodeId,
selector: 'p'
});
if (paragraphs.nodeIds.length > 0) {
await send('DOM.removeNode', {
nodeId: paragraphs.nodeIds[0]
});
console.log('First paragraph removed!');
}
}Look at the Brave window while running this - you'll see the page change in real time.
Step 5: Watch for DOM Mutations
For debugging, you often want to know when the DOM changes - not just read its current state. CDP sends mutation events automatically for any nodes it has already delivered to your session.
The key insight: when you call DOM.getDocument({ depth: -1 }), CDP sends you the full tree. From that point on, it will push events whenever those nodes change. No extra subscription command needed - DOM.enable plus having requested the nodes is enough.
async function exploreDOM() {
// Get the full document tree - CDP will track mutations for nodes it knows about
const doc = await send('DOM.getDocument', { depth: -1 });
const rootNodeId = doc.root.nodeId;
const bodyResult = await send('DOM.querySelector', {
nodeId: rootNodeId,
selector: 'body'
});
console.log('Watching for DOM mutations...');
console.log('(Modify the page in DevTools or via CDP to see events)\n');
// Trigger a change - add a new element via Runtime.evaluate
await send('Runtime.evaluate', {
expression: `
const newEl = document.createElement('p');
newEl.textContent = 'Dynamically added by CDP!';
newEl.id = 'cdp-added';
document.body.appendChild(newEl);
`
});
// Give events a moment to arrive
await new Promise(resolve => setTimeout(resolve, 500));
// Check what mutation events we received
const domEvents = events.filter(event => event.method.startsWith('DOM.'));
console.log('DOM events received:');
domEvents.forEach(event => {
console.log(` ${event.method}`, JSON.stringify(event.params).slice(0, 100));
});
}Common DOM events you'll see:
DOM.childNodeInserted- a new node was addedDOM.childNodeRemoved- a node was removedDOM.attributeModified- an attribute changedDOM.attributeRemoved- an attribute was removedDOM.characterDataModified- text content changedDOM.childNodeCountUpdated- the number of children changed
Combining DOM + Runtime: Inspecting Angular Components
The DOM domain gives you the tree structure, but for Angular-specific state you'd combine it with Runtime.evaluate:
// Find an Angular component's host element by selector
const appRoot = await send('DOM.querySelector', {
nodeId: rootNodeId,
selector: 'app-root'
});
// Use DOM.resolveNode to get a Runtime object reference for this element
const resolved = await send('DOM.resolveNode', {
nodeId: appRoot.nodeId
});
// Now use Runtime.callFunctionOn to call ng.getComponent() on it
const componentState = await send('Runtime.callFunctionOn', {
objectId: resolved.object.objectId,
functionDeclaration: `function() { return JSON.stringify(ng.getComponent(this)); }`,
returnByValue: true
});
console.log('Angular component state:', componentState.result.value);DOM.resolveNode is the bridge between the DOM domain and the Runtime domain. It takes a nodeId and gives you an objectId - which lets you call JavaScript methods on that element. This is how you cross from "I found this element in the tree" to "let me inspect its Angular component instance."
Key Commands Reference
The DOM domain commands you'll use most often:
| Command | Purpose |
|---|---|
DOM.getDocument |
Get the root node (entry point for everything) |
DOM.querySelector |
Find one node by CSS selector |
DOM.querySelectorAll |
Find all nodes matching a CSS selector |
DOM.getOuterHTML |
Read the HTML of a node |
DOM.getAttributes |
Read all attributes of a node |
DOM.setOuterHTML |
Replace a node's HTML |
DOM.setAttributeValue |
Set or add an attribute |
DOM.removeNode |
Delete a node from the tree |
DOM.resolveNode |
Get a Runtime objectId for a DOM node |
DOM.requestChildNodes |
Fetch children of a node (for lazy-loaded subtrees) |
For the full list, see the DOM section of the complete reference.
Runtime.evaluate, you can also bridge into Angular's component model. This is the foundation for an agent that can verify "did the page render correctly?"
Primary Source
Read the DOM domain documentation. Pay attention to the difference between DOM.querySelector (returns one nodeId) and DOM.querySelectorAll (returns an array). Also browse the events section - particularly childNodeInserted and attributeModified.