diff --git a/render-wasm/package.json b/render-wasm/package.json index ca03750588..50e68c2bbb 100644 --- a/render-wasm/package.json +++ b/render-wasm/package.json @@ -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" } } diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs index 16f3d1a0a8..1d9cd450a4 100644 --- a/render-wasm/src/render/images.rs +++ b/render-wasm/src/render/images.rs @@ -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>, } diff --git a/render-wasm/visual-tests/README.md b/render-wasm/visual-tests/README.md new file mode 100644 index 0000000000..52e141a8a2 --- /dev/null +++ b/render-wasm/visual-tests/README.md @@ -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__/.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. diff --git a/render-wasm/visual-tests/harness.mjs b/render-wasm/visual-tests/harness.mjs new file mode 100644 index 0000000000..952b120397 --- /dev/null +++ b/render-wasm/visual-tests/harness.mjs @@ -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 `_` 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__/.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}`); + } +} diff --git a/render-wasm/visual-tests/render.test.mjs b/render-wasm/visual-tests/render.test.mjs new file mode 100644 index 0000000000..63bfe3f808 --- /dev/null +++ b/render-wasm/visual-tests/render.test.mjs @@ -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)); +}); diff --git a/render-wasm/visual-tests/scenes.mjs b/render-wasm/visual-tests/scenes.mjs new file mode 100644 index 0000000000..a3812f7573 --- /dev/null +++ b/render-wasm/visual-tests/scenes.mjs @@ -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; +}