Intercepting Vector Tile Requests with Playwright route

A map suite that still reaches the tile service is not deterministic; it is stable until the day the basemap is updated, the CDN is slow, or the run happens outside the office network. Route interception fixes that, and the part teams get wrong is not the tiles — it is the four other asset classes a vector map fetches, each of which will silently keep coming from the network while everything looks fine. This procedure builds a complete fixture layer and ends with the catch-all that proves it is complete.

This is a task within Playwright Event Hooks for Map Capture, under Screenshot Capture, Sync & Comparison Logic. It is what makes the tile-drain predicate in Handling Async Tile Loading settle in tens of milliseconds rather than seconds.

Prerequisites

Step-by-step procedure

1. Enumerate what the map actually fetches

A vector map makes five classes of request, and stubbing only the first is the standard mistake.

Class Typical path What happens if it escapes
Style JSON /styles/basemap.json The whole render changes when the style is updated upstream
Vector tiles /tiles/{z}/{x}/{y}.pbf Geometry changes as the basemap is re-cut
Sprite sheet /sprite@2x.png, /sprite@2x.json Every icon shifts when the atlas is re-packed
Glyph ranges /fonts/{fontstack}/{range}.pbf Every label re-rasterises if the font build changes
Raster / DEM tiles /hillshade/{z}/{x}/{y}.png Terrain shading changes with a data refresh

The sprite and the glyphs are the ones that get missed, because they are fetched once, cached aggressively, and produce a change that looks like a rendering regression rather than a data one.

2. Record the set once

Run the test with a recording route that passes everything through and writes what it sees. Delete the recorder afterwards; it is a tool, not part of the suite.

await context.route(/\/(tiles|fonts|sprite|styles|hillshade)\//, async (route) => {
  const response = await route.fetch();
  const rel = new URL(route.request().url()).pathname.replace(/^\//, '');
  const file = path.join(FIXTURES, rel);
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, await response.body());
  await route.fulfill({ response });
});

3. Serve from memory, not from disk, per request

A route handler that reads a file on every tile adds milliseconds per request and hundreds per capture, and it becomes the thing your readiness gate is waiting for. Load the set once per worker.

const FIXTURE_CACHE = new Map();
for (const file of walk(FIXTURES)) {
  FIXTURE_CACHE.set('/' + path.relative(FIXTURES, file), fs.readFileSync(file));
}

await context.route(/\/(tiles|fonts|sprite|styles|hillshade)\//, async (route) => {
  const { pathname } = new URL(route.request().url());
  const body = FIXTURE_CACHE.get(pathname);
  if (!body) return route.fulfill({ status: 404, body: 'no fixture' });
  await route.fulfill({
    body,
    contentType: contentTypeFor(pathname),
    headers: { 'cache-control': 'no-store', 'access-control-allow-origin': '*' },
  });
});

Returning 404 for a missing fixture rather than falling through is deliberate. A fall-through hides the gap until the network is unavailable; a 404 surfaces it the first time.

Five asset classes a vector map fetches, and how often each is requested Five bars show how many requests each asset class produces for one capture. Vector tiles dominate with dozens of requests. Glyph ranges follow with a handful, one per font stack and character range in view. Raster or terrain tiles vary with the style. The sprite sheet and its index are two requests, and the style JSON is one. A note beside the two smallest bars explains why they are the ones that get missed: they are requested once, cached aggressively, and when they change they alter every icon or every label at once, which reads as a rendering regression rather than as a data change. The two smallest classes cause the largest diffs vector tiles glyph ranges raster / DEM sprite (2) style JSON (1) re-packed atlas → every icon shifts upstream edit → the whole render changes dozens

4. Match on the path, never on the full URL

Tile URLs commonly carry an access token, a cache-busting parameter or a version query that varies per run. Matching on the full URL makes the route miss; matching on the pathname is stable.

const key = new URL(request.url()).pathname;   // ignores search entirely

If the application legitimately varies a path segment — a style hash in the path, for instance — normalise it in one place so both the recorder and the server agree on the key.

5. Count what was served and assert it

The single most useful guard is a count. A capture that served zero fixtures is drawing from the network; a capture that served three when it should serve forty is missing a class.

let served = 0;
// ... in the handler: served += 1;

