summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-02 21:56:01 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit589269c0b3ca74a29f5889fa6e781e59cef7efdb (patch)
treeecd2ce3060d06353f3b6302e70abf784295ff40f /tests
parent81992cc92743a98066ee1a9025bc74f91cf0e967 (diff)
downloadpardes-589269c0b3ca74a29f5889fa6e781e59cef7efdb.tar.gz
pardes-589269c0b3ca74a29f5889fa6e781e59cef7efdb.zip
wasm replay ui
Diffstat (limited to 'tests')
-rw-r--r--tests/browser_replay_dump.zon53
-rw-r--r--tests/browser_replay_e2e.mjs240
2 files changed, 293 insertions, 0 deletions
diff --git a/tests/browser_replay_dump.zon b/tests/browser_replay_dump.zon
new file mode 100644
index 00000000..fbac9067
--- /dev/null
+++ b/tests/browser_replay_dump.zon
@@ -0,0 +1,53 @@
+.{
+ .screen = .{ .cols = 80, .rows = 24 },
+ .active = 0,
+ .topbar = "pardes replay browser e2e",
+ .theme = "dark",
+ .columns = .{
+ .{ .weight = 1, .panes = .{ 0, 1 } },
+ },
+ .panes = .{
+ .{
+ .kind = .file,
+ .tag = "nm browser_replay.zig Del",
+ .body =
+ \\const message = "browser replay shader path";
+ \\pub fn main() void {
+ \\ // This fixture should render in WebGL.
+ \\}
+ ,
+ .scroll = 0,
+ .cols = 80,
+ .rows = 12,
+ .vweight = 1,
+ .file = .{
+ .path = "browser_replay.zig",
+ .content =
+ \\const message = "browser replay shader path";
+ \\pub fn main() void {
+ \\ // This fixture should render in WebGL.
+ \\}
+ ,
+ },
+ },
+ .{
+ .kind = .terminal,
+ .tag = "sh /tmp Del",
+ .body =
+ \\$ zig build replay-wasm
+ \\browser e2e marker 4200
+ ,
+ .scroll = 0,
+ .cols = 80,
+ .rows = 10,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp",
+ .stream =
+ \\$ zig build replay-wasm
+ \\browser e2e marker 4200
+ ,
+ },
+ },
+ },
+}
diff --git a/tests/browser_replay_e2e.mjs b/tests/browser_replay_e2e.mjs
new file mode 100644
index 00000000..33267bbb
--- /dev/null
+++ b/tests/browser_replay_e2e.mjs
@@ -0,0 +1,240 @@
+import { createServer } from "node:http";
+import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
+import { spawn } from "node:child_process";
+import { createRequire } from "node:module";
+import { tmpdir } from "node:os";
+import { resolve, join, extname } from "node:path";
+import { inflateSync } from "node:zlib";
+
+const webDir = resolve(process.argv[2] ?? "zig-out/web");
+const screenshotPath = resolve(process.argv[3] ?? "zig-out/browser-replay.png");
+
+const mime = new Map([
+ [".html", "text/html; charset=utf-8"],
+ [".js", "text/javascript; charset=utf-8"],
+ [".wasm", "application/wasm"],
+ [".png", "image/png"],
+]);
+
+function run(cmd, args) {
+ return new Promise((resolvePromise, reject) => {
+ const child = spawn(cmd, args, { stdio: "inherit" });
+ child.on("error", reject);
+ child.on("exit", (code) => {
+ if (code === 0) resolvePromise();
+ else reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`));
+ });
+ });
+}
+
+async function loadPlaywright() {
+ try {
+ return { api: await import("playwright"), cleanup: async () => {} };
+ } catch (err) {
+ if (process.env.PARDES_BROWSER_SKIP_INSTALL === "1") throw err;
+ }
+
+ const dir = await mkdtemp(join(tmpdir(), "pardes-playwright-"));
+ try {
+ await run("npm", ["--prefix", dir, "install", "--silent", "playwright"]);
+ await run("npm", ["--prefix", dir, "exec", "--", "playwright", "install", "chromium"]);
+ const require = createRequire(join(dir, "package.json"));
+ return {
+ api: require("playwright"),
+ cleanup: async () => {
+ await rm(dir, { recursive: true, force: true });
+ },
+ };
+ } catch (err) {
+ await rm(dir, { recursive: true, force: true });
+ throw err;
+ }
+}
+
+async function serve(root) {
+ await stat(join(root, "replay.html"));
+ await stat(join(root, "pardes-replay.js"));
+
+ const server = createServer(async (req, res) => {
+ try {
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
+ const path = url.pathname === "/" ? "/replay.html" : url.pathname;
+ if (path.includes("..")) throw new Error("bad path");
+ const file = join(root, path.slice(1));
+ const body = await readFile(file);
+ res.writeHead(200, {
+ "content-type": mime.get(extname(file)) ?? "application/octet-stream",
+ "cache-control": "no-store",
+ });
+ res.end(body);
+ } catch {
+ res.writeHead(404);
+ res.end("not found");
+ }
+ });
+
+ await new Promise((resolvePromise) => server.listen(0, "127.0.0.1", resolvePromise));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("failed to bind test server");
+ return { server, url: `http://127.0.0.1:${address.port}/replay.html` };
+}
+
+function parsePng(buf) {
+ const sig = "89504e470d0a1a0a";
+ if (buf.subarray(0, 8).toString("hex") !== sig) throw new Error("not a png");
+
+ let offset = 8;
+ let width = 0;
+ let height = 0;
+ let bitDepth = 0;
+ let colorType = 0;
+ const idat = [];
+ while (offset < buf.length) {
+ const len = buf.readUInt32BE(offset);
+ const type = buf.subarray(offset + 4, offset + 8).toString("ascii");
+ const data = buf.subarray(offset + 8, offset + 8 + len);
+ offset += 12 + len;
+ if (type === "IHDR") {
+ width = data.readUInt32BE(0);
+ height = data.readUInt32BE(4);
+ bitDepth = data[8];
+ colorType = data[9];
+ } else if (type === "IDAT") {
+ idat.push(data);
+ } else if (type === "IEND") {
+ break;
+ }
+ }
+ if (bitDepth !== 8) throw new Error(`unsupported png bit depth ${bitDepth}`);
+ const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : 0;
+ if (channels === 0) throw new Error(`unsupported png color type ${colorType}`);
+
+ const raw = inflateSync(Buffer.concat(idat));
+ const stride = width * channels;
+ const rows = Buffer.alloc(width * height * channels);
+ let rawOffset = 0;
+ for (let y = 0; y < height; y += 1) {
+ const filter = raw[rawOffset];
+ rawOffset += 1;
+ const rowStart = y * stride;
+ for (let x = 0; x < stride; x += 1) {
+ const left = x >= channels ? rows[rowStart + x - channels] : 0;
+ const up = y > 0 ? rows[rowStart + x - stride] : 0;
+ const upLeft = y > 0 && x >= channels ? rows[rowStart + x - stride - channels] : 0;
+ const value = raw[rawOffset + x];
+ let recon;
+ if (filter === 0) recon = value;
+ else if (filter === 1) recon = value + left;
+ else if (filter === 2) recon = value + up;
+ else if (filter === 3) recon = value + Math.floor((left + up) / 2);
+ else if (filter === 4) {
+ const p = left + up - upLeft;
+ const pa = Math.abs(p - left);
+ const pb = Math.abs(p - up);
+ const pc = Math.abs(p - upLeft);
+ recon = value + (pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft);
+ } else {
+ throw new Error(`unsupported png filter ${filter}`);
+ }
+ rows[rowStart + x] = recon & 0xff;
+ }
+ rawOffset += stride;
+ }
+ return { width, height, channels, rows };
+}
+
+function validateScreenshot(png) {
+ let bright = 0;
+ let colored = 0;
+ let nonDark = 0;
+ const pixels = png.width * png.height;
+ for (let i = 0; i < pixels; i += 1) {
+ const off = i * png.channels;
+ const r = png.rows[off];
+ const g = png.channels === 1 ? r : png.rows[off + 1];
+ const b = png.channels === 1 ? r : png.rows[off + 2];
+ if (r > 90 || g > 90 || b > 90) bright += 1;
+ if (Math.max(r, g, b) - Math.min(r, g, b) > 20) colored += 1;
+ if (r > 35 || g > 35 || b > 35) nonDark += 1;
+ }
+ if (png.width < 640 || png.height < 360) {
+ throw new Error(`screenshot too small: ${png.width}x${png.height}`);
+ }
+ if (bright < 500 || nonDark < 2000) {
+ throw new Error(`browser replay appears blank: bright=${bright} nonDark=${nonDark} colored=${colored}`);
+ }
+ console.log(`browser replay screenshot ok: ${png.width}x${png.height}, bright=${bright}, nonDark=${nonDark}, colored=${colored}`);
+}
+
+const { server, url } = await serve(webDir);
+let cleanupPlaywright = async () => {};
+let browser;
+try {
+ const { api, cleanup } = await loadPlaywright();
+ cleanupPlaywright = cleanup;
+ const { chromium } = api;
+
+ browser = await chromium.launch({
+ headless: true,
+ args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"],
+ });
+ const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
+ const consoleMessages = [];
+ const pageErrors = [];
+ page.on("console", (msg) => consoleMessages.push(`${msg.type()}: ${msg.text()}`));
+ page.on("pageerror", (err) => pageErrors.push(err.stack ?? err.message));
+
+ await page.addInitScript(() => {
+ window.__pardesReplayE2E = { draws: 0, contextLost: 0 };
+ window.addEventListener("webglcontextlost", () => {
+ window.__pardesReplayE2E.contextLost += 1;
+ }, true);
+
+ const wrapDraws = (target) => {
+ if (!target) return;
+ for (const name of ["drawArraysInstanced", "drawArrays", "drawElements", "drawElementsInstanced"]) {
+ if (typeof target[name] !== "function" || target[name].__pardesReplayE2E) continue;
+ const oldDraw = target[name];
+ target[name] = function(...args) {
+ window.__pardesReplayE2E.draws += 1;
+ return oldDraw.apply(this, args);
+ };
+ target[name].__pardesReplayE2E = true;
+ }
+ };
+
+ wrapDraws(globalThis.WebGLRenderingContext?.prototype);
+ wrapDraws(globalThis.WebGL2RenderingContext?.prototype);
+
+ const oldGetContext = HTMLCanvasElement.prototype.getContext;
+ HTMLCanvasElement.prototype.getContext = function(type, attrs) {
+ const ctx = oldGetContext.call(this, type, attrs);
+ if (ctx && !ctx.__pardesReplayE2E && (type === "webgl2" || type === "webgl" || type === "experimental-webgl")) {
+ ctx.__pardesReplayE2E = true;
+ wrapDraws(ctx);
+ }
+ return ctx;
+ };
+ });
+
+ await page.goto(url, { waitUntil: "load" });
+ await page.waitForSelector("canvas", { timeout: 5000 });
+ await page.waitForFunction(() => window.__pardesReplayE2E?.draws >= 2, null, { timeout: 5000 });
+ await page.waitForTimeout(250);
+ const trace = await page.evaluate(() => window.__pardesReplayE2E);
+ if (trace.contextLost !== 0) {
+ throw new Error(`browser WebGL context was lost during replay: ${JSON.stringify(trace)}`);
+ }
+ if (pageErrors.length !== 0) {
+ throw new Error(`browser page error: ${pageErrors[0]}`);
+ }
+ const lostMessage = consoleMessages.find((msg) => msg.includes("CONTEXT_LOST_WEBGL"));
+ if (lostMessage) throw new Error(`browser WebGL context lost: ${lostMessage}`);
+
+ await page.screenshot({ path: screenshotPath, fullPage: true });
+ validateScreenshot(parsePng(await readFile(screenshotPath)));
+} finally {
+ if (browser) await browser.close();
+ await cleanupPlaywright();
+ server.close();
+}