Exposing Map Readiness with Playwright exposeFunction
Polling a map for readiness works and costs you the thing you were trying to buy. waitForFunction samples on an interval, so the capture happens somewhere inside that interval rather than at the moment readiness occurred, and the predicate has to collapse into one synchronous expression — which the composite condition a WebGL map actually needs is not. exposeFunction inverts the arrangement: the page tells the test the instant it is ready, and carries evidence back with it. This procedure wires that up, including the parts that make it fail loudly when it does not work.
This is a task within Playwright Event Hooks for Map Capture, under Screenshot Capture, Sync & Comparison Logic. The predicate it reports on is the one built in WebGL Idle & Render-Completion Detection.
Prerequisites
Step-by-step procedure
1. Bind the callback before navigating
exposeFunction installs a binding in every document the page creates, but only from the moment it is called. Register it before goto, or the page’s own script calls an undefined function inside an event handler, the ReferenceError is swallowed, and the test waits out its timeout with no clue why.
const ready = new Promise((resolve, reject) => {
page.exposeFunction('__mapReady', (detail) => resolve(detail));
page.exposeFunction('__mapFailed', (message) => reject(new Error(message)));
});
Binding a failure channel as well as a success one is what turns “the gate timed out” into “the style is missing the roads layer”. It costs one extra line and removes most of the debugging.
2. Report from an init script, not from the test
The predicate needs the map instance and the engine’s events, both of which live in page scope. Put it in a context-level init script so it is installed before the application constructs the map.
await context.addInitScript(() => {
const REQUIRED = ['background', 'water', 'roads', 'place-labels'];
const QUIET_FRAMES = 2;
window.addEventListener('map:created', (event) => {
const map = event.detail;
let quiet = 0;
let settled = false;
const holds = () =>
map.areTilesLoaded() &&
!map.isMoving() && !map.isRotating() &&
REQUIRED.every((id) => map.getLayer(id));
map.on('render', () => { quiet = 0; });
const tick = () => {
if (settled) return;
if (!holds()) { quiet = 0; return requestAnimationFrame(tick); }
if (++quiet < QUIET_FRAMES) return requestAnimationFrame(tick);
settled = true;
const c = map.getCenter();
window.__mapReady({
zoom: map.getZoom(),
center: [c.lng, c.lat],
bearing: map.getBearing(),
pitch: map.getPitch(),
sources: Object.keys(map.getStyle().sources),
});
};
map.on('idle', () => requestAnimationFrame(tick));
});
});
The settled latch matters: idle fires more than once during a load, and without it the page calls the binding repeatedly. Playwright tolerates that, but the second call resolves nothing and the extra work runs during the capture.
3. Report failure explicitly rather than letting the timeout speak
The two conditions worth reporting are a missing layer and a source that errored, because both are permanent — no amount of further waiting fixes them.
map.on('error', (e) => {
window.__mapFailed(`map error: ${e.error?.message ?? 'unknown'}`);
});
setTimeout(() => {
if (settled) return;
const missing = REQUIRED.filter((id) => !map.getLayer(id));
if (missing.length) window.__mapFailed(`layers never appeared: ${missing.join(', ')}`);
}, 10000);
4. Assert the reported state before capturing
The detail the page sends back is the cheapest possible check that the map ended up where the fixture asked. It costs nothing and catches a camera that was clamped by maxBounds, a fractional zoom that eased instead of jumping, and a style that loaded a different source set.
const detail = await ready;
expect(detail.zoom).toBeCloseTo(fixture.zoom, 6);
expect(detail.center[0]).toBeCloseTo(fixture.center[0], 6);
expect(detail.sources).toEqual(fixture.sources);
5. Give the binding a name that cannot silently drift
A rename in the init script that misses the test, or the reverse, produces the swallowed-ReferenceError failure above. Import the name from one module on both sides so the two cannot disagree.
// binding-names.js — imported by the test and serialised into the init script
export const READY = '__mapReady';
export const FAILED = '__mapFailed';
6. Guard against a page that never calls back
Even with a failure channel, a page can hang in a way neither branch reports — a worker that never returns, a style request that stalls. Wrap the promise with a bounded race that reports what the page’s state was at the timeout rather than just that it happened.
const settled = await Promise.race([
ready,
new Promise((_, rej) =>
setTimeout(async () => {
const state = await page.evaluate(() => ({
hasMap: Boolean(window.__testMap),
tiles: window.__testMap?.areTilesLoaded?.(),
layers: window.__testMap?.getStyle?.().layers.length,
})).catch(() => null);
rej(new Error(`readiness timed out; page state: ${JSON.stringify(state)}`));
}, 30000)
),
]);
7. Keep one definition of ready for the whole suite
The predicate belongs in one init script that every test registers, not copied into each spec. A suite with thirty copies of the readiness logic has thirty subtly different definitions within a year, and the ones that drift are the ones that go flaky.
Verification
Confirm the procedure worked before wiring it into a blocking gate:
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The test times out with no error, and the page looks fine in a headed run | The binding was registered after goto, so the page’s call hit an undefined function inside a handler and the error was swallowed |
Register exposeFunction before navigation and assert the binding exists at the top of the init script |
| Readiness resolves, but the capture still shows a partly drawn frame | The predicate latched on idle alone without the quiet-frame count, so the compositor had not presented the last frame |
Add the QUIET_FRAMES counter from step 2, which is the frame-level half of the condition |
| The binding is called several times per load | idle fires more than once during loading and the settled latch is missing |
Latch after the first successful report, as in step 2, so later idle events do no work during the capture |
Frequently asked questions
Does exposeFunction slow the page down?
Each call crosses the process boundary, so it costs roughly a millisecond — irrelevant for a signal that fires once per capture, and material if a predicate calls it on every frame. Report once, latched, and the cost never appears. The anti-pattern to avoid is using a binding as a general-purpose logging channel from a render loop, which does measurably change the timing you are trying to observe.
What if the application does not dispatch a map-created event?
Add one in the test build; it is a single line next to the map’s construction and it is the cleanest hook available. The alternatives — polling a global until it appears, or patching the library’s constructor — both work and both are more fragile, because they depend on internal timing or internal names rather than on a contract the application deliberately exposes.
Can the same pattern report progress rather than just readiness?
Yes, and it is worth doing for slow scenarios: report tile counts as they resolve, and the test can distinguish “still loading, making progress” from “stuck” when a timeout approaches. Keep the reports coarse — every tenth tile, not every tile — so the channel cost stays negligible.
Is this specific to Playwright?
The mechanism is; the pattern is not. Puppeteer offers exposeFunction with the same semantics, and any driver with a page-to-runner binding supports the same inversion. What matters is that the page reports the moment rather than the runner sampling for it, which is a design choice rather than an API feature.
Related
- Up to Playwright Event Hooks for Map Capture, and the section Screenshot Capture, Sync & Comparison Logic.
- WebGL Idle & Render-Completion Detection — the composite predicate this reports on.
- Freezing Map Time with Playwright addInitScript — the other half of the init-script setup.
- Viewport & Zoom Sync Strategies — the camera fixture the reported detail is asserted against.