mirror of
https://github.com/penpot/penpot.git
synced 2026-06-20 06:12:04 +00:00
wip visual tests
This commit is contained in:
parent
48cec4c83b
commit
eee6056c90
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>>,
|
||||
}
|
||||
|
||||
|
||||
33
render-wasm/visual-tests/README.md
Normal file
33
render-wasm/visual-tests/README.md
Normal 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.
|
||||
120
render-wasm/visual-tests/harness.mjs
Normal file
120
render-wasm/visual-tests/harness.mjs
Normal 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}`);
|
||||
}
|
||||
}
|
||||
17
render-wasm/visual-tests/render.test.mjs
Normal file
17
render-wasm/visual-tests/render.test.mjs
Normal 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));
|
||||
});
|
||||
19
render-wasm/visual-tests/scenes.mjs
Normal file
19
render-wasm/visual-tests/scenes.mjs
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user