Freezing Map Time with Playwright addInitScript

A map reads the clock in more places than it looks: fade transitions between tile levels, the age styling on a live layer, a {time} token in a raster source URL, an attribution string with a year in it, and the animation frames every one of those advances on. Freeze the clock a moment too late and the first frames run on the real one, so the first capture of a run differs from every later capture and no amount of threshold tuning explains it. This procedure installs the substitution in the only place that is early enough, and covers the four time sources a map actually uses.

This is one task within Playwright Event Hooks for Map Capture, the guide to which runner hook reaches which part of a capture. It sits under Screenshot Capture, Sync & Comparison Logic, and it is a prerequisite for anything in Animated Tile Layer Stabilization, which assumes the clock is already pinned.

Prerequisites

Step-by-step procedure

1. Choose the epoch and put it in the fixture

The value is arbitrary; recording it is not. Store it as fixtures/time.json. A baseline blessed against one epoch and compared against a run using another will differ wherever anything is styled by age or formats a date, and the difference is impossible to attribute after the fact.

{ "epoch": 1719792000000, "tz": "UTC", "locale": "en-GB" }

Storing the timezone and locale alongside it is deliberate: all three together are what determine a formatted date, and pinning one without the others leaves the capture dependent on the runner’s system settings.

2. Replace Date on the context, before any page script

addInitScript on the context runs in every document the context creates — the main page, any popup, any iframe — before that document’s own scripts. Registering it on the page instead covers only the page.

const { epoch } = require('./fixtures/time.json');

await context.addInitScript((EPOCH) => {
  const RealDate = Date;
  const FrozenDate = class extends RealDate {
    constructor(...args) {
      super(...(args.length ? args : [EPOCH]));
    }
    static now() { return EPOCH; }
    static parse(...a) { return RealDate.parse(...a); }
    static UTC(...a) { return RealDate.UTC(...a); }
  };
  FrozenDate.prototype = RealDate.prototype;
  globalThis.Date = FrozenDate;
}, epoch);

Keeping parse and UTC delegating to the real implementation matters: map libraries and date formatters use them on strings from the style and from data, and breaking them produces Invalid Date in places that have nothing to do with the freeze.

3. Freeze performance.now() as well

Date.now() is not the only clock. Map renderers time their fade transitions with performance.now(), because it is monotonic and higher resolution, and a frozen Date with a running performance leaves every fade advancing normally.

await context.addInitScript(() => {
  const t0 = 0;
  Object.defineProperty(performance, 'now', {
    value: () => t0,
    configurable: true,
  });
});

Returning a constant is the right choice for a static capture: a fade whose elapsed time never advances stays at its starting state, which is deterministic. If the capture needs the end state of a fade rather than the start, drive it explicitly per step 5 rather than letting the timer run.

4. Make requestAnimationFrame deterministic without breaking the render loop

The temptation is to stub requestAnimationFrame entirely. Do not: the readiness gate described in WebGL Idle & Render-Completion Detection depends on real frames being scheduled and presented. What needs pinning is only the timestamp the callback receives, which some libraries use as their animation clock.

await context.addInitScript(() => {
  const raf = globalThis.requestAnimationFrame.bind(globalThis);
  globalThis.requestAnimationFrame = (cb) => raf(() => cb(0));
});

Frames still happen at the browser’s own rate, the compositor still presents them, and every callback sees the same timestamp — so anything integrating elapsed time stands still while the render loop keeps working.

Four clock sources and which of them to freeze, stub or leave alone Four time sources are listed with the treatment each needs. Date.now and the Date constructor are replaced with a frozen epoch, because they drive age styling, formatted dates and time tokens in tile URLs. performance.now is replaced with a constant, because renderers time fade transitions with it and a running value keeps every fade advancing. The requestAnimationFrame timestamp is pinned to zero while the scheduling itself is left intact, because the readiness gate depends on real frames being presented. Timer functions such as setTimeout are left alone entirely, because stubbing them stalls the loading path the capture is waiting for. Freeze the values, never the scheduling Date.now / new Date REPLACE with the fixed epoch drives age styling, formatted dates, time tokens performance.now REPLACE with a constant renderers time fade transitions with it rAF timestamp PIN the argument, keep the scheduling the readiness gate needs real presented frames setTimeout / setInterval LEAVE ALONE stubbing them stalls the loading path being waited on

5. Drive a transition to its end state rather than waiting it out

With the clock frozen, a fade that is mid-flight stays mid-flight. If the baseline should show the settled state — and for tile-level fades it almost always should — the correct move is to disable the transition in the style rather than to advance the clock.

await page.evaluate(() => {
  const map = window.__testMap;
  for (const layer of map.getStyle().layers) {
    if (layer.type === 'raster') map.setPaintProperty(layer.id, 'raster-fade-duration', 0);
  }
});

