summaryrefslogtreecommitdiff
path: root/test/web_snapshot.mjs
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /test/web_snapshot.mjs
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'test/web_snapshot.mjs')
-rw-r--r--test/web_snapshot.mjs204
1 files changed, 132 insertions, 72 deletions
diff --git a/test/web_snapshot.mjs b/test/web_snapshot.mjs
index a1912f15..a9cd8dff 100644
--- a/test/web_snapshot.mjs
+++ b/test/web_snapshot.mjs
@@ -1,19 +1,6 @@
#!/usr/bin/env node
-// Drive the freestanding WASM + DOM build in a real headless Chromium using
-// .snap scripts. This is an end-to-end browser test: Chrome creates genuine
-// touch events and the JS shell translates them into core events.
-// Shared commands retain test/snapshot.zig's spelling, and a capture retains
-// its header and row shape — but every capture here is a whole frame, where
-// the tty runner emits later captures as row deltas (`|<n>: <row>`):
-// 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.
+// Native touch events through Chromium's DevTools protocol, without npm.
+// PARDES_CHROME overrides the Chromium executable.
import childProcess from "node:child_process";
import fs from "node:fs";
import fsp from "node:fs/promises";
@@ -21,21 +8,10 @@ import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
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 scriptPath;
let server;
let chrome;
let client;
@@ -44,7 +20,21 @@ let chromeStderr = "";
const browserLog = [];
async function main() {
+ 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> <test.mjs|script.snap> [--update]");
+ process.exitCode = 2;
+ return;
+ }
+ const webDir = path.resolve(positional[0]);
+ scriptPath = path.resolve(positional[1]);
+ const scenarioModule = scriptPath.endsWith(".mjs");
+ const goldenPath = scriptPath.replace(/\.snap$/, ".golden");
try {
+ if (!scenarioModule && goldenPath === scriptPath) throw new Error(`not a test module or .snap path: ${scriptPath}`);
+ if (scenarioModule && update) throw new Error("semantic browser tests have no golden to update");
const served = await serveDirectory(webDir);
server = served.server;
const debugPort = await unusedPort();
@@ -63,6 +53,7 @@ async function main() {
`--user-data-dir=${userDataDir}`,
"about:blank",
], { stdio: ["ignore", "ignore", "pipe"] });
+ chrome.on("error", (error) => { chromeStderr += `\n${error.message}`; });
chrome.stderr.setEncoding("utf8");
chrome.stderr.on("data", (chunk) => {
chromeStderr = (chromeStderr + chunk).slice(-16_384);
@@ -71,7 +62,7 @@ async function main() {
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" },
+ { method: "PUT", signal: AbortSignal.timeout(10_000) },
);
if (!targetResponse.ok) throw new Error(`create Chrome target: HTTP ${targetResponse.status}`);
const target = await targetResponse.json();
@@ -89,6 +80,12 @@ async function main() {
await client.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
await waitForRuntime(20_000);
+ if (scenarioModule) {
+ const scenario = await import(pathToFileURL(scriptPath));
+ await scenario.default({ grid, touch: dispatchTouch, wait: waitUntil, stable: waitStable, delay });
+ console.error(`web test ok: ${path.relative(process.cwd(), scriptPath)}`);
+ return;
+ }
const output = await runScript(await fsp.readFile(scriptPath, "utf8"));
if (update) {
await fsp.writeFile(goldenPath, output);
@@ -105,19 +102,21 @@ async function main() {
}
} catch (error) {
console.error(error?.stack ?? error);
+ if (scenarioModule && client) {
+ const frame = await grid().catch(() => null);
+ if (frame) console.error(formatSnapshot("failure", frame));
+ }
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 (chrome) await stopChrome(chrome);
+ if (server) {
+ const closed = new Promise((resolve) => server.close(resolve));
+ server.closeAllConnections();
+ await closed;
}
- if (server) await new Promise((resolve) => server.close(resolve));
if (userDataDir) await fsp.rm(userDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}
@@ -282,13 +281,12 @@ function attrsKey(attrs) {
}
async function waitStable(quiet, timeout) {
- let elapsed = 0;
+ const deadline = Date.now() + timeout;
let previous = JSON.stringify(await grid());
- while (elapsed < timeout) {
- await delay(quiet);
- elapsed += quiet;
+ while (Date.now() < deadline) {
+ await delay(Math.min(quiet, deadline - Date.now()));
const current = JSON.stringify(await grid());
- if (current === previous) return;
+ if (current === previous && Date.now() <= deadline) return;
previous = current;
}
throw new Error(`screen did not stabilize within ${timeout}ms`);
@@ -312,7 +310,8 @@ async function waitForRuntime(timeout) {
tagRect.width === bodyRect.width && tagRect.height === bodyRect.height &&
parseFloat(getComputedStyle(tagline).fontSize) < parseFloat(getComputedStyle(body).fontSize);
})()`);
- } catch {
+ } catch (error) {
+ if (client.error) throw error;
return false;
}
}, timeout, "Pardes web runtime");
@@ -322,7 +321,8 @@ 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}`);
+ if (chrome && (chrome.exitCode !== null || chrome.signalCode !== null))
+ throw new Error(`Chromium exited with ${chrome.exitCode ?? chrome.signalCode}`);
await delay(50);
}
throw new Error(`timed out waiting for ${description}`);
@@ -338,51 +338,110 @@ async function evaluate(expression) {
return response.result.value;
}
-class Cdp {
- static async connect(url, log) {
+export class Cdp {
+ static async connect(url, log, timeout = 10_000) {
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);
+ const client = new Cdp(socket, log, timeout);
+ try {
+ await new Promise((resolve, reject) => {
+ const finish = (error) => {
+ clearTimeout(timer);
+ socket.removeEventListener("open", opened);
+ socket.removeEventListener("close", failed);
+ socket.removeEventListener("error", failed);
+ if (error) reject(error); else resolve();
+ };
+ const opened = () => finish();
+ const failed = () => finish(client.error ?? new Error(`connect DevTools websocket ${url}`));
+ const timer = setTimeout(() => finish(new Error(`DevTools connection timed out after ${timeout}ms`)), timeout);
+ socket.addEventListener("open", opened, { once: true });
+ socket.addEventListener("close", failed, { once: true });
+ socket.addEventListener("error", failed, { once: true });
+ });
+ return client;
+ } catch (error) {
+ client.close();
+ throw error;
+ }
}
- constructor(socket, log) {
+ constructor(socket, log, timeout = 10_000) {
this.socket = socket;
- this.log = log;
+ this.timeout = timeout;
this.nextId = 1;
this.pending = new Map();
+ this.error = null;
+ socket.addEventListener("close", (event) => this.fail(new Error(`DevTools connection closed (${event.code ?? "unknown"})`)));
+ socket.addEventListener("error", () => this.fail(new Error("DevTools connection error")));
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 (this.error) return;
+ try {
+ const message = JSON.parse(event.data);
+ if (message.id) {
+ const pending = this.pending.get(message.id);
+ if (!pending) return;
+ this.pending.delete(message.id);
+ clearTimeout(pending.timer);
+ 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}`);
+ } catch (error) {
+ this.fail(new Error("invalid DevTools message", { cause: error }));
}
- 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 = {}) {
+ if (this.error) return Promise.reject(this.error);
+ if (this.socket.readyState !== WebSocket.OPEN) {
+ this.fail(new Error("DevTools connection is not open"));
+ return Promise.reject(this.error);
+ }
const id = this.nextId++;
return new Promise((resolve, reject) => {
- this.pending.set(id, { resolve, reject, method });
- this.socket.send(JSON.stringify({ id, method, params }));
+ const timer = setTimeout(() => this.fail(new Error(`${method} timed out after ${this.timeout}ms`)), this.timeout);
+ this.pending.set(id, { resolve, reject, method, timer });
+ try {
+ this.socket.send(JSON.stringify({ id, method, params }));
+ } catch (error) {
+ this.fail(new Error(`send DevTools command ${method}`, { cause: error }));
+ }
});
}
- close() {
+ fail(error) {
+ if (this.error) return;
+ this.error = error;
+ for (const pending of this.pending.values()) {
+ clearTimeout(pending.timer);
+ pending.reject(error);
+ }
+ this.pending.clear();
this.socket.close();
}
+
+ close() {
+ this.fail(new Error("DevTools client closed"));
+ }
+}
+
+export async function stopChrome(child, timeout = 2_000) {
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null) return;
+ await new Promise((resolve) => {
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeout);
+ child.once("exit", () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ child.kill("SIGTERM");
+ });
}
async function serveDirectory(root) {
@@ -429,9 +488,10 @@ async function unusedPort() {
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}`);
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null)
+ throw new Error(`Chromium exited with ${child.exitCode ?? child.signalCode}`);
try {
- const response = await fetch(`http://127.0.0.1:${port}/json/version`);
+ const response = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(500) });
if (response.ok) return;
} catch {}
await delay(50);
@@ -486,4 +546,4 @@ function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
-await main();
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();