diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-10 17:32:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 685b8c8e74c9edbdf701594f50ff1b70ac288ec9 (patch) | |
| tree | f303bd5e1f422030c2c645ea2d6c3a393990fb5e /test | |
| parent | ddc2eefb1e7cdd402593efb1297c25fc7ae4b110 (diff) | |
| download | pardes-685b8c8e74c9edbdf701594f50ff1b70ac288ec9.tar.gz pardes-685b8c8e74c9edbdf701594f50ff1b70ac288ec9.zip | |
web snapshots: add switch for saving/loading exports, update web version, web snapshot tooling
Diffstat (limited to 'test')
| -rw-r--r-- | test/web-snapshots/README.md | 18 | ||||
| -rw-r--r-- | test/web-snapshots/source-list.dump.zon | 26 | ||||
| -rw-r--r-- | test/web-snapshots/source-list.golden | 190 | ||||
| -rw-r--r-- | test/web-snapshots/source-list.snap | 29 | ||||
| -rw-r--r-- | test/web_snapshot.mjs | 471 |
5 files changed, 734 insertions, 0 deletions
diff --git a/test/web-snapshots/README.md b/test/web-snapshots/README.md new file mode 100644 index 00000000..d2ea84f8 --- /dev/null +++ b/test/web-snapshots/README.md @@ -0,0 +1,18 @@ +# Web browser snapshots + +`source-list.dump.zon` is a byte-for-byte dump from a running Pardes instance. +It is not assembled or normalized by a fixture generator. + +To recapture it: + +1. Build and launch the native TTY in this checkout with `PARDES_DUMP` pointing + at a temporary file. +2. Enter TTY mode, run `clear`, then run `git ls-files '*.zig'`. +3. Leave the pane in TTY mode and middle-click Pardes's `Dump` builtin. +4. Copy the resulting dump into `source-list.dump.zon` without editing it. + +The visible `.body` must exactly match the command plus every path reported by +Git (and the final prompt). The base64 PTY stream must contain the command +itself. `source-list.snap` then drives that dump through Chromium: it checks the +full listing, taps `build.zig`, snapshots tree-sitter style runs, resizes the +pane separator, and finally verifies body scrolling. diff --git a/test/web-snapshots/source-list.dump.zon b/test/web-snapshots/source-list.dump.zon new file mode 100644 index 00000000..02e1eb43 --- /dev/null +++ b/test/web-snapshots/source-list.dump.zon @@ -0,0 +1,26 @@ +.{ + .magic = "pardes-dump", + .version = 1, + .screen = .{ .cols = 100, .rows = 30 }, + .active = 0, + .topbar = "Kill Newcol Tutor Debug Colors NextColor Crt Dump", + .theme = "dark", + .columns = .{.{ .weight = 1, .panes = .{0} }}, + .panes = .{.{ + .kind = .terminal, + .tag = "TTY /home/goblin/00-projects/0x4200.cafe/02-pardes-code Del", + .body = "$ git ls-files '*.zig'\nbuild.zig\nsrc/crt.zig\nsrc/deck.zig\nsrc/dump.zig\nsrc/gui.zig\nsrc/image.zig\nsrc/look.zig\nsrc/main.zig\nsrc/modal.zig\nsrc/pardes.zig\nsrc/petscii.zig\nsrc/syntax.zig\nsrc/tracy.zig\nsrc/tty.zig\ntest/deck/replay.zig\ntest/deck/schema.zig\ntest/e2e_harness.zig\ntest/hxdiff.zig\ntest/snapshot.zig\nuucode_config.zig\n$ \n\n\n\n\n\n", + .scroll = 0, + .cols = 98, + .rows = 28, + .vweight = 1, + .terminal = .{ + .cwd = "/home/goblin/00-projects/0x4200.cafe/02-pardes-code", + .stream = "$ git ls-files '*.zig'\nbuild.zig\nsrc/crt.zig\nsrc/deck.zig\nsrc/dump.zig\nsrc/gui.zig\nsrc/image.zig\nsrc/look.zig\nsrc/main.zig\nsrc/modal.zig\nsrc/pardes.zig\nsrc/petscii.zig\nsrc/syntax.zig\nsrc/tracy.zig\nsrc/tty.zig\ntest/deck/replay.zig\ntest/deck/schema.zig\ntest/e2e_harness.zig\ntest/hxdiff.zig\ntest/snapshot.zig\nuucode_config.zig\n$ ", + .stream_b64 = "G10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L2hvbWUvZ29ibGluLzAwLXByb2plY3RzLzB4NDIwMC5jYWZlLzAyLXBhcmRlcy1jb2RlBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7QgcNG1tLDRtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHbHMNChtbPzIwMDRsDRtdMTMzO0MHYXNzZXRzCSAgICAgICBkb2NzCQkgICAgbmV4dC1zdGVwcy50eHQgIHRlc3QJICAgICAgdXVjb2RlX2NvbmZpZy56aWcgIHppZy1wa2cNCmJ1aWxkLnppZyAgICAgIG1pc2UudG9tbAkgICAgc2hhZGVycwkgICAgdG9vbHMJICAgICAgdmVuZG9yDQpidWlsZC56aWcuem9uICBuZXh0LW5leHQtc3RlcHMudHh0ICBzcmMJCSAgICB0cmFuc2FjdGlvbnMudHh0ICB6aWctb3V0DQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovaG9tZS9nb2JsaW4vMDAtcHJvamVjdHMvMHg0MjAwLmNhZmUvMDItcGFyZGVzLWNvZGUHG1s/MjAwNGgbXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCB2NsZWFyDQobWz8yMDA0bA0bXTEzMztDBxtbSBtbMkobWzNKG10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L2hvbWUvZ29ibGluLzAwLXByb2plY3RzLzB4NDIwMC5jYWZlLzAyLXBhcmRlcy1jb2RlBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7QgdnaXQgbHMtZmlsZXMgJyouemlnJw0KG1s/MjAwNGwNG10xMzM7QwdidWlsZC56aWcNCnNyYy9jcnQuemlnDQpzcmMvZGVjay56aWcNCnNyYy9kdW1wLnppZw0Kc3JjL2d1aS56aWcNCnNyYy9pbWFnZS56aWcNCnNyYy9sb29rLnppZw0Kc3JjL21haW4uemlnDQpzcmMvbW9kYWwuemlnDQpzcmMvcGFyZGVzLnppZw0Kc3JjL3BldHNjaWkuemlnDQpzcmMvc3ludGF4LnppZw0Kc3JjL3RyYWN5LnppZw0Kc3JjL3R0eS56aWcNCnRlc3QvZGVjay9yZXBsYXkuemlnDQp0ZXN0L2RlY2svc2NoZW1hLnppZw0KdGVzdC9lMmVfaGFybmVzcy56aWcNCnRlc3QvaHhkaWZmLnppZw0KdGVzdC9zbmFwc2hvdC56aWcNCnV1Y29kZV9jb25maWcuemlnDQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovaG9tZS9nb2JsaW4vMDAtcHJvamVjdHMvMHg0MjAwLmNhZmUvMDItcGFyZGVzLWNvZGUHG1s/MjAwNGgbXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCBw==", + .cursor = .{ .col = 2, .row = 21 }, + }, + .file = null, + .image = null, + }}, +}
\ No newline at end of file diff --git a/test/web-snapshots/source-list.golden b/test/web-snapshots/source-list.golden new file mode 100644 index 00000000..d4321fbb --- /dev/null +++ b/test/web-snapshots/source-list.golden @@ -0,0 +1,190 @@ +== snap launcher grid=120x37 cursor=4,23 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| TTY /home/goblin/00-projects/0x4200.cafe/02-pardes-code Del +| $ git ls-files '*.zig' +| build.zig +| src/crt.zig +| src/deck.zig +| src/dump.zig +| src/gui.zig +| src/image.zig +| src/look.zig +| src/main.zig +| src/modal.zig +| src/pardes.zig +| src/petscii.zig +| src/syntax.zig +| src/tracy.zig +| src/tty.zig +| test/deck/replay.zig +| test/deck/schema.zig +| test/e2e_harness.zig +| test/hxdiff.zig +| test/snapshot.zig +| uucode_config.zig +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap source-open grid=120x37 cursor=7,25 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| TTY /home/goblin/00-projects/0x4200.cafe/02-pardes-code Del +| $ git ls-files '*.zig' +| build.zig +| src/crt.zig +| src/deck.zig +| src/dump.zig +| src/gui.zig +| src/image.zig +| src/look.zig +| src/main.zig +| src/modal.zig +| src/pardes.zig +| src/petscii.zig +| src/syntax.zig +| src/tracy.zig +| src/tty.zig +| test/deck/replay.zig +| test/deck/schema.zig +| test/e2e_harness.zig +| test/hxdiff.zig +| test/snapshot.zig +| uucode_config.zig +| +| NOR build.zig Save Del +| 1 const std = @import("std"); +| 2 +| 3 pub const Platform = enum { tty, gui, web }; +| 4 pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; +| 5 const GhosttyArtifact = enum { ghostty, lib }; +| 6 +| 7 pub fn build(b: *std.Build) void { +| 8 // Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu +| 9 // with the glibc version pinned low, so a binary built on a rolling- +| 10 // release host runs on SteamOS — a native build references the host's +| 11 // newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found" +| 12 // on the deck. Override with -Dtarget= as usual. +== style source-open +|0: 0-48 #969696,#2c2a3e, 49-119 d,#2c2a3e, +|1: 0-1 d,#383c54, 2-60 #969696,#2c2a3e, 61-119 d,#2c2a3e, +|2: 0-1 d,#525252, 2-119 d,d, +|3: 0-1 d,#525252, 2-119 d,d, +|4: 0-1 d,#525252, 2-119 d,d, +|5: 0-1 d,#525252, 2-119 d,d, +|6: 0-1 d,#525252, 2-119 d,d, +|7: 0-1 d,#525252, 2-119 d,d, +|8: 0-1 d,#525252, 2-119 d,d, +|9: 0-1 d,#525252, 2-119 d,d, +|10: 0-1 d,#525252, 2-119 d,d, +|11: 0-1 d,#525252, 2-119 d,d, +|12: 0-1 d,#525252, 2-119 d,d, +|13: 0-1 d,#525252, 2-119 d,d, +|14: 0-1 d,#525252, 2-119 d,d, +|15: 0-1 d,#525252, 2-119 d,d, +|16: 0-1 d,#525252, 2-119 d,d, +|17: 0-1 d,#525252, 2-119 d,d, +|18: 0-1 d,#525252, 2-119 d,d, +|19: 0-1 d,#525252, 2-119 d,d, +|20: 0-1 d,#525252, 2-119 d,d, +|21: 0-1 d,#525252, 2-119 d,d, +|22: 0-1 d,#525252, 2-119 d,d, +|23: 0-1 d,#525252, 2-119 d,d, +|24: 0-1 d,#6e4fd0, 2-23 #969696,#2c2a3e, 24-119 d,#2c2a3e, +|25: 0-1 d,#525252, 2-6 #969696,#2c2a3e, 7-18 d,d, 19-25 #cc70d8,d,b 26-26 d,d, 27-31 #8ab87a,d, 32-119 d,d, +|26: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-119 d,d, +|27: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-27 d,d, 28-31 #cc70d8,d,b 32-119 d,d, +|28: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-37 d,d, 38-41 #cc70d8,d,b 42-119 d,d, +|29: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-30 d,d, 31-34 #cc70d8,d,b 35-119 d,d, +|30: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-119 d,d, +|31: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-12 #cc70d8,d,b 13-119 d,d, +|32: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-81 #f0f0f0,d,b 82-119 d,d, +|33: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-79 #f0f0f0,d,b 80-119 d,d, +|34: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-80 #f0f0f0,d,b 81-119 d,d, +|35: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-83 #f0f0f0,d,b 84-119 d,d, +|36: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-10 d,d, 11-59 #f0f0f0,d,b 60-119 d,d, +== snap separator-resized grid=120x37 cursor=7,21 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| TTY /home/goblin/00-projects/0x4200.cafe/02-pardes-code Del +| src/dump.zig +| src/gui.zig +| src/image.zig +| src/look.zig +| src/main.zig +| src/modal.zig +| src/pardes.zig +| src/petscii.zig +| src/syntax.zig +| src/tracy.zig +| src/tty.zig +| test/deck/replay.zig +| test/deck/schema.zig +| test/e2e_harness.zig +| test/hxdiff.zig +| test/snapshot.zig +| uucode_config.zig +|╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ +| NOR build.zig Save Del +| 1 const std = @import("std"); +| 2 +| 3 pub const Platform = enum { tty, gui, web }; +| 4 pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; +| 5 const GhosttyArtifact = enum { ghostty, lib }; +| 6 +| 7 pub fn build(b: *std.Build) void { +| 8 // Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu +| 9 // with the glibc version pinned low, so a binary built on a rolling- +| 10 // release host runs on SteamOS — a native build references the host's +| 11 // newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found" +| 12 // on the deck. Override with -Dtarget= as usual. +| 13 const target = b.standardTargetOptions(.{ .default_target = .{ +| 14 .cpu_arch = .x86_64, +| 15 .os_tag = .linux, +| 16 .abi = .gnu, +== snap source-scrolled grid=120x37 cursor=0,0 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| TTY /home/goblin/00-projects/0x4200.cafe/02-pardes-code Del +| src/dump.zig +| src/gui.zig +| src/image.zig +| src/look.zig +| src/main.zig +| src/modal.zig +| src/pardes.zig +| src/petscii.zig +| src/syntax.zig +| src/tracy.zig +| src/tty.zig +| test/deck/replay.zig +| test/deck/schema.zig +| test/e2e_harness.zig +| test/hxdiff.zig +| test/snapshot.zig +| uucode_config.zig +| +| NOR build.zig Save Del +| 10 // release host runs on SteamOS — a native build references the host's +| 11 // newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found" +| 12 // on the deck. Override with -Dtarget= as usual. +| 13 const target = b.standardTargetOptions(.{ .default_target = .{ +| 14 .cpu_arch = .x86_64, +| 15 .os_tag = .linux, +| 16 .abi = .gnu, +| 17 .glibc_version = .{ .major = 2, .minor = 38, .patch = 0 }, +| 18 } }); +| 19 const requested_optimize = b.standardOptimizeOption(.{}); +| 20 const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web)") orelse .tty; +| 21 const static = b.option(bool, "static", "statically link") orelse false; +| 22 const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)"); +| 23 const emcc_path = b.option([]const u8, "emcc", "emcc for the web shell's final link") orelse "/usr/lib/emscri +| 24 const is_emscripten = target.result.os.tag == .emscripten; +| 25 // wasm: size is the budget diff --git a/test/web-snapshots/source-list.snap b/test/web-snapshots/source-list.snap new file mode 100644 index 00000000..b1a0a79a --- /dev/null +++ b/test/web-snapshots/source-list.snap @@ -0,0 +1,29 @@ +# Browser-only .snap commands. The page is built from a dump made by a real +# Pardes TTY after running `git ls-files '*.zig'`; touch events below are +# injected through Chrome's real browser input path. +wait 10000 uucode_config.zig +stable 100 5000 +snap launcher + +# A little sub-slop movement is still a tap. On web one-finger taps are LOOK, +# so this opens build.zig from the archive generated from Git-tracked sources. +touch down 1 5 3 +touch move 1 5.2 3.1 +touch up 1 5.2 3.1 +wait 10000 build.zig Save Del +stable 100 5000 +snap source-open +snapstyle source-open + +# A touch that begins on the horizontal pane separator is a latched left-mouse +# resize gesture. Crossing the body-scroll threshold must never turn it into a +# wheel gesture. +swipe 2 60 23 60 19 8 +stable 100 5000 +snap separator-resized + +# Travel well outside the tap slop becomes natural one-finger scrolling and +# must not also click/LOOK at the release point. +swipe 3 60 34 60 27 12 +stable 100 5000 +snap source-scrolled 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(); |
