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.
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');
}
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.
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.
Related
- Up to Playwright Event Hooks for Map Capture, and the section Screenshot Capture, Sync & Comparison Logic.
- Animated Tile Layer Stabilization — the layer-level freeze this clock substitution is a prerequisite for.
- Live Data Overlay Stabilization — reconciling a fixture’s timestamps with the frozen epoch.
- Containerized Rendering Environments for Map Tests — pinning the timezone the freeze assumes.