← Back to Index

Lesson 8

The Page Domain

What You'll Learn

The Page domain covers the page's lifecycle - navigation, loading, frames, screenshots, and more. For a CDP adapter, the critical skill is knowing when a page (or SPA route change) has finished loading and is ready to inspect.

  1. Navigation and the page lifecycle events
  2. Waiting for load reliably (traditional navigation vs SPA)
  3. Frame management (iframes)
  4. Screenshots and PDF generation
  5. Injecting scripts that run on every page load
  6. Handling JavaScript dialogs (alert, confirm, prompt)

The Navigation Lifecycle

When you call Page.navigate, a cascade of events follows. Understanding this sequence is essential for knowing when it's safe to inspect the page:

// Enable lifecycle tracking
await send('Page.enable');
await send('Page.setLifecycleEventsEnabled', { enabled: true });

// Navigate
await send('Page.navigate', { url: 'https://example.com' });

The events fire in this order:

  1. Page.frameStartedNavigating - navigation begins (experimental but useful)
  2. Page.frameNavigated - the frame has navigated to the new URL (headers received, document committed)
  3. Page.domContentEventFired - DOM is fully parsed (equivalent to DOMContentLoaded event)
  4. Page.lifecycleEvent with name: 'DOMContentLoaded' - same moment, different event format
  5. Page.loadEventFired - all resources loaded (equivalent to window.onload)
  6. Page.lifecycleEvent with name: 'load' - same moment
  7. Page.lifecycleEvent with name: 'networkIdle' - no network activity for 500ms
Which event should a CDP adapter wait for?
  • domContentEventFired - safe to query the DOM, but images/fonts may still be loading
  • loadEventFired - everything loaded, safe for screenshots
  • networkIdle - all network settled, best for SPAs that make API calls after load
For Angular apps, networkIdle is usually the right choice - the initial HTML loads fast, but the app then makes API calls to fetch data before rendering meaningful content.

Waiting for Navigation - Traditional Pages

For traditional full-page navigations, wait for Page.loadEventFired:

function waitForLoad() {
  return new Promise((resolve) => {
    const handler = (data) => {
      const msg = JSON.parse(data.toString());
      if (msg.method === 'Page.loadEventFired') {
        ws.removeListener('message', handler);
        resolve();
      }
    };
    ws.on('message', handler);
  });
}

await send('Page.navigate', { url: 'https://example.com' });
await waitForLoad();
// Page is now ready to inspect

Waiting for SPA Route Changes

Angular's router doesn't trigger traditional navigation events - the URL changes via the History API, and the page's execution context stays alive. There's no loadEventFired for a route change.

CDP fires Page.navigatedWithinDocument for same-document navigations (History API pushState/replaceState):

// Fires when Angular's router changes the URL
{
  "method": "Page.navigatedWithinDocument",
  "params": {
    "frameId": "ABC123",
    "url": "http://localhost:4200/dashboard"
  }
}

But this only tells you the URL changed - not that the new view has rendered. For SPAs, you typically need to wait for one of:

// Wait for a selector to appear - the most reliable approach for SPAs
async function waitForSelector(selector, timeout = 5000) {
  const startTime = Date.now();

  while (Date.now() - startTime < timeout) {
    const doc = await send('DOM.getDocument', { depth: 0 });
    const result = await send('DOM.querySelector', {
      nodeId: doc.root.nodeId,
      selector
    });

    if (result.nodeId !== 0) return result.nodeId;
    await new Promise(resolve => setTimeout(resolve, 100));
  }

  throw new Error(`Selector "${selector}" not found within ${timeout}ms`);
}

// Navigate via the app's router (click a link, or evaluate)
await send('Runtime.evaluate', {
  expression: `window.history.pushState({}, '', '/dashboard')`
});

