wip visual tests

This commit is contained in:
Elena Torro 2026-06-05 11:48:55 +02:00
parent 48cec4c83b
commit eee6056c90
6 changed files with 197 additions and 2 deletions

View File

@ -10,8 +10,14 @@
"url": "https://github.com/penpot/penpot"
},
"type": "module",
"scripts": {
"test:headless": "node --test visual-tests/",
"test:headless:update": "UPDATE_SNAPSHOTS=1 node --test visual-tests/"
},
"devDependencies": {
"@types/node": "^20.12.7",
"esbuild": "^0.27.4"
"esbuild": "^0.27.4",
"pixelmatch": "^6.0.0",
"pngjs": "^7.0.0"
}
}

View File

@ -62,7 +62,7 @@ enum StoredImage {
pub struct ImageStore {
images: HashMap<(Uuid, bool), StoredImage>,
/// GPU context for decoding images to textures. `None` in headless mode,
/// where image fills are skipped (other shapes render normally).
/// where image fills are decoded on the CPU instead (see `get_cpu_image`).
context: Option<Box<DirectContext>>,
}

View File

@ -0,0 +1,33 @@
# Headless visual tests
Image-diff tests for the **headless** render path — the GPU-free Skia pipeline
(`init_headless` + `render_shape_raster`) that powers server-side export. They're
the Playwright-snapshot equivalent for code that runs with no browser/WebGL:
boot the built WASM in Node, render a scene to PNG, and compare against a
committed baseline with pixelmatch.
## Running
```bash
./build # produce frontend/resources/public/js/render-wasm.*
pnpm install # one-time, pulls pngjs + pixelmatch
pnpm test:headless # diff against baselines in __snapshots__/
pnpm test:headless:update # (re)generate baselines, then review + commit them
```
A failing case writes `__snapshots__/<name>.diff.png` highlighting the changed
pixels. Baselines are produced by CPU Skia, so they're deterministic across
machines.
## Scenes
Scenes are built through the WASM FFI in `scenes.mjs` (no `.penpot` file needed),
so the suite is self-contained on this branch. Rendering real files headless
needs the shape-tree serialization that lives in the exporter; once that's
exposed as a JS module it can feed `renderToPng` through this same harness.
- `harness.mjs` — load/boot the headless module, build fills, render to PNG,
snapshot compare.
- `scenes.mjs` — FFI scene builders.
- `*.test.mjs``node:test` cases.
- `__snapshots__/` — committed baseline PNGs.

View File

@ -0,0 +1,120 @@
// Image-diff harness for the headless render path. Loads the built render-wasm
// artifact in Node (no browser/WebGL), boots it with `init_headless`, renders a
// shape to PNG, and compares against a committed baseline with pixelmatch —
// the Playwright-snapshot equivalent for the headless renderer.
//
// Requires `./build` first (it loads frontend/resources/public/js/render-wasm.*,
// built with -sENVIRONMENT=web,node). Baselines live in ./__snapshots__; run
// with UPDATE_SNAPSHOTS=1 to (re)generate them.
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, resolve, join } from "node:path";
import assert from "node:assert/strict";
// pngjs/pixelmatch are loaded lazily (only when actually diffing), so loading +
// rendering can be exercised without the diff dependencies installed.
const here = dirname(fileURLToPath(import.meta.url));
const ARTIFACT_DIR = resolve(here, "../../frontend/resources/public/js");
const SNAPSHOT_DIR = resolve(here, "__snapshots__");
const UPDATE = !!process.env.UPDATE_SNAPSHOTS;
export const FILL_U8_SIZE = 160; // 4 + max(gradient 156, image 36, solid 4)
let modulePromise;
/// Loads + boots the headless module once (memoized). Returns `{ Module, call }`,
/// where `call(name, ...args)` invokes the `_<name>` export.
export function loadHeadlessModule(width = 800, height = 600) {
modulePromise ??= (async () => {
const wasmBytes = readFileSync(resolve(ARTIFACT_DIR, "render-wasm.wasm"));
const factory = (await import(pathToFileURL(resolve(ARTIFACT_DIR, "render-wasm.js")).href))
.default;
const Module = await factory({
instantiateWasm(imports, success) {
WebAssembly.instantiate(wasmBytes, imports).then(({ instance }) => success(instance));
return {};
},
locateFile: (p) => resolve(ARTIFACT_DIR, p),
printErr: () => {},
});
const call = (name, ...args) => {
const fn = Module["_" + name];
if (typeof fn !== "function") throw new Error(`export _${name} missing`);
return fn(...args);
};
call("init_headless", width, height);
return { Module, call };
})();
return modulePromise;
}
/// Writes `colorsARGB` (one solid fill each, ARGB) to the current shape.
export function setSolidFills({ Module, call }, colorsARGB) {
const size = 4 + colorsARGB.length * FILL_U8_SIZE;
const ptr = call("alloc_bytes", size);
const buf = Module.HEAPU8.subarray(ptr, ptr + size);
buf.fill(0);
buf[0] = colorsARGB.length; // num fills (bytes 1..4 are padding)
const dv = new DataView(buf.buffer, buf.byteOffset, size);
colorsARGB.forEach((argb, i) => {
const off = 4 + i * FILL_U8_SIZE;
dv.setUint8(off, 0x00); // Solid tag
dv.setUint32(off + 4, argb >>> 0, true); // ARGB, little-endian
});
call("set_shape_fills");
}
/// Renders the subtree rooted at `id` (a [u32, u32, u32, u32] quartet) to PNG
/// bytes via `render_shape_raster`.
export function renderToPng({ Module, call }, id, scale = 1) {
const [a, b, c, d] = id;
const ptr = call("render_shape_raster", a, b, c, d, scale);
const base = ptr >>> 2;
const len = Module.HEAPU32[base];
const png = Module.HEAPU8.slice(ptr + 12, ptr + 12 + len); // skip [len][w][h]
call("free_bytes");
return Buffer.from(png);
}
/// Compares `pngBytes` against ./__snapshots__/<name>.png. Creates the baseline
/// when missing or when UPDATE_SNAPSHOTS=1; otherwise fails (and writes a diff
/// PNG) when more than `maxDiffPixels` differ.
export async function expectMatchesSnapshot(
name,
pngBytes,
{ maxDiffPixels = 0, threshold = 0.1 } = {},
) {
const baseline = join(SNAPSHOT_DIR, `${name}.png`);
if (UPDATE || !existsSync(baseline)) {
mkdirSync(SNAPSHOT_DIR, { recursive: true });
writeFileSync(baseline, pngBytes);
return;
}
const { PNG } = await import("pngjs");
const { default: pixelmatch } = await import("pixelmatch");
const actual = PNG.sync.read(pngBytes);
const expected = PNG.sync.read(readFileSync(baseline));
assert.equal(
`${actual.width}x${actual.height}`,
`${expected.width}x${expected.height}`,
`${name}: dimensions differ from baseline`,
);
const diff = new PNG({ width: actual.width, height: actual.height });
const n = pixelmatch(actual.data, expected.data, diff.data, actual.width, actual.height, {
threshold,
});
if (n > maxDiffPixels) {
const diffPath = join(SNAPSHOT_DIR, `${name}.diff.png`);
writeFileSync(diffPath, PNG.sync.write(diff));
assert.fail(`${name}: ${n} differing pixels (> ${maxDiffPixels}); diff written to ${diffPath}`);
}
}

