diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-02 21:56:01 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 589269c0b3ca74a29f5889fa6e781e59cef7efdb (patch) | |
| tree | ecd2ce3060d06353f3b6302e70abf784295ff40f /tests | |
| parent | 81992cc92743a98066ee1a9025bc74f91cf0e967 (diff) | |
| download | pardes-589269c0b3ca74a29f5889fa6e781e59cef7efdb.tar.gz pardes-589269c0b3ca74a29f5889fa6e781e59cef7efdb.zip | |
wasm replay ui
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/browser_replay_dump.zon | 53 | ||||
| -rw-r--r-- | tests/browser_replay_e2e.mjs | 240 |
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(); +} |
