#!/usr/bin/env node // 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"; 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 }; let scriptPath; let server; let chrome; let client; let userDataDir; 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 [--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(); 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.on("error", (error) => { chromeStderr += `\n${error.message}`; }); 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", signal: AbortSignal.timeout(10_000) }, ); 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); if (scenarioModule) { const scenario = await import(pathToFileURL(scriptPath)); await scenario.default({ grid, touch: dispatchTouch, wait: waitUntil, stable: waitStable, delay, evaluate, mouse: (event) => client.send("Input.dispatchMouseEvent", event) }); 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); 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 (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) await stopChrome(chrome); if (server) { const closed = new Promise((resolve) => server.close(resolve)); server.closeAllConnections(); await closed; } 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(`pardes.test.cellPoint(${JSON.stringify(col)}, ${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 frame = pardes.test.grid(); const { cols, rows, cursorX, cursorY } = frame; const lines = []; const styleRows = []; for (let y = 0; y < rows; y++) { let line = ""; const styles = []; for (let x = 0; x < cols; x++) { const cell = frame.cells[y * cols + x]; line += cell.text || " "; styles.push([cell.fg, cell.bg, cell.attrs]); } lines.push(line.replace(/ +$/, "")); styleRows.push(styles); } return { cols, rows, lines, styleRows, cursorX, cursorY, 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) { const deadline = Date.now() + timeout; let previous = JSON.stringify(await grid()); while (Date.now() < deadline) { await delay(Math.min(quiet, deadline - Date.now())); const current = JSON.stringify(await grid()); if (current === previous && Date.now() <= deadline) return; previous = current; } throw new Error(`screen did not stabilize within ${timeout}ms`); } async function waitForRuntime(timeout) { await waitUntil(async () => { try { return await evaluate(`(() => { const app = globalThis.pardes; if (!app?.test?.grid()?.cols || document.querySelector("canvas")) return false; const want = app.runtime.gridSize(); const frame = app.test.grid(); const tagline = document.querySelector(".pardes-cell.is-tagline"); const body = document.querySelector(".pardes-cell:not(.is-tagline)"); if (!tagline || !body) return false; const tagRect = tagline.getBoundingClientRect(); const bodyRect = body.getBoundingClientRect(); return frame.cols === want.cols && frame.rows === want.rows && document.querySelectorAll(".pardes-row").length === want.rows && tagRect.width === bodyRect.width && tagRect.height === bodyRect.height && parseFloat(getComputedStyle(tagline).fontSize) < parseFloat(getComputedStyle(body).fontSize); })()`); } catch (error) { if (client.error) throw error; 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 && (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}`); } 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; } export class Cdp { static async connect(url, log, timeout = 10_000) { const socket = new WebSocket(url); 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, timeout = 10_000) { this.socket = socket; 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) => { 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 })); } }); } 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) => { 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 })); } }); } 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) { 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") || filename.endsWith(".mjs") ? "text/javascript; charset=utf-8" : filename.endsWith(".css") ? "text/css; charset=utf-8" : filename.endsWith(".ttf") ? "font/ttf" : "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.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`, { signal: AbortSignal.timeout(500) }); 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()); } // macOS installs a browser as an application BUNDLE, whose executable is // buried inside it and never on PATH, so the lookup above finds nothing on // a Mac that has Chrome sitting in /Applications. if (process.platform === "darwin") for (const app of ["Google Chrome", "Chromium", "Google Chrome Canary"]) candidates.push(`/Applications/${app}.app/Contents/MacOS/${app}`); 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)); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();