summaryrefslogtreecommitdiff
path: root/test
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
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')
-rw-r--r--test/web-snapshots/README.md18
-rw-r--r--test/web-snapshots/source-list.dump.zon26
-rw-r--r--test/web-snapshots/source-list.golden190
-rw-r--r--test/web-snapshots/source-list.snap29
-rw-r--r--test/web_snapshot.mjs471
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();