Migrating from Percy to Chromatic for Map Testing

Percy and Chromatic solve the same problem from opposite ends of the pipeline, and that difference is exactly what makes a map migration fiddly. Percy’s percySnapshot() serializes the live DOM in your end-to-end test, ships that snapshot to Percy’s cloud, and re-renders it there — which means your WebGL canvas has to be frozen to a data URL before it leaves the browser. Chromatic renders each Storybook story in its own cloud Chromium and screenshots the live frame, canvas and all. So porting a map suite is not a find-and-replace: you are moving the settle logic from an E2E test step into a story, and you are handing WebGL rasterization to a different renderer, which resets every baseline. This guide walks the exact steps, in order, with the map-specific traps called out.

This is one task inside the comparison guide Percy vs Chromatic for Maps, and it sits under the broader Web Map Visual Testing Fundamentals & Toolchains reference. If you have not yet decided the move is worth it, weigh the trade-offs first in how to choose visual regression tools for Leaflet vs Mapbox; this page assumes the decision is made and the goal is a clean cutover.

Prerequisites

Step-by-step procedure

1. Inventory Percy snapshots and their settle hooks

Before touching Chromatic, list every percySnapshot call and — critically — the wait that precedes it. In a map suite each snapshot is almost always preceded by a bespoke settle: a waitForFunction on areTilesLoaded(), an await map.once('idle'), or a fixed delay. That settle logic is the asset you are migrating; the snapshot call itself is trivial.

# Enumerate every snapshot and the lines just before it, so no settle hook is lost.
grep -rn "percySnapshot" test/ e2e/ cypress/ \
  | tee migration/percy-inventory.txt
grep -rn -B6 "percySnapshot" test/ e2e/ > migration/settle-hooks.txt

Record, for each snapshot: the scene name, the camera state (center, zoom, bearing, pitch), any masked regions passed via percyCSS, and the exact settle condition. This table becomes your story checklist. The idle/settle contract itself is covered in depth by Handling Async Tile Loading — the migration only relocates it, it does not redesign it.

2. Map each percySnapshot to a Chromatic story

Percy snapshots a page state reached by an E2E script; Chromatic snapshots a story. Convert each inventoried scene into a story whose args encode the camera and fixtures. One Percy snapshot becomes one story export.

// MapScene.stories.js — one export per former percySnapshot scene.
import { MapScene } from "./MapScene";

export default {
  title: "Maps/Basemap",
  component: MapScene,
};

export const HomeZ12 = {
  args: {
    center: [-0.1278, 51.5074],
    zoom: 12,          // integer zoom — matches the old Percy scene
    bearing: 0,
    pitch: 0,
    styleUrl: "/fixtures/style.json",
  },
};

Where Percy masked dynamic UI with percyCSS, use Chromatic’s per-story parameters instead. Attribution overlays, cursors, and live tooltips still have to be hidden — the masking rationale is unchanged, only the syntax differs.

HomeZ12.parameters = {
  chromatic: {
    // Regions that legitimately churn — hide them, don't diff them.
    ignoreSelectors: [".maplibregl-ctrl-attrib", ".cursor-layer"],
  },
};

3. Port the map idle/settle wait into the story

This is the load-bearing step. In Percy the wait lived in the test around percySnapshot; in Chromatic it must live inside the story so Chromatic does not screenshot a half-painted frame. Move the settle into a play function (or a loader) and delay Chromatic’s capture until the map reports idle. Guard the wait with isChromatic() so interactive local development is not blocked.

import { isChromatic } from "chromatic/isChromatic";

export const HomeZ12 = {
  args: { /* …camera as above… */ },
  play: async () => {
    const map = window.__map;
    await new Promise((resolve) => {
      const settled = () => !map.isMoving() && map.areTilesLoaded();
      const check = () => { if (settled()) { map.off("idle", check); resolve(); } };
      map.on("idle", check);
      check();               // handle the already-idle case
    });
  },
};

// Give the compositor a fixed settle window and stop animation before capture.
HomeZ12.parameters = {
  chromatic: { delay: 300, pauseAnimationAtEnd: true },
};

Chromatic waits for the play function to resolve before it snapshots, so resolving on idle reproduces the Percy behavior. Keep the delay small and deterministic — it backstops GPU compositing, it is not a substitute for the idle gate.

4. Re-generate and bless baselines in Chromatic

Percy baselines do not transfer. Chromatic rasterizes on its own Chromium, so the first Chromatic run has no golden to compare against and every story is “new.” Run the first build to establish baselines, review each captured frame against the corresponding Percy scene, and accept the ones that are correct.

# First build: establishes baselines. Review and accept in the Chromatic web UI,
# or auto-accept the very first build on the trunk branch.
npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN --auto-accept-changes=main

Do the first build on a clean trunk commit with fixtures pinned, so the blessed baseline reflects known-good pixels rather than in-flight work. From here, accepting a diff re-blesses only that story — the same per-scene discipline you want for any map suite.

5. Wire the Chromatic CI check and turn off the Percy gate

Run both gates in parallel for a short bake-in, then make Chromatic required and demote Percy. Do not delete Percy on the same commit you add Chromatic — you want one green Chromatic run on the trunk first.

