Rukon
Building Real Systems5 min read

Freezing a Label Design in CI by Hashing Its Rendered Raster

A Vitest test that hashes each label template's JSON and its exact 1-bit print raster, so any change that would move a single printed dot fails the build and the deploy.

By

VitestSnapshot testingRegression testingCILabel printingTypeScript

The requirement: the physical label must not move

The RPOMS Print Engine prints router and box labels on a 50 × 30 mm thermal printer. The router label design was settled before the engine existed: it matched the labels already on the line. Every later change, a new template editor, a rewritten renderer, a second label module, came with the same instruction: the printed output must not change.

"Be careful" is not a control. A renderer is exactly the kind of code where a refactor that looks harmless, a rounding change, a font loaded differently, a default property added, moves a barcode one dot to the right. Nobody sees that in a code review. So the instruction became a test. This article describes it, for anyone who needs a pixel-exact guarantee on generated output, labels or otherwise.

What the test hashes

For every built-in template, the test renders the template with its own stored sample data through the real renderer, then records four things:

RecordedCatches
SHA-256 of the template JSON, minus createdAt and updatedAtAny edit to the design itself
SHA-256 of the print raster bytesAny change in what the printer would receive
Raster width and heightA geometry or printhead change, with a clearer message
Each element as id:type@x,y w×hA moved or resized element, readable in the failure diff

The raster hash is the one that matters. It is taken over the final 1-bit bitmap, the exact bytes that are split into row packets and sent over Bluetooth, not over a preview image. That is the only level at which "the label did not change" is a precise statement. How that raster is built from a millimetre template is described in rasterising labels for a 576-dot printhead.

The other entries exist for the person reading a failure. A bare hash mismatch says something changed. The element list says which element moved, and the JSON hash says whether the template or the renderer is responsible.

for (const t of BUILTIN_TEMPLATES) {
  const { createdAt: _c, updatedAt: _u, ...rest } = t;
  const r = await renderTemplate(t, t.sampleData ?? {}, { env });
  out[t.id] = {
    json: sha(JSON.stringify(rest)),
    raster: sha(r.bitmap.data),
    width: r.bitmap.width,
    height: r.bitmap.height,
    elements: t.elements.map((e) => `${e.id}:${e.type}@${e.x},${e.y} ${e.width}×${e.height}`),
  };
}
tsFrom tests/templateRegression.test.ts.

A second, much plainer test pins the router template's structure by name: exactly five elements, in order logo, rule, model, barcode and serial, with the model text bound to {{MODEL}} and both the barcode and the serial text bound to {{SERIAL}}. It is redundant with the hash, deliberately. If the baseline is ever regenerated carelessly, this one still fails on the change that matters most, a barcode that no longer encodes the serial.

Rendering in Node, with the same fonts

The test runs in Vitest under Node, not in a browser. The renderer only needs a canvas, so the test passes it an environment built on @napi-rs/canvas and registers the same Roboto font files the browser build bundles, so that text metrics match. That is possible because the renderer never touches the DOM directly: it receives its canvas factory and image loader as an injected environment.

Updating the baseline must be a deliberate act

The baseline is a JSON file of hashes committed in the repository. If it does not exist, the test writes it and passes. To accept a real design change, you delete the file and rerun. That is intentionally clumsy. It cannot happen as a side effect of updating snapshots in bulk, and the deleted-and-recreated file shows up in the diff.

Two further guards stop a baseline change from sliding through unnoticed:

  • CI runs git diff --exit-code on the baseline file and the templates/ folder after the test suite. If running the tests changed either, the job fails.
  • The deploy script runs the freeze test on its own before building the app. A server cannot be updated with a label change that the test rejects.

Adding features without moving the frozen labels

The freeze shaped how new renderer features were written. When a second label module needed text that wraps after a hyphen or at a space, wrapping became an opt-in property on text elements. Templates that do not set it take the old code path, so the four frozen templates render exactly as before and their hashes did not change. The new module also got its own template store and its own built-in template, outside the frozen list, and importing one of its templates into the router label store is refused.

That new label has its own test in a different style. It reads the QR code back out of the print raster and compares it, module by module, with the encoding of the exact input value. For a label whose content varies, checking that the output decodes to the right data is more useful than freezing one sample.

One bug the freeze test found in itself

On 27 September 2026 the freeze test failed on a Windows machine with no change to any template. The fixture path had been built with new URL(...).pathname, which on Windows yields something like /C:/Users/... with spaces encoded as %20. Node could not open that path, so the test could not find its own baseline. The same mistake was in the database migration runner. The fix was fileURLToPath in both places. The hashes were unchanged, and the test now passes on Windows, macOS and Linux.

It is a reminder that a guard test is code too. A freeze test that fails for the wrong reason teaches people to delete the baseline, which is the one thing it exists to prevent.