// Wait for the dashboard component to render
await waitForSelector('[data-testid="dashboard-header"]');
Angular-specific: You could also wait by evaluating ng.getComponent() on an expected element, or by checking Angular's Zone.js stability (NgZone.isStable). But selector-based waiting is framework-agnostic and works regardless of how the component renders.

Frames (iframes)

Pages can contain iframes - each is a separate frame with its own document and execution context. The Page domain tracks the frame tree:

// Get the complete frame tree
const frameTree = await send('Page.getFrameTree');
// frameTree.frameTree = {
//   frame: { id: 'main', url: 'http://localhost:4200', ... },
//   childFrames: [
//     { frame: { id: 'iframe-1', url: 'https://payment-provider.com/checkout', ... } }
//   ]
// }

Frame lifecycle events:

To interact with an iframe's content, you need its execution context (from Runtime.executionContextCreated) and target it with contextId in Runtime.evaluate, or attach to it as a separate target via the Target domain.

Screenshots

Page.captureScreenshot captures the current viewport (or a specific region):

// Full viewport screenshot
const screenshot = await send('Page.captureScreenshot', {
  format: 'png'    // 'png', 'jpeg', or 'webp'
});
// screenshot.data = base64-encoded image

// Specific region (clip)
const regionShot = await send('Page.captureScreenshot', {
  format: 'png',
  clip: {
    x: 0,
    y: 0,
    width: 800,
    height: 600,
    scale: 1
  }
});

// Full page (beyond viewport - captures the entire scrollable content)
// First, get the full page dimensions
const metrics = await send('Page.getLayoutMetrics');
const fullPageShot = await send('Page.captureScreenshot', {
  format: 'png',
  clip: {
    x: 0,
    y: 0,
    width: metrics.cssContentSize.width,
    height: metrics.cssContentSize.height,
    scale: 1
  }
});

PDF Generation

Page.printToPDF renders the page as a PDF (headless mode only):

const pdf = await send('Page.printToPDF', {
  landscape: false,
  printBackground: true,
  preferCSSPageSize: true
});
// pdf.data = base64-encoded PDF

Injecting Scripts on Every Page Load

Page.addScriptToEvaluateOnNewDocument registers a script that runs in every frame before any other scripts. This is how you'd inject test helpers or override APIs:

// Inject a script that runs before the page's own JavaScript
await send('Page.addScriptToEvaluateOnNewDocument', {
  source: `
    // Override Date.now() to return a fixed time (useful for snapshot testing)
    Date.now = () => 1719012345678;

    // Or inject a test helper
    window.__CDP_READY__ = false;
    window.addEventListener('load', () => { window.__CDP_READY__ = true; });
  `
});
Use cases for a CDP adapter:
  • Inject a "ready" flag that signals when Angular has finished bootstrapping
  • Mock Date.now() for deterministic test output
  • Override Math.random() for reproducible behaviour
  • Disable animations (* { animation-duration: 0s !important; } via style injection)

Handling JavaScript Dialogs

When the page calls alert(), confirm(), or prompt(), CDP fires Page.javascriptDialogOpening. You must handle it or the page hangs:

// Listen for dialog events
// Event: Page.javascriptDialogOpening
{
  "method": "Page.javascriptDialogOpening",
  "params": {
    "url": "http://localhost:4200",
    "message": "Are you sure you want to delete?",
    "type": "confirm",     // 'alert', 'confirm', 'prompt', 'beforeunload'
    "defaultPrompt": ""    // For prompt() - the default value
  }
}

// Accept or dismiss the dialog
await send('Page.handleJavaScriptDialog', {
  accept: true,           // true = OK/Yes, false = Cancel/No
  promptText: ''          // For prompt() - the text to enter
});
Auto-handling in a CDP adapter: You'd likely want to automatically dismiss all dialogs by default (most journey steps don't expect them). Set up a listener that calls handleJavaScriptDialog immediately when javascriptDialogOpening fires. Allow journeys to override this for steps that specifically test dialog behaviour.

Full dialog handling example

