Maintaining Per-Engine Baselines for Leaflet and OpenLayers

Leaflet and OpenLayers render maps very differently from a WebGL library: their basemap is a grid of <img> or 2D-canvas raster tiles, their controls and popups are ordinary DOM, and their overlays are SVG or Canvas 2D paths. That means they diverge far less across browser engines than a GPU rasterizer does — a raster PNG tile is byte-identical whether Chromium, WebKit, or Firefox paints it. But they do not converge completely: Canvas 2D anti-aliasing on vector overlays and DOM text metrics for labels and attribution still differ per engine. The practical task is to hold a baseline set that splits exactly where the pixels genuinely differ and shares everywhere else, so you store three goldens where you need three and one where one will do — and can re-bless a single engine without disturbing the rest.

This procedure is one task within the Cross-Browser Baseline Matrix guide, and it sits under the broader CI/CD Integration & Visual Test Operations area that gates deploys on these comparisons. It assumes you have already characterized where your engines diverge — the companion guide on Chromium vs WebKit vs Firefox MapLibre rendering divergence covers the WebGL case that motivates the wider matrix.

Prerequisites

Step-by-step procedure

1. Decide which axes actually need separate baselines

Do not open three goldens per view by reflex. For raster/canvas-and-DOM libraries the engine axis only bites on two surfaces: Canvas 2D anti-aliasing (OpenLayers vector layers, Leaflet’s Canvas renderer, marker shadows) and DOM text rasterization (zoom controls, scale bar, attribution, L.Tooltip/ol.Overlay labels). Everything painted from a raster tile is engine-independent. Classify each view before you bless anything.

// Per-view axis decision. `split` means keep one golden per engine;
// `share` means a single golden covers all engines for that view.
const viewAxisPolicy = {
  'basemap-only-z12':       { engineAxis: 'share' },  // pure raster tiles
  'basemap-plus-attribution': { engineAxis: 'split' }, // DOM text over tiles
  'ol-vector-overlay-z14':  { engineAxis: 'split' },  // Canvas 2D AA differs
  'leaflet-svg-geojson':    { engineAxis: 'split', dprAxis: 'split' }, // SVG + DPR
};

The axes worth modelling explicitly are library (leaflet vs openlayers), library version, engine (chromium/webkit/firefox), and device pixel ratio. Zoom is a fixture axis, not an engine axis — pin it. The DPR axis is real and cheap to forget: at DPR 2, Leaflet requests @2x tiles and OpenLayers scales its canvas, so a DPR-2 frame is a different baseline, never a rescale of the DPR-1 one.

2. Build a stable storage key from the axes

Encode every axis that can legitimately change the pixels into a deterministic key, and let views that share the engine axis resolve to the same object. A flat filename cannot express “these two engines share one golden”; a structured key with a resolver can.

function baselineKey({ view, lib, libVersion, engine, dpr }, policy) {
  // Collapse the engine segment to "any" when the view shares across engines.
  const engineSeg = policy[view]?.engineAxis === 'share' ? 'any' : engine;
  const dprSeg = policy[view]?.dprAxis === 'share' ? 'any' : `dpr${dpr}`;
  return [lib, `v${libVersion}`, view, engineSeg, dprSeg].join('/') + '.png';
}

// leaflet/v1.9/basemap-only-z12/any/dpr1.png        <- one golden, all engines
// openlayers/v9.2/ol-vector-overlay-z14/webkit/dpr1.png  <- engine-specific

Record the same axes as fields in the manifest, not just in the path, so an audit can query “every WebKit golden for OpenLayers 9.2” without parsing filenames. Keep the binary tiles and PNGs out of Git and the manifest in it, exactly as the storing map baselines in S3 with Git LFS manifests guide lays out.

Baseline key structure and which matrix cells split versus share across engines Top: the storage key is composed of five ordered segments — library, library version, view, engine, and device pixel ratio — where the engine segment collapses to the literal "any" when a view is shared across engines. Below, a matrix has two library rows, Leaflet and OpenLayers, and three engine columns, Chromium, WebKit, and Firefox. A basemap-only raster row spans all three columns as one shared cell because raster tiles are byte-identical across engines. A DOM-text row (attribution and controls) and a Canvas or SVG vector-overlay row each split into three engine-specific cells because anti-aliasing and text metrics differ per engine. The baseline key: split where pixels differ, share where they don't leaflet v1.9 basemap-only-z12 any dpr1 library version view engine → "any" = shared DPR Matrix: what splits vs what shares Chromium WebKit Firefox Basemap raster tiles 1 shared golden — engine = "any" PNG tiles are byte-identical across engines DOM text controls · attribution golden · chromium golden · webkit golden · firefox Vector overlay Canvas 2D / SVG golden · chromium golden · webkit golden · firefox Shared rows cost one golden and one comparison; split rows cost three of each. Split only the rows that earn it.

3. Bless only the affected engine’s set

When a change actually alters output on one engine, re-approve that engine’s cells and leave the shared and unaffected cells untouched. The blessing tool must accept an engine filter and refuse to overwrite a shared (any) golden unless the change is proven to affect every engine — otherwise a Chromium-only kerning shift silently overwrites the golden WebKit and Firefox still depend on.

