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.
- Navigation and the page lifecycle events
- Waiting for load reliably (traditional navigation vs SPA)
- Frame management (iframes)
- Screenshots and PDF generation
- Injecting scripts that run on every page load
- 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:
Page.frameStartedNavigating- navigation begins (experimental but useful)Page.frameNavigated- the frame has navigated to the new URL (headers received, document committed)Page.domContentEventFired- DOM is fully parsed (equivalent toDOMContentLoadedevent)Page.lifecycleEventwithname: 'DOMContentLoaded'- same moment, different event formatPage.loadEventFired- all resources loaded (equivalent towindow.onload)Page.lifecycleEventwithname: 'load'- same momentPage.lifecycleEventwithname: 'networkIdle'- no network activity for 500ms
domContentEventFired- safe to query the DOM, but images/fonts may still be loadingloadEventFired- everything loaded, safe for screenshotsnetworkIdle- all network settled, best for SPAs that make API calls after load
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 inspectWaiting 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:
- A specific element to appear in the DOM (
DOM.querySelectorpolling) - Network to go quiet (no pending XHR/Fetch requests)
- A custom "ready" signal from the app
// 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"]');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:
Page.frameAttached- a new iframe was added to the DOMPage.frameNavigated- an iframe navigated to a new URLPage.frameDetached- an iframe was removed from the DOM
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 PDFInjecting 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; });
`
});- 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
});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: '' });
}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).