Here's how you'd wire up automatic dialog handling in the WebSocket message listener. The key pattern: listen for the dialog event, then immediately respond to it:

// Store a configurable dialog handler - default behaviour is to accept all dialogs
let dialogHandler = (dialogParams) => ({
  accept: true,
  promptText: ''
});

// In your message listener, watch for dialog events and respond immediately
ws.on('message', (data) => {
  const msg = JSON.parse(data.toString());

  // Handle pending promise responses (from earlier lessons)
  if (msg.id && pending.has(msg.id)) {
    const resolvePromise = pending.get(msg.id);
    resolvePromise(msg.result || msg.error);
    pending.delete(msg.id);
    return;
  }

  // Handle dialog events - must respond or the page hangs
  if (msg.method === 'Page.javascriptDialogOpening') {
    const { message, type, defaultPrompt } = msg.params;
    console.log(`Dialog opened: [${type}] "${message}"`);

    // Ask the configurable handler what to do
    const response = dialogHandler(msg.params);

    // Respond to the dialog - this unblocks the page
    send('Page.handleJavaScriptDialog', {
      accept: response.accept,
      promptText: response.promptText || ''
    });
    return;
  }

  // Pass other events to the diagnostic collector, etc.
  if (msg.method) {
    collector.handleEvent(msg.method, msg.params);
  }
});

To override for a specific journey step (e.g., testing that a delete confirmation works):

// Temporarily override the dialog handler for one step
async function clickDeleteAndConfirm(selector) {
  // Set up: expect a confirm dialog and accept it
  const dialogPromise = new Promise((resolve) => {
    dialogHandler = (params) => {
      resolve(params);  // Capture the dialog details for assertion
      return { accept: true };
    };
  });

  // Trigger the action that opens the dialog
  await send('Runtime.evaluate', {
    expression: `document.querySelector('${selector}').click()`
  });

  // Wait for the dialog to appear and be handled
  const dialogDetails = await dialogPromise;
  console.log(`Confirmed: "${dialogDetails.message}"`);

  // Restore default handler
  dialogHandler = () => ({ accept: true, promptText: '' });
}

// For a prompt dialog where you need to enter text
async function handlePromptDialog(selector, textToEnter) {
  dialogHandler = (params) => {
    return { accept: true, promptText: textToEnter };
  };

  await send('Runtime.evaluate', {
    expression: `document.querySelector('${selector}').click()`
  });

  // Restore default
  dialogHandler = () => ({ accept: true, promptText: '' });
}
Why you must respond immediately: When a dialog is open (alert, confirm, prompt), the page's JavaScript execution is completely blocked - nothing else runs until the dialog is dismissed. If a CDP adapter doesn't call handleJavaScriptDialog, the page will hang and subsequent CDP commands targeting that page will queue indefinitely.

Reloading

// Simple reload
await send('Page.reload');

// Reload ignoring cache (like Ctrl+Shift+R)
await send('Page.reload', {
  ignoreCache: true
});

// Stop a loading page
await send('Page.stopLoading');

Key Commands Reference

Command Purpose
Page.navigate Navigate to a URL
Page.reload Reload the page (optionally ignoring cache)
Page.setLifecycleEventsEnabled Enable lifecycle events (DOMContentLoaded, load, networkIdle)
Page.getFrameTree Get the iframe hierarchy
Page.captureScreenshot Capture viewport or full page as image
Page.printToPDF Render page as PDF (headless only)
Page.addScriptToEvaluateOnNewDocument Inject script that runs before page JS on every load
Page.handleJavaScriptDialog Accept or dismiss alert/confirm/prompt dialogs
Page.getLayoutMetrics Get page dimensions (for full-page screenshots)
Page.setDocumentContent Replace the page's HTML entirely

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

Primary Source

Read the Page domain documentation. Focus on the lifecycle events and the navigate command's return values (frameId, loaderId, errorText).