test.afterEach(() => {
  expect(served).toBeGreaterThan(EXPECTED_MIN);
});

6. Add the catch-all that proves nothing escaped

Registered last, so the specific routes win, and scoped to reject anything that is not local.

await context.route('**/*', (route) => {
  const url = route.request().url();
  const local = url.startsWith('http://127.0.0.1') || url.startsWith('data:') || url.startsWith('blob:');
  if (local) return route.continue();
  throw new Error(`unfixtured request escaped: ${url}`);
});

This is the check that keeps the fixture set complete as the application grows. Without it, a new layer added six months from now quietly reintroduces a network dependency and nobody notices until a run fails offline.

Route matching order, with the catch-all registered last Requests enter at the left and pass through route handlers in registration order. The specific fixture route matches the five asset paths and fulfils them from memory. A request that matches the pattern but has no fixture is fulfilled with a 404, which surfaces the gap immediately instead of hiding it. Everything else falls through to the catch-all registered last, which allows local and data URLs to continue and throws on anything else, naming the URL. A caption notes that the catch-all is what keeps the fixture set complete as the application grows. Specific routes first, catch-all last request fixture route tiles, fonts, sprite, styles, hillshade fulfil from memory · or 404 if absent a 404 surfaces the gap; a fall-through hides it catch-all, registered last local and data URLs continue anything else throws, naming the URL

7. Refresh fixtures deliberately, not incidentally

A fixture set is a snapshot of upstream data, and it ages. Refresh it on a schedule the team chooses — a quarterly job that re-records and opens a pull request — so the diff caused by upstream data is one reviewable event rather than a surprise mixed into an unrelated branch.

Scheduled fixture refresh against incidental drift Two timelines over a year. On the unfixtured timeline, upstream basemap updates arrive whenever the provider ships them, each producing a red suite on whatever branch happened to be open, so the churn is attributed to unrelated pull requests. On the fixtured timeline with a quarterly refresh, the suite is unaffected by upstream releases and the data change arrives as four scheduled events, each reviewed on its own branch with nothing else in it. A caption notes that the total amount of change is the same; only its attribution differs. Same amount of change, attributable instead of scattered live tiles each one reddens whatever branch was open fixtures four scheduled refreshes, each reviewed alone

Verification

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

Troubleshooting

Symptom Likely cause Fix
Routes never fire and everything comes from the network The routes were registered after page.goto, so the navigation’s requests had already been issued Register every route on the context before navigating, as in Playwright Event Hooks for Map Capture
Tiles are served but labels render in a fallback font The glyph range requests are not matched by the fixture pattern, so they 404 and the renderer falls back Include /fonts/ in the pattern and re-record; glyph ranges are per font stack and per character range
Fixtures are served locally but the capture is still slow The handler reads from disk on every request rather than from an in-memory cache Load the set once per worker as in step 3; the handler should be a map lookup and nothing more

Frequently asked questions

Should tile fixtures be committed to the repository?

For a small set — one or two scenarios, a few megabytes — yes, because it makes the suite runnable from a clone with no setup. Beyond that, store them as a versioned archive keyed by a manifest hash and restore them in CI from a cache, the same arrangement described in Storing Map Baselines in S3 with Git LFS Manifests. What matters either way is that the set is pinned by a hash rather than fetched fresh.

How do I keep the fixture set from growing without limit?

Trim to what the camera actually requests. A recorder left running across a suite captures every tile every scenario touched, including prefetch tiles that never reach a pixel. Recording per scenario and storing per scenario keeps each set small and makes it obvious which scenario a fixture belongs to, at the cost of some duplication between neighbouring cameras — which compresses away almost entirely.

Does serving fixtures make the test less realistic?

It removes the network from the test and leaves everything else. Decode, projection, style evaluation, symbol placement, collision and rendering all still run on real tile data. What is lost is coverage of the transport — retries, partial responses, CDN behaviour — and that belongs in an integration test rather than in a visual one, for the same reason the visual suite does not test interaction.

What about a map that fetches tiles from several hosts?

Match on the pathname and ignore the host, or normalise the host in the key. Multi-host tile serving is common for browser connection limits, and a fixture keyed by full URL will miss whichever subdomain the library happened to choose this run — which is itself randomised, making it an excellent source of intermittent failures.