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);
A polled predicate against a pushed one, over the same load Two timelines over one map load. The polled timeline samples every 250 milliseconds; readiness occurs between two samples, so the capture happens at the next sample and the gap between readiness and capture is wasted. The pushed timeline has no samples at all: the page calls the exposed binding at the instant its predicate first holds, so capture follows immediately. A second difference is annotated: the pushed signal carries the camera and source list back with it, so the test can assert the map ended up where the fixture said before it takes the picture. Push reports the moment; poll reports the next sample after it polled ready capture here — up to one interval late pushed ready = capture and the pushed signal carries the camera and source list back for assertion

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)
  ),
]);
Three channels between the page and the test, and what each one carries Three bindings run from the page back to the test. The ready channel carries the camera, the source list and the frame count at the moment the composite predicate first held, and resolves the test's promise. The failed channel carries a specific message for the permanent failures — a layer that never appeared, a source that errored — and rejects the promise so the test fails with a cause rather than a timeout. The timeout channel is owned by the test rather than the page and fires only when neither of the others did, capturing the page's state at that instant so the report says what was missing. Three channels, so a hang reports a cause instead of a duration __mapReady · page → test camera, sources, frame count at the latching instant resolves the promise, and gives the test something to assert __mapFailed · page → test a named permanent failure — missing layer, source error rejects immediately; no reason to wait out the timeout timeout · owned by the test fires only when neither channel did reads the page’s state so the message names what was missing

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.

One shared predicate against a copy per spec Two arrangements of the same suite. On the left, one readiness module is registered by every spec, so a correction to the predicate reaches all thirty tests at once and there is a single definition of ready. On the right, each spec carries its own copy; three of them are shaded to show that they have drifted, having been edited during separate debugging sessions, and those three are the tests that go flaky. A caption states the rule of thumb: readiness is a property of the application, not of a test, so it belongs where the application is described. Readiness is a property of the map, not of a test one predicate a fix reaches every spec three have drifted — and those three flake the drift always happens during debugging, and is never noticed at the time

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.