name: visual-regression
on: [pull_request]

jobs:
  chromatic:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }   # Chromatic needs full history for baselines
      - uses: actions/setup-node@v4
        with: { node-version: "20" }
      - run: npm ci
      - name: Run Chromatic
        run: npx chromatic --project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }} --exit-zero-on-changes

Once Chromatic is stable, drop --exit-zero-on-changes so it fails the job on unaccepted diffs, flip it to a required status check in branch protection, and remove the Percy step and its PERCY_TOKEN. Getting the gate semantics right is a topic of its own — see GitHub Actions & GitLab CI Gates for Map Visual Tests for enforcing and parallelizing the check.

Percy path versus Chromatic path for a map snapshot Two pipelines compared top and bottom. The Percy path: an end-to-end test locks the camera and waits for idle, then percySnapshot serializes the DOM and freezes the WebGL canvas to a data URL, which is uploaded to Percy's cloud and re-rendered there before diffing against a Percy baseline. The Chromatic path: a Storybook story encodes the same camera as args and a play function waits for idle, then Chromatic renders the live story in its own cloud Chromium and screenshots the real WebGL frame before diffing against a Chromatic baseline. The settle-on-idle step is shared; the canvas handling and the rendering environment differ. Percy path E2E test lock camera, wait for idle percySnapshot serialize DOM, freeze canvas to data URL Percy cloud re-renders the serialized DOM diff vs Percy baseline Shared: the settle-on-idle gate is identical — only its location and the canvas handling change. Chromatic path Storybook story camera as args, play waits for idle Chromatic cloud renders live story in its own Chromium screenshot live WebGL frame, no serialization diff vs Chromatic baseline

6. Handle the WebGL capture difference

This is the map-specific gotcha that generic Percy-to-Chromatic guides skip. Percy re-renders a serialized DOM, so a live WebGL canvas would arrive blank — Percy’s serializer works around this by replacing each canvas with a static image of its toDataURL() at snapshot time. Chromatic never serializes: it screenshots the running story, so the canvas is rasterized fresh by Chromatic’s browser. Two consequences follow.

First, remove the Percy canvas-freezing workaround from your components; it is unnecessary in Chromatic and can even leave a stale image behind. Second, pin the GL backend so Chromatic’s live rasterization is deterministic, exactly as you would for any headless map capture.

// Story-level guard: ensure the map uses software GL and no animation loop
// keeps mutating the framebuffer during Chromatic's capture.
export const HomeZ12 = {
  args: { /* … */ },
  parameters: {
    chromatic: { delay: 300, pauseAnimationAtEnd: true, forcedColors: "none" },
  },
  loaders: [async () => {
    // preferWebGL2/antialias parity with the baseline environment
    window.__mapGLOptions = { antialias: false, preserveDrawingBuffer: true };
    return {};
  }],
};

Because rasterization moved to a new environment, expect a one-time, whole-suite baseline shift on cutover — that is the re-bless in step 4, not a regression. The same per-engine divergence is why a serious suite keeps separate goldens per browser, as laid out in Cross-Browser Baseline Matrix.

Verification

Confirm the migration is faithful before you retire Percy:

If a story shows a blank or grey map, the play function resolved before idle — the settle port in step 3 is incomplete, not the renderer.

Troubleshooting

Symptom Likely cause Fix
Chromatic captures a blank or grey canvas The story left a leftover Percy toDataURL freeze, or the play function resolved before idle Remove the canvas-serialization workaround (step 6) and resolve the play promise only on areTilesLoaded() (step 3)
Every story is a “change” on a normal PR Baselines never blessed after cutover, or fixtures drifted between builds Re-run the first build on trunk with --auto-accept-changes, and pin tile fixtures so pixels do not move (step 4)
Passes locally, differs in Chromatic cloud Local host GPU rasterizes WebGL differently from Chromatic’s Chromium Pin swiftshader software GL in the story environment and re-bless once against the cloud renderer

Frequently asked questions

Do my Percy baselines carry over to Chromatic?

No. Chromatic rasterizes each story in its own cloud Chromium, which produces different pixels from Percy’s re-rendered DOM snapshots, so there is no meaningful baseline to import. Treat the first Chromatic build as a fresh bless: review every story once against its old Percy scene and accept the correct ones.

Where does the map idle/settle wait go in Chromatic?

Inside the story, not the test. In Percy the wait wrapped the percySnapshot call; in Chromatic you move it into a play function (or loader) that resolves only when the map reports idle and areTilesLoaded() is true. Chromatic delays its screenshot until the play function completes, which reproduces the Percy timing.

Why did my WebGL canvas capture change after migrating?

Percy freezes the canvas to a static toDataURL() image before uploading, then re-renders that image; Chromatic screenshots the live canvas rasterized by its own browser. The rasterizer changed, so a one-time whole-suite baseline shift on cutover is expected. Pin a software GL backend so the new baseline is itself deterministic.

Can I run Percy and Chromatic at the same time during the migration?

Yes, and you should. Keep both CI steps for a short bake-in with Chromatic set to –exit-zero-on-changes so it cannot block merges yet. Once Chromatic is green on the trunk and stories reach parity, make it a required check and remove the Percy step and its token in the same commit.