View File

@ -0,0 +1,17 @@
import { test } from "node:test";
import { loadHeadlessModule, renderToPng, expectMatchesSnapshot } from "./harness.mjs";
import { solidRect } from "./scenes.mjs";
test("solid red rect", async () => {
const mod = await loadHeadlessModule();
const id = solidRect(mod, { width: 120, height: 80, fills: [0xffff0000] });
await expectMatchesSnapshot("solid-red-rect", renderToPng(mod, id, 1));
});
test("stacked translucent fills", async () => {
const mod = await loadHeadlessModule();
// fills[0] draws on top: 50% blue over opaque red → blended purple
const id = solidRect(mod, { width: 120, height: 80, fills: [0x800000ff, 0xffff0000] });
await expectMatchesSnapshot("stacked-fills", renderToPng(mod, id, 2));
});

View File

@ -0,0 +1,19 @@
// Scene builders for the headless image-diff tests, constructed through the
// WASM FFI (no .penpot file needed). Real files require the shape-tree
// serialization that lives in the exporter; once that's available as a JS
// module it can build scenes for this same harness.
import { setSolidFills } from "./harness.mjs";
const ROOT = [1, 2, 3, 4]; // any fixed non-nil uuid; the pool is reset per scene
/// A single rectangle with one or more stacked solid fills (ARGB). `fills[0]`
/// draws on top, matching Penpot's fill order.
export function solidRect(mod, { width, height, fills }) {
const { call } = mod;
call("init_shapes_pool", 1);
call("use_shape", ...ROOT);
call("set_shape_selrect", 0, 0, width, height);
setSolidFills(mod, fills);
return ROOT;
}