# Re-bless just the WebKit vector-overlay goldens for OpenLayers 9.2,
# leaving Chromium, Firefox, and every shared raster golden as-is.
bless-baselines \
  --lib openlayers --lib-version 9.2 \
  --engine webkit \
  --view 'ol-vector-overlay-*' \
  --from current/ --refuse-shared
// Guard inside the bless step: never let a single-engine diff touch a shared key.
function assertBlessable(key, engine) {
  if (key.includes('/any/') && engine !== 'all-engines-verified') {
    throw new Error(`Refusing to overwrite shared golden ${key} from a single engine`);
  }
}

A single-engine bless keeps the audit trail honest: the manifest entry records which engine produced the new golden and when, so a later reviewer can see that WebKit moved while Chromium and Firefox did not.

4. Collapse engines when a raster cell is byte-identical

A view that starts life as three split goldens can often collapse to one. After capturing the same view on all three engines, hash the frames; if they match, replace the three engine-specific keys with one shared any key and delete the redundant objects. This is where raster/canvas-and-DOM libraries pay off relative to WebGL — most basemap-only views collapse cleanly.

import { createHash } from 'node:crypto';

const sha = (buf) => createHash('sha256').update(buf).digest('hex');

function tryCollapse(view, framesByEngine) {
  const hashes = Object.values(framesByEngine).map(sha);
  const identical = hashes.every((h) => h === hashes[0]);
  if (identical) {
    return { action: 'collapse', to: keyFor(view, 'any'), remove: enginesOf(view) };
  }
  return { action: 'keep-split' };
}

Run the collapse check as a maintenance job, not on every PR — engines occasionally converge after a browser update, and reclaiming those cells shrinks both storage and the number of comparisons each CI run performs. The inverse also happens: a browser update can split a previously shared cell, which the drift audit in the next step catches.

5. Audit drift between engines and against the shared golden

Standing baselines rot. A browser bump, a font-package change, or a Leaflet/OpenLayers upgrade can make a formerly-shared cell diverge, or make two split cells reconverge. A periodic audit re-captures every view on every engine and compares against the stored keys, reporting three states: shared-still-valid, split-still-needed, and newly-divergent (a shared golden that no longer matches one engine).

for (const view of allViews) {
  const frames = await captureAllEngines(view);      // { chromium, webkit, firefox }
  const stored = await loadGolden(view);
  if (isShared(view)) {
    const offenders = Object.entries(frames)
      .filter(([, buf]) => sha(buf) !== sha(stored))
      .map(([engine]) => engine);
    if (offenders.length) report.newlyDivergent(view, offenders); // must now split
  } else {
    if (allEqual(Object.values(frames))) report.canCollapse(view); // can now share
  }
}

Feed the audit report back into the axis policy from step 1: newlyDivergent flips a view’s engineAxis to split, canCollapse flips it to share. That keeps the matrix minimal without a human re-auditing every cell by hand.

Verification

Confirm the per-engine store is coherent before you trust it as a gate:

A healthy store gives a clean audit with zero newlyDivergent rows and a bless that touches only the engine you named.

Troubleshooting

Symptom Likely cause Fix
Blessing one engine changes another engine’s result A single-engine diff overwrote a shared any golden that all engines resolve to Add the --refuse-shared guard from step 3; split the view before re-blessing if the change is genuinely engine-specific
Basemap-only view still needs three goldens Attribution, a scale bar, or a Canvas overlay is inside the capture clip, so DOM/AA differences leak into a “raster” frame Clip to the tile pane only, or move DOM chrome into its own split view and keep the raster pane shared
Audit flags a stable view as newly divergent every run Tiles served live, not from fixtures, so a CDN re-encode changes bytes independent of engine Pin tiles to local fixtures per the versioning guide so the raster layer is bit-stable and only real engine drift is reported

Frequently asked questions

Why keep per-engine baselines at all for Leaflet and OpenLayers if raster tiles are identical?

Because the raster basemap is only part of the frame. Zoom controls, the scale bar, attribution, tooltips, and any Canvas 2D or SVG vector overlay are rasterized by the browser, and their anti-aliasing and text metrics differ between Chromium, WebKit, and Firefox. Those surfaces need per-engine goldens; the pure-raster views do not, which is exactly why you split some cells and share others rather than blanket-splitting everything.

When can I safely collapse three engine baselines into one shared golden?

When the same view captured on all three engines is byte-identical — hash the frames and compare. That is common for basemap-only views clipped to the tile pane, where every pixel comes from a fixture PNG. Run the collapse check as a maintenance job rather than per-PR, because a browser update can later re-split a collapsed cell, which the drift audit is there to catch.

How do I re-bless only WebKit without disturbing Chromium and Firefox goldens?

Key each golden by engine and give the bless tool an --engine filter plus a guard that refuses to overwrite a shared any key from a single engine. Blessing then rewrites only the named engine’s objects and manifest entries; the shared raster goldens and the other engines’ split goldens keep their bytes and timestamps, so the audit trail shows exactly which engine moved.

Does device pixel ratio need its own baseline axis here?

Yes. At DPR 2, Leaflet requests @2x tiles and OpenLayers scales its canvas, so the frame is a genuinely different image roughly four times the pixel area — never a rescale of the DPR-1 golden. Encode dpr in the key alongside library, version, view, and engine, and never let a DPR-2 capture resolve to a DPR-1 golden.

← Back to Cross-Browser Baseline Matrix