← Back to Index

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:

  1. Retrieve the document root
  2. Query for specific nodes using CSS selectors
  3. Read node attributes and content
  4. Modify the DOM (set attributes, change content, remove nodes)
  5. 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.

Key concept: 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 same backendNodeId. 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).
In practice, for automation and debugging you'll query nodes fresh at the point of assertion rather than storing references. This is especially important in SPAs (like Angular apps) where the page doesn't navigate but the DOM changes radically as components are created and destroyed by the router or structural directives (*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/"
  }
}
The 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)
}
Attribute format. 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:

For an AI debugging agent: These mutation events are how you'd watch Angular render components. After navigating to a route, you could wait for specific DOM mutations to confirm the component tree rendered correctly - or detect when expected elements don't appear within a timeout.

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.

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.