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.
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.
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.
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.
Related
- Up to Playwright Event Hooks for Map Capture, and the section Screenshot Capture, Sync & Comparison Logic.
- Handling Async Tile Loading — the drain predicate fixtures make fast and reliable.
- Capturing Consistent Map States Across Network Conditions — testing that the gate holds when the transport is slow.
- Testing Map Tiles After CDN Cache Invalidation — the case where the network is deliberately not stubbed.