X-GIS
Blog

testing

Offline tile mirrors for deterministic map e2e

Our CI container's browser can't reach the internet, but the final render gate needs a real 117-layer basemap. On mirroring styles, tiles, glyphs and sprites locally — and the traps: SPA fallback serving HTML as tiles, unencoded fontstacks, and settling on signals instead of sleeps.

By the X-GIS team 4 min read

A rendering engine’s final gate is a real style — synthetic fixtures have three layers, a production basemap has 117. Ours had to run in an isolated container where the browser’s outbound network is blocked entirely: curl reaches an agent proxy, but Chromium’s CONNECT tunnels are reset before a request lands. The tell was the proxy’s relay log — it stayed empty. That ruled out a fix by configuration: Playwright’s per-context proxy and --proxy-server both assume the browser will hand its CONNECTs to the proxy to be relayed, but Chromium tore the tunnel down before anything reached the proxy to point anywhere. Instead of fighting the network, we closed the resource graph and moved it local.

A map style’s dependency graph is finite and explicit:

style.json ─┬─ tilejson (source metadata)
├─ vector tiles /{z}/{x}/{y}.pbf
├─ glyphs /fonts/{fontstack}/{range}.pbf
└─ sprite /sprite/*.{json,png}

We mirror all of it under the dev server’s public/, rewrite every URL in a copy of the style to a mirror-relative path, and delete sources the test doesn’t exercise — the ne2 world raster (z≤6, invisible at the z14 test camera). The rewrite is mechanical, absolute upstream URL to dev-server path:

"https://…/{z}/{x}/{y}.pbf" → "/mirror/tiles/{z}/{x}/{y}.pbf"

Scoped to a single camera (Tokyo, z0–14) the snapshot is ~90 MB — style, tilejson, vector tiles, sprite and Noto Sans glyph ranges — regenerated by script and gitignored.

Trap 1: the SPA fallback poisons your tiles

A 200 is not a success

An SPA dev server answers any unmatched path with index.html. A binary asset fetch — tile, glyph, sprite — that misses the mirror therefore receives HTML under a 200, and fails much later, deep inside a decoder, looking like corruption rather than the 404 it actually is.

The nastiest failure mode: request a tile the mirror doesn’t have, and Vite answers 200 with index.html (SPA fallback). The worker then tries to parse HTML as MVT and dies with protobuf errors like Unimplemented type: 4. A missing tile masquerades as a corrupt tile.

We found it by looking, not guessing: a 200 OK .pbf that a protobuf decoder rejects is a contradiction, so we dumped the response bytes instead of trusting the decoder’s story. The first line was <!DOCTYPE html> — the tile was the SPA shell (3c 21 44 4f 43…, where a real tile opens on a protobuf field tag). Once you have seen the byte shape the failure is obvious; before that, Unimplemented type: 4 sends you hunting a tile-encoder bug that does not exist.

The fix is to close the request set. Log every tile/glyph request the page actually makes, and grow the mirror until the not-in-mirror set is empty:

page.on('request', (r) => {
const m = r.url().match(/\/mirror\/(tiles|fonts)\/.+\.pbf/)
if (m) requests.add(m[0])
})

As a by-product, that log is a regression artifact: “exactly which tiles does the engine request from camera X” is now a diffable file.

Trap 2: fontstacks contain spaces

Glyph URLs template {fontstack} with values like Noto Sans Regular. Forget to URL-encode it in the mirror script and 0 of 36 ranges download. One urllib.parse.quote. If your labels are CJK, also check range coverage — Han/Kana live in high ranges (12288, 19968, …) that a Latin-only mirror never fetches.

Trap 3: element screenshots include the DOM

For pixel comparison, capture with canvas.toBlob() — an element screenshot composites any DOM painted over the canvas (debug overlays, toasts). We watched one overlay inflate a tile’s diff score by 3 points before learning this the proper way.

Trap 4: settle on signals, not sleeps

A real style loads for a long time — tile parsing, glyph rasterization, and in CI, SwiftShader shader compiles. Fixed sleeps flake, always. The engine exposes a work-pending signal (“how many needed tiles can still materialize”), and the test loops invalidate-and-wait until it reaches zero. Building that signal honestly paid twice: the harness found an engine bug where a layer with no features in view kept the count above zero forever, spinning the frame loop at 60 fps on a converged, visually perfect frame.

What it buys

A 117-layer dual-backend pixel gate that runs in minutes, in a container with zero network, deterministically — immune to tile-server updates, CDN hiccups, and rate limits. The same mirror now serves performance runs too: with network variance gone, frame-time comparisons got dramatically quieter.

Read next

testing

5 min read

The pixel test that passed on the wrong GPU

The same render test passed twice: once at 38.5% painted pixels on real WebGPU, once at 93.6% on a silent WebGL2 fallback the assertion never noticed. Why a designed-in fallback chain turns every green output test ambiguous — until the test also pins which backend produced the frame.

rendering

7 min read

The hemisphere that was never selected: a fast-path bbox that collapsed to a point

Half the globe rendered as background again — same symptom as the drape-suppression bug, opposite root cause. This time the trans-antimeridian tiles were never selected: the overzoom fast-path unprojects the viewport corners to a lon/lat box, but on a small globe disc the corners miss the sphere, the box collapses to the sub-camera point, and it emits the single tile column under the camera — which a contiguous longitude range can never wrap across the dateline. Told apart from the draw bug by seam location and persistence.