summaryrefslogtreecommitdiff
path: root/test/web_snapshot.mjs
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-10 17:32:43 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit685b8c8e74c9edbdf701594f50ff1b70ac288ec9 (patch)
treef303bd5e1f422030c2c645ea2d6c3a393990fb5e /test/web_snapshot.mjs
parentddc2eefb1e7cdd402593efb1297c25fc7ae4b110 (diff)
downloadpardes-685b8c8e74c9edbdf701594f50ff1b70ac288ec9.tar.gz
pardes-685b8c8e74c9edbdf701594f50ff1b70ac288ec9.zip
web snapshots: add switch for saving/loading exports, update web version, web snapshot tooling
Diffstat (limited to 'test/web_snapshot.mjs')
-rw-r--r--test/web_snapshot.mjs471
1 files changed, 471 insertions, 0 deletions
diff --git a/test/web_snapshot.mjs b/test/web_snapshot.mjs
new file mode 100644
index 00000000..7c0c7462
--- /dev/null
+++ b/test/web_snapshot.mjs
@@ -0,0 +1,471 @@
+#!/usr/bin/env node
+// Drive the Emscripten build in a real headless Chromium using .snap scripts.
+// Shared commands retain test/snapshot.zig's spelling and output format:
+// wait <ms> <text> | stable <quiet-ms> <timeout-ms>
+// snap <label> | snapstyle <label>
+// Browser touch additions:
+// touch <down|move|up> <id> <col> <row>
+// swipe <id> <from-col> <from-row> <to-col> <to-row> <steps>
+//
+// No npm package is needed: Node's WebSocket connects directly to Chrome's
+// DevTools protocol. Set PARDES_CHROME when Chromium is not on PATH or in the
+// normal Playwright cache.
+import childProcess from "node:child_process";
+import fs from "node:fs";
+import fsp from "node:fs/promises";
+import http from "node:http";
+import net from "node:net";
+import os from "node:os";
+import path from "node:path";
+
+const VIEWPORT = { width: 1120, height: 720 };
+const args = process.argv.slice(2);
+const update = args.includes("--update");
+const positional = args.filter((arg) => arg !== "--update");
+if (positional.length !== 2) {
+ console.error("usage: node test/web_snapshot.mjs <zig-out/web> <script.snap> [--update]");
+ process.exit(2);
+}
+
+const webDir = path.resolve(positional[0]);
+const scriptPath = path.resolve(positional[1]);
+const goldenPath = scriptPath.replace(/\.snap$/, ".golden");
+if (goldenPath === scriptPath) throw new Error(`not a .snap path: ${scriptPath}`);
+
+let server;
+let chrome;
+let client;
+let userDataDir;
+let chromeStderr = "";
+const browserLog = [];
+
+async function main() {
+ try {
+ const served = await serveDirectory(webDir);
+ server = served.server;
+ const debugPort = await unusedPort();
+ const chromePath = await findChrome();
+ userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pardes-web-snap-"));
+ chrome = childProcess.spawn(chromePath, [
+ "--headless=new",
+ "--no-sandbox",
+ "--disable-dev-shm-usage",
+ "--enable-unsafe-swiftshader",
+ "--use-angle=swiftshader",
+ "--no-first-run",
+ "--no-default-browser-check",
+ `--remote-debugging-address=127.0.0.1`,
+ `--remote-debugging-port=${debugPort}`,
+ `--user-data-dir=${userDataDir}`,
+ "about:blank",
+ ], { stdio: ["ignore", "ignore", "pipe"] });
+ chrome.stderr.setEncoding("utf8");
+ chrome.stderr.on("data", (chunk) => {
+ chromeStderr = (chromeStderr + chunk).slice(-16_384);
+ });
+
+ await waitForDevTools(debugPort, chrome);
+ const targetResponse = await fetch(
+ `http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(`http://127.0.0.1:${served.port}/index.html`)}`,
+ { method: "PUT" },
+ );
+ if (!targetResponse.ok) throw new Error(`create Chrome target: HTTP ${targetResponse.status}`);
+ const target = await targetResponse.json();
+ client = await Cdp.connect(target.webSocketDebuggerUrl, browserLog);
+ await client.send("Runtime.enable");
+ await client.send("Page.enable");
+ await client.send("Log.enable");
+ await client.send("Emulation.setDeviceMetricsOverride", {
+ ...VIEWPORT,
+ screenWidth: VIEWPORT.width,
+ screenHeight: VIEWPORT.height,
+ deviceScaleFactor: 1,
+ mobile: false,
+ });
+ await client.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
+ await waitForRuntime(20_000);
+
+ const output = await runScript(await fsp.readFile(scriptPath, "utf8"));
+ if (update) {
+ await fsp.writeFile(goldenPath, output);
+ console.error(`updated ${path.relative(process.cwd(), goldenPath)}`);
+ } else {
+ const golden = await fsp.readFile(goldenPath, "utf8").catch(() => null);
+ if (golden === null) throw new Error(`missing ${goldenPath}; run with --update`);
+ if (golden !== output) {
+ const actualPath = goldenPath.replace(/\.golden$/, ".actual");
+ await fsp.writeFile(actualPath, output);
+ throw new Error(`snapshot mismatch; actual written to ${actualPath}`);
+ }
+ console.error(`web snapshot ok: ${path.relative(process.cwd(), scriptPath)}`);
+ }
+ } catch (error) {
+ console.error(error?.stack ?? error);
+ if (browserLog.length) console.error(`browser log:\n${browserLog.join("\n")}`);
+ if (chromeStderr.trim()) console.error(`chromium stderr (tail):\n${chromeStderr.trim()}`);
+ process.exitCode = 1;
+ } finally {
+ client?.close();
+ if (chrome && chrome.exitCode === null) {
+ chrome.kill("SIGTERM");
+ await Promise.race([
+ new Promise((resolve) => chrome.once("exit", resolve)),
+ delay(2_000),
+ ]);
+ }
+ if (server) await new Promise((resolve) => server.close(resolve));
+ if (userDataDir) await fsp.rm(userDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
+ }
+}
+
+async function runScript(source) {
+ let output = "";
+ let activeTouch = null;
+ const lines = source.split("\n");
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index].trim();
+ if (!line || line.startsWith("#")) continue;
+ const tokens = line.split(/\s+/);
+ const command = tokens.shift();
+ try {
+ if (command === "wait") {
+ const timeout = integer(tokens.shift(), "wait timeout");
+ const needle = tokens.join(" ");
+ if (!needle) throw new Error("wait needs text");
+ await waitUntil(async () => (await grid()).text.includes(needle), timeout, `screen containing ${JSON.stringify(needle)}`);
+ } else if (command === "stable") {
+ const quiet = integer(tokens.shift(), "stable quiet time");
+ const timeout = integer(tokens.shift(), "stable timeout");
+ await waitStable(quiet, timeout);
+ } else if (command === "snap") {
+ const label = tokens.join(" ");
+ if (!label) throw new Error("snap needs a label");
+ output += formatSnapshot(label, await grid());
+ } else if (command === "snapstyle") {
+ const label = tokens.join(" ");
+ if (!label) throw new Error("snapstyle needs a label");
+ output += formatStyleSnapshot(label, await grid());
+ } else if (command === "touch") {
+ const phase = tokens.shift();
+ const id = integer(tokens.shift(), "touch id");
+ const col = number(tokens.shift(), "touch col");
+ const row = number(tokens.shift(), "touch row");
+ if (tokens.length || !["down", "move", "up"].includes(phase)) throw new Error("bad touch command");
+ activeTouch = await dispatchTouch(phase, id, col, row, activeTouch);
+ await delay(40);
+ } else if (command === "swipe") {
+ const id = integer(tokens.shift(), "swipe id");
+ const x0 = number(tokens.shift(), "swipe start col");
+ const y0 = number(tokens.shift(), "swipe start row");
+ const x1 = number(tokens.shift(), "swipe end col");
+ const y1 = number(tokens.shift(), "swipe end row");
+ const steps = integer(tokens.shift(), "swipe steps");
+ if (tokens.length || steps < 1) throw new Error("bad swipe command");
+ activeTouch = await dispatchTouch("down", id, x0, y0, activeTouch);
+ for (let step = 1; step <= steps; step += 1) {
+ const t = step / steps;
+ activeTouch = await dispatchTouch("move", id, x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, activeTouch);
+ await delay(20);
+ }
+ activeTouch = await dispatchTouch("up", id, x1, y1, activeTouch);
+ await delay(80);
+ } else {
+ throw new Error(`unknown command ${command}`);
+ }
+ } catch (error) {
+ const screen = await grid().catch(() => null);
+ const detail = screen ? `\ncurrent screen:\n${screen.lines.map((row) => `|${row}`).join("\n")}` : "";
+ throw new Error(`${path.basename(scriptPath)}:${index + 1}: ${line}\n${error.message}${detail}`, { cause: error });
+ }
+ }
+ if (activeTouch) throw new Error("script ended with a finger still down");
+ return output;
+}
+
+async function dispatchTouch(phase, id, col, row, active) {
+ const point = await evaluate(`({
+ x: Module._pardes_web_test_cell_x(${JSON.stringify(col)}),
+ y: Module._pardes_web_test_cell_y(${JSON.stringify(row)})
+ })`);
+ const touch = { x: point.x, y: point.y, id, radiusX: 1, radiusY: 1, force: 1 };
+ if (phase === "down") {
+ if (active) throw new Error("only one finger is supported by this web test");
+ await client.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [touch] });
+ return touch;
+ }
+ if (!active || active.id !== id) throw new Error(`finger ${id} is not down`);
+ if (phase === "move") {
+ await client.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [touch] });
+ return touch;
+ }
+ // Chrome's touchEnd carries no points; a final move gives SDL the scripted
+ // release coordinate before the lift.
+ await client.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [touch] });
+ await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
+ return null;
+}
+
+async function grid() {
+ return evaluate(`(() => {
+ const cols = Module._pardes_web_test_cols();
+ const rows = Module._pardes_web_test_rows();
+ const lines = [];
+ const styleRows = [];
+ for (let y = 0; y < rows; y++) {
+ let line = "";
+ const styles = [];
+ for (let x = 0; x < cols; x++) {
+ const cp = Module._pardes_web_test_cell(x, y) || 32;
+ line += String.fromCodePoint(cp);
+ styles.push([
+ Module._pardes_web_test_cell_fg(x, y),
+ Module._pardes_web_test_cell_bg(x, y),
+ Module._pardes_web_test_cell_attrs(x, y),
+ ]);
+ }
+ lines.push(line.replace(/ +$/, ""));
+ styleRows.push(styles);
+ }
+ return {
+ cols, rows, lines, styleRows,
+ cursorX: Module._pardes_web_test_cursor_x(),
+ cursorY: Module._pardes_web_test_cursor_y(),
+ text: lines.join("\\n"),
+ };
+ })()`);
+}
+
+function formatSnapshot(label, state) {
+ const cursorX = state.cursorX < 0 ? 0 : state.cursorX;
+ const cursorY = state.cursorY < 0 ? 0 : state.cursorY;
+ let out = `== snap ${label} grid=${state.cols}x${state.rows} cursor=${cursorX},${cursorY}\n`;
+ for (const line of state.lines) out += `|${line}\n`;
+ return out;
+}
+
+function formatStyleSnapshot(label, state) {
+ let out = `== style ${label}\n`;
+ for (let y = 0; y < state.rows; y++) {
+ const styles = state.styleRows[y];
+ out += `|${y}:`;
+ let runStart = 0;
+ let runKey = styleKey(styles[0]);
+ for (let x = 1; x <= state.cols; x++) {
+ const key = x === state.cols ? null : styleKey(styles[x]);
+ if (key === runKey) continue;
+ out += ` ${runStart}-${x - 1} ${runKey}`;
+ runStart = x;
+ runKey = key;
+ }
+ out += "\n";
+ }
+ return out;
+}
+
+function styleKey([fg, bg, attrs]) {
+ return `${colorKey(fg)},${colorKey(bg)},${attrsKey(attrs)}`;
+}
+
+function colorKey(color) {
+ if (color === 0x01000000) return "d";
+ if ((color & 0xff000000) === 0x02000000) return `p${color & 0xff}`;
+ return `#${(color & 0xffffff).toString(16).padStart(6, "0")}`;
+}
+
+function attrsKey(attrs) {
+ let out = "";
+ if (attrs & (1 << 0)) out += "b";
+ if (attrs & (1 << 1)) out += "f";
+ if (attrs & (1 << 2)) out += "i";
+ if (attrs & (1 << 3)) out += "k";
+ if (attrs & (1 << 4)) out += "r";
+ if (attrs & (1 << 5)) out += "v";
+ if (attrs & (1 << 6)) out += "x";
+ out += ["", "u", "U", "~", ".", "-"][(attrs >> 8) & 0x7] ?? "?";
+ return out;
+}
+
+async function waitStable(quiet, timeout) {
+ let elapsed = 0;
+ let previous = JSON.stringify(await grid());
+ while (elapsed < timeout) {
+ await delay(quiet);
+ elapsed += quiet;
+ const current = JSON.stringify(await grid());
+ if (current === previous) return;
+ previous = current;
+ }
+ throw new Error(`screen did not stabilize within ${timeout}ms`);
+}
+
+async function waitForRuntime(timeout) {
+ await waitUntil(async () => {
+ try {
+ return await evaluate(`typeof Module?._pardes_web_test_cols === "function" && Module._pardes_web_test_cols() > 0`);
+ } catch {
+ return false;
+ }
+ }, timeout, "Pardes web runtime");
+}
+
+async function waitUntil(predicate, timeout, description) {
+ const deadline = Date.now() + timeout;
+ while (Date.now() < deadline) {
+ if (await predicate()) return;
+ if (chrome?.exitCode !== null) throw new Error(`Chromium exited with ${chrome.exitCode}`);
+ await delay(50);
+ }
+ throw new Error(`timed out waiting for ${description}`);
+}
+
+async function evaluate(expression) {
+ const response = await client.send("Runtime.evaluate", {
+ expression,
+ returnByValue: true,
+ awaitPromise: true,
+ });
+ if (response.exceptionDetails) throw new Error(response.exceptionDetails.text);
+ return response.result.value;
+}
+
+class Cdp {
+ static async connect(url, log) {
+ const socket = new WebSocket(url);
+ await new Promise((resolve, reject) => {
+ socket.addEventListener("open", resolve, { once: true });
+ socket.addEventListener("error", () => reject(new Error(`connect DevTools websocket ${url}`)), { once: true });
+ });
+ return new Cdp(socket, log);
+ }
+
+ constructor(socket, log) {
+ this.socket = socket;
+ this.log = log;
+ this.nextId = 1;
+ this.pending = new Map();
+ socket.addEventListener("message", (event) => {
+ const message = JSON.parse(event.data);
+ if (message.id) {
+ const pending = this.pending.get(message.id);
+ if (!pending) return;
+ this.pending.delete(message.id);
+ if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`));
+ else pending.resolve(message.result ?? {});
+ return;
+ }
+ if (message.method === "Runtime.exceptionThrown")
+ log.push(`exception: ${message.params.exceptionDetails.text}`);
+ else if (message.method === "Runtime.consoleAPICalled")
+ log.push(`console ${message.params.type}: ${message.params.args.map((arg) => arg.value ?? arg.description).join(" ")}`);
+ else if (message.method === "Log.entryAdded")
+ log.push(`${message.params.entry.level}: ${message.params.entry.text}`);
+ });
+ }
+
+ send(method, params = {}) {
+ const id = this.nextId++;
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject, method });
+ this.socket.send(JSON.stringify({ id, method, params }));
+ });
+ }
+
+ close() {
+ this.socket.close();
+ }
+}
+
+async function serveDirectory(root) {
+ const canonicalRoot = await fsp.realpath(root);
+ const server = http.createServer(async (request, response) => {
+ try {
+ const url = new URL(request.url, "http://localhost");
+ const relative = decodeURIComponent(url.pathname === "/" ? "/index.html" : url.pathname);
+ const filename = path.resolve(canonicalRoot, `.${relative}`);
+ if (filename !== canonicalRoot && !filename.startsWith(`${canonicalRoot}${path.sep}`)) {
+ response.writeHead(403).end();
+ return;
+ }
+ const contents = await fsp.readFile(filename);
+ const contentType = filename.endsWith(".wasm") ? "application/wasm"
+ : filename.endsWith(".js") ? "text/javascript; charset=utf-8"
+ : "text/html; charset=utf-8";
+ response.writeHead(200, { "content-type": contentType, "cache-control": "no-store" });
+ response.end(contents);
+ } catch {
+ response.writeHead(404).end();
+ }
+ });
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", resolve);
+ });
+ return { server, port: server.address().port };
+}
+
+async function unusedPort() {
+ const probe = net.createServer();
+ await new Promise((resolve, reject) => {
+ probe.once("error", reject);
+ probe.listen(0, "127.0.0.1", resolve);
+ });
+ const port = probe.address().port;
+ await new Promise((resolve) => probe.close(resolve));
+ return port;
+}
+
+async function waitForDevTools(port, child) {
+ const deadline = Date.now() + 10_000;
+ while (Date.now() < deadline) {
+ if (child.exitCode !== null) throw new Error(`Chromium exited with ${child.exitCode}`);
+ try {
+ const response = await fetch(`http://127.0.0.1:${port}/json/version`);
+ if (response.ok) return;
+ } catch {}
+ await delay(50);
+ }
+ throw new Error("Chromium DevTools endpoint did not start");
+}
+
+async function findChrome() {
+ const candidates = [];
+ if (process.env.PARDES_CHROME) candidates.push(process.env.PARDES_CHROME);
+ for (const command of ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable"]) {
+ const found = childProcess.spawnSync("which", [command], { encoding: "utf8" });
+ if (found.status === 0 && found.stdout.trim()) candidates.push(found.stdout.trim());
+ }
+ const cache = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), ".cache", "ms-playwright");
+ for (const entry of await fsp.readdir(cache).catch(() => [])) {
+ if (entry.startsWith("chromium-")) {
+ candidates.push(path.join(cache, entry, "chrome-linux64", "chrome"));
+ candidates.push(path.join(cache, entry, "chrome-linux", "chrome"));
+ } else if (entry.startsWith("chromium_headless_shell-")) {
+ candidates.push(path.join(cache, entry, "chrome-headless-shell-linux64", "chrome-headless-shell"));
+ }
+ }
+ for (const candidate of candidates) {
+ try {
+ await fsp.access(candidate, fs.constants.X_OK);
+ return candidate;
+ } catch {}
+ }
+ throw new Error("no Chromium found; set PARDES_CHROME=/path/to/chrome");
+}
+
+function integer(value, label) {
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isSafeInteger(parsed) || String(parsed) !== value) throw new Error(`bad ${label}: ${value}`);
+ return parsed;
+}
+
+function number(value, label) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) throw new Error(`bad ${label}: ${value}`);
+ return parsed;
+}
+
+function delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+await main();