A zero fade duration with a frozen clock is unambiguous: the layer is either fully drawn or not drawn, with no intermediate opacity for a threshold to have an opinion about.

6. Assert the freeze took, in the page

Because a failed substitution produces a plausible screenshot rather than an error, the init script should record whether it worked and the test should read that record before capturing.

await context.addInitScript((EPOCH) => {
  globalThis.__timeFrozen = () =>
    Date.now() === EPOCH && performance.now() === 0;
}, epoch);

// in the test, before capture
if (!(await page.evaluate(() => globalThis.__timeFrozen()))) {
  throw new Error('time substitution did not take — check init script ordering');
}
Registering the substitution one step too late, and what the first capture shows Two page-load timelines are compared. In the first, the init script is registered on the context before navigation, so the substitution is in place before the document's own scripts run and every frame from the first onward sees the frozen epoch. In the second, the substitution is applied after navigation resolves; the shaded early region shows the frames that already ran on the real clock, during which a raster fade advanced and an age-styled overlay computed a real age. The caption notes the symptom this produces — the first capture of a run differs from every later one, which reads as flakiness rather than as an ordering bug. The frames before the substitution are the ones that differ on the context every frame sees the frozen epoch after goto real clock · a fade advanced, an age was computed the first capture of a run differs from every later one, which reads as flakiness

7. Record the fixture alongside the baseline

The last step is bookkeeping, and it is what makes the freeze survive contact with a second engineer. The epoch, timezone and locale become part of the capture’s input vector, so they belong in the baseline’s annotation exactly as the engine version and style hash do.

Time settings joining the rest of the capture's input vector A baseline's input vector is drawn as a row of chips that together determine the captured pixels. The established chips are the engine and version, the device pixel ratio, the style hash and the camera hash. Three further chips are added in a contrasting colour: the frozen epoch, the timezone and the locale. An arrow from the whole row leads to the baseline hash. A note explains the consequence of leaving the three new chips out: two runs that agree on everything else can still produce different pixels, and the difference has no recorded cause. Time is an input, so it belongs in the input vector chromium-121 dpr1 style-a4f9 camera-7b02 epoch-1719 UTC en-GB baseline hash leave the three teal chips out and two runs can agree on everything recorded and still produce different pixels, with no attributable cause

Verification

Confirm the procedure worked before wiring it into a blocking gate:

Troubleshooting

Symptom Likely cause Fix
Captures agree with each other but differ from the blessed baseline by a date string The baseline was blessed under a different epoch, or under the runner’s real timezone rather than the pinned one Record the epoch, timezone and locale in the fixture, re-bless once against them, and treat the fixture as part of the baseline key
Invalid Date appears where a date was parsed from style or data The Date replacement did not delegate parse and UTC to the real implementation Keep both static methods delegating, as in step 2 — only now and the zero-argument constructor should be frozen
Tile fades never complete, so layers capture at partial opacity performance.now() is frozen, so the renderer’s elapsed time never advances and the transition cannot finish Set raster-fade-duration to 0 as in step 5 rather than advancing the timer; a zero-length transition has no intermediate state

Frequently asked questions

Why not use a fake-timer library instead of hand-rolling this?

A fake-timer library that also replaces setTimeout and setInterval will stall the map’s own loading path, because tile requests, style loading and worker messages all schedule work through those. If you use one, configure it to fake only Date and performance and to leave the timer functions real. The hand-rolled version is a dozen lines and makes that boundary explicit, which is why it is the recommendation here.

Does freezing time break the readiness gate?

No, provided requestAnimationFrame scheduling is left intact as in step 4. The gate counts frames and watches engine events; neither depends on the timestamp value. It would break if the whole function were stubbed, which is exactly the mistake step 4 exists to prevent.

What epoch should a team pick?

Any instant that is unambiguous in the application’s own terms — a round UTC value, not a local midnight that shifts with daylight saving. If the map styles anything by age, choose one that puts the fixture’s data at a sensible age rather than one that makes every feature ten months old. Beyond that the value does not matter; recording it does.

Do I need to freeze time if nothing on screen shows a date?

Usually yes, because the things time affects on a map are rarely dates on screen. Tile-level fade transitions, age-based opacity on a data layer, a {time} token in a raster URL and any animation the style declares are all clock-driven and all visible as pixels. A map with no visible date and no frozen clock is the common case for a suite that is stable ninety-five percent of the time.

Does this survive a page navigation inside the test?

Yes, when the init script is registered on the context. Every document the context creates gets it, including one created by a navigation, a popup or an iframe. This is the main practical reason to prefer context.addInitScript over page.addInitScript, and the difference only shows up in the tests that navigate — which is a small enough subset that the bug can live a long time.