diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /test/web_snapshot.mjs | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-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.mjs | 204 |
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(); |
