Playwright Event Hooks for Map Capture
Playwright gives you five separate places to intervene between opening a page and taking a screenshot, and a map capture needs four of them. Choose the wrong hook and the symptom is never an error: the clock freezes after the first animation frame has already run, the readiness predicate is registered after the map has finished loading and never fires, or the tile interceptor attaches after the first twelve requests have gone to the network. Each of those produces a capture that is subtly wrong and a suite that is intermittently red. This page maps each part of a map capture onto the hook that can actually reach it, and fixes the order they must run in.
This page extends the synchronization layer of Screenshot Capture, Sync & Comparison Logic with the runner-side mechanics. Where Handling Async Tile Loading and WebGL Idle & Render-Completion Detection define what to wait for, this one is about where in the page lifecycle the waiting code has to be installed for it to observe anything at all.
The five hooks, and what each one can still reach
A Playwright hook is defined by the moment it runs relative to the page’s own scripts, and that moment is the whole story. Anything the page has already done before your hook executes is invisible to it and unrecoverable.
browser.launch(args) runs before a page exists. It is the only place that can decide the GL backend, the device scale factor’s default, the font-rendering flags and the sandbox configuration. Nothing later can change these, because the renderer process is already running with them.
context.addInitScript(fn) runs in every new document before any of the page’s own scripts, including inline scripts in <head>. It is the only hook that can replace a global — Date.now, Math.random, requestAnimationFrame — with the page still guaranteed to see the replacement. Registering it on the context rather than the page matters, because a map application that navigates or opens a popup gets fresh documents and each needs the same substitution.
context.route(pattern, handler) intercepts network requests. It must be registered before the navigation that triggers them; a route added after page.goto() resolves catches only whatever the map requests afterwards, which for a tile pyramid is an arbitrary subset.
page.exposeFunction(name, fn) installs a Node-side callback that page code can call. Like addInitScript, it is a per-document binding and must exist before the page code that calls it runs. It is how a map’s own events push readiness out to the test, rather than the test polling in.
page.evaluate / page.waitForFunction run after the document exists. They are the right tools for asking questions and for waiting, and the wrong tools for installing anything the page’s startup path depends on.
Design pattern: push readiness out, do not poll for it
The instinctive way to wait for a map is page.waitForFunction(() => window.__map.loaded()). It works, and it is the weaker of the two available designs.
Polling has three costs. It samples at an interval, so the capture happens up to one poll period after readiness rather than at it — which matters when the thing you are racing is a compositor frame. It cannot observe an event that is true only transiently, and map engines emit exactly those. And it forces the readiness logic to be expressible as a single synchronous expression, which the composite predicate described in WebGL Idle & Render-Completion Detection is not.
The stronger pattern inverts it. The test exposes a callback; an init script subscribes to the map’s own events and calls that callback the instant the composite predicate first holds. The test then awaits a promise that the callback resolves. Readiness is reported at the moment it happens, by the code best placed to know, and the test contains no interval at all.
// test side — the binding must exist before the page's scripts run
let resolveReady;
const ready = new Promise((r) => { resolveReady = r; });
await page.exposeFunction('__mapReady', (detail) => resolveReady(detail));
await context.addInitScript(() => {
// page side — runs before the app constructs the map
window.addEventListener('map:created', (e) => {
const map = e.detail;
let quiet = 0;
const settled = () =>
map.areTilesLoaded() && !map.isMoving() && !map.isRotating();
const onFrame = () => {
if (!settled()) { quiet = 0; return requestAnimationFrame(onFrame); }
if (++quiet < 2) return requestAnimationFrame(onFrame);
window.__mapReady({ zoom: map.getZoom(), center: map.getCenter() });
};
map.on('idle', () => requestAnimationFrame(onFrame));
});
});
await page.goto(url);
const detail = await ready; // resolves at readiness, not after it
The returned detail is worth more than it looks. Because the page reports the camera it was actually at when it declared itself ready, the test can assert that value against the fixture before capturing — catching a camera that drifted during load without a second round trip.
Step-by-step: wiring the four hooks in order
The order below is not stylistic. Each step installs something the next one depends on, and moving any of them later breaks it silently.
1. Launch with the rendering environment pinned. Everything downstream is calibrated against this, and it cannot be changed once the browser is running.
const browser = await chromium.launch({
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-gpu',
'--force-device-scale-factor=1',
'--font-render-hinting=none',
'--disable-lcd-text',
],
});
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 1,
locale: 'en-GB',
timezoneId: 'UTC',
colorScheme: 'light',
reducedMotion: 'reduce',
});
reducedMotion: 'reduce' is doing real work here: it makes the browser report prefers-reduced-motion so any well-behaved CSS in the application disables its own transitions, which removes a whole category of suppression work described in Animation & Transition Suppression.
2. Substitute the clock and the entropy sources. This is addInitScript and nowhere else.
await context.addInitScript(() => {
const EPOCH = 1719792000000; // fixed instant, in the fixture
const RealDate = Date;
// eslint-disable-next-line no-global-assign
Date = class extends RealDate {
constructor(...args) { super(...(args.length ? args : [EPOCH])); }
static now() { return EPOCH; }
};
let seed = 0x2f6e2b1;
Math.random = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
});
The seeded Math.random matters more for maps than for most applications, because several libraries use it for label-collision jitter and for cluster spiderfy angles. An unseeded one is a per-run difference in a place nobody thinks to look.
3. Register the tile routes before navigating. Serving fixtures rather than the live tile service removes both the network variance and the possibility that a basemap update turns every baseline red overnight.
await context.route(/\/tiles\/.*\.(pbf|mvt|png|webp)$/, async (route) => {
const url = new URL(route.request().url());
const file = path.join(FIXTURES, url.pathname.replace(/^\//, ''));
if (!fs.existsSync(file)) return route.fulfill({ status: 404 });
await route.fulfill({
path: file,
headers: { 'cache-control': 'no-store', 'access-control-allow-origin': '*' },
});
});
Returning 404 for a missing fixture rather than falling through to the network is deliberate. A fall-through makes a missing fixture invisible until the day the network is slow; an explicit 404 makes the gap fail loudly the first time.
4. Expose the readiness callback, then navigate. The binding and the init script from step 2 must both be in place before goto, which is why navigation is last.
Cross-browser and cross-runner considerations
The hook names are the same across engines; what they can reach is not.
- Chromium is the only engine where the GL backend flags apply, which is why it is the reference target for any WebGL basemap. It also exposes a CDP session, so
Emulation.setDeviceMetricsOverrideandNetwork.emulateNetworkConditionsare available for the cases that Playwright’s own API does not cover. - WebKit accepts
addInitScriptandrouteidentically but ignores the Chromium GL flags entirely, so its anti-aliasing is its own and needs a separate baseline per the Cross-Browser Baseline Matrix. ItsreducedMotionsupport is reliable, which makes it a good place to notice CSS animations that Chromium’s flags were masking. - Firefox honours the hooks and has no CDP, so anything built on a raw protocol session is Chromium-only by construction. Keep protocol-level work in one adapter rather than scattered through tests, or the suite quietly becomes single-engine.
- Containerised runners must carry the same font set as the machine that blessed the baselines, because
addInitScriptcannot substitute a font. That is an image-level concern, covered in Containerized Rendering Environments for Map Tests.
Hook and timeout reference
| Setting | Recommended value | Why this value |
|---|---|---|
expect.timeout for readiness |
30000 ms | Long enough for a cold fixture read on a loaded runner; short enough that a hung gate fails inside one CI step |
| No-repaint frames before ready | 2 | One frame proves a boundary passed, two prove the engine stopped scheduling |
context.route handler budget |
< 5 ms | A slow handler becomes the thing the capture is waiting for and distorts every timing measurement |
Fixed epoch for Date.now |
any value, stored in the fixture | The value is irrelevant; that it is recorded alongside the baseline is not |
deviceScaleFactor |
1 | DPR-2 doubles the tile grid and changes anti-aliasing; make it a separate, deliberate axis |
reducedMotion |
reduce |
Removes well-behaved CSS animation at the source rather than overriding it later |
Navigation waitUntil |
commit |
The readiness promise is the real signal; waiting for load or networkidle adds latency and proves nothing about the map |
Proving a hook actually ran
Because every one of these failures is silent, the cheapest insurance is making each hook assert its own presence. Three small checks catch essentially all of it, and they cost microseconds.
Assert the substitution took. At the top of the readiness init script, compare Date.now() against the epoch you installed. If they differ, the init script ran after something else replaced the clock, or it did not run at all — either way the test should fail immediately with a message naming the hook rather than three minutes later with a mismatched screenshot.
await context.addInitScript(() => {
window.__hooks = { clock: false, ready: false, routed: 0 };
// ... clock substitution ...
window.__hooks.clock = Date.now() === EPOCH;
});
Count what the router served. Increment a counter in the route handler and read it after the capture. A run that served zero tiles from fixtures either navigated before the route was registered or is matching the wrong URL pattern, and both look identical from the outside — a perfectly good screenshot drawn from live data.
Fail on an unrecognised request. Once fixtures are complete, any request that reaches the network is a gap. Adding a catch-all route that fails the test on an unexpected host turns “we think we serve everything locally” into a checked claim, and it is how fixture sets stay complete as the application grows.
await context.route('**/*', (route) => {
const url = route.request().url();
if (url.startsWith('http://localhost') || url.startsWith('data:')) return route.continue();
throw new Error(`unfixtured request escaped to the network: ${url}`);
});
Together these turn the entire class of ordering bugs from “intermittent visual difference nobody can reproduce” into “assertion failure naming the hook”, which is the difference between an afternoon and a fortnight.
Common pitfalls
Registering hooks on the page instead of the context. page.addInitScript applies to that page only. A map application that opens a print preview in a popup gets a fresh document with the real Date.now, and the popup’s capture drifts while the main page’s does not — a difference that looks like a rendering bug for as long as it takes to notice which surface it happens on.
Awaiting networkidle as a proxy for readiness. It is appealing because it needs no page cooperation, and it is wrong in both directions: prefetch traffic keeps the network busy after the visible tiles are complete, and a lull between two request waves reads as idle while half the pyramid is still to come. The failure mode is documented in detail under Handling Async Tile Loading; the fix is a predicate the page evaluates, not a network heuristic the runner infers.
Letting the route handler do work. Reading a fixture from disk on every tile request, uncached, adds milliseconds per tile and hundreds per capture. Load the fixture set into memory once per worker and serve from there; the handler should be a lookup.
Exposing a function whose name the page does not know. exposeFunction binds a name; if the init script and the test disagree about that name — after a rename, typically — the page throws a ReferenceError inside an event handler, which is swallowed, and the test waits out its timeout with no indication of why. Assert that the binding exists at the top of the init script and fail loudly if it does not.
Using waitForTimeout anywhere. Every fixed delay in a map capture is either too short on a loaded runner or wasted on a fast one, and it hides the missing signal that made it seem necessary. A capture suite with no waitForTimeout in it is a suite whose readiness conditions are all explicit.
Frequently asked questions
Should the readiness logic live in the test or in the application?
In the application’s test build, installed through addInitScript, with the test holding only the promise. The predicate needs the map instance, needs to subscribe to engine events, and needs to run inside the page — all three argue for page scope. Keeping it in one init script also means every test in the suite shares one definition of ready, so a fix to the predicate applies everywhere at once rather than being copied into thirty test files.
Is page.waitForFunction ever the right tool for a map?
Yes, for questions whose answer is a stable state rather than a transient event — asserting that a specific layer exists in the style, or that a feature count matches a fixture. It is the wrong tool for readiness, because readiness is a moment rather than a state, and a poller can miss a moment entirely. Use it to check things, not to wait for things.
Why register routes on the context rather than per page?
Because a map application creates documents you did not plan for — popups, iframes for embedded views, service worker registrations that re-fetch style assets. A context-level route covers all of them with one registration. A page-level route covers exactly the page you attached it to, and the first tile that leaks to the live network from an iframe is very hard to notice.
Does freezing Date.now break the map engine?
Not in the engines this site covers, provided the substitution keeps Date constructible and monotonic-looking. Map libraries use the clock for fade transitions and animation timing, and a frozen clock makes those transitions never advance — which is exactly the intent, but it means any transition in flight when the clock froze stays in flight forever. Freeze before the map is constructed, as in step 2, and no transition is ever mid-flight when it happens.
How do I keep protocol-level code from making the suite Chromium-only?
Put every CDP call behind one adapter with a capability check, and give the adapter a documented no-op path for engines without a protocol session. Tests then call adapter.throttleNetwork(profile) rather than client.send('Network.emulateNetworkConditions', ...), and running the suite on WebKit skips the throttling rather than failing. The alternative — protocol calls inline in tests — makes the engine axis impossible to add later without rewriting the suite.
Related
- Up to Screenshot Capture, Sync & Comparison Logic, the section this synchronization work belongs to.
- Handling Async Tile Loading — the tile-hydration predicate these hooks install.
- WebGL Idle & Render-Completion Detection — the frame-level half of the readiness condition.
- Viewport & Zoom Sync Strategies — the camera fixture the readiness callback reports back.
- Containerized Rendering Environments for Map Tests — pinning the parts of the environment no hook can reach.