// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT // Run one validation case in the terminal this program is running in, and // write what was checked. Started by emulators/run.mjs in a fresh terminal // for every case, sized to the case's screen and titled with a sentinel, so // that nothing one case does can leak into the next. // // node case.mjs CASE.json RESULT.json // // CASE.json is { input, expected } in the site's notation, with the // sentinel title, the window's class name, and the profile: how to fence // and read the screen in this terminal. "xterm" fences with DECRQCRA and // reads each cell with it, as below; "kitty" fences with an XTGETTCAP for a // name no case asks for, which kitty answers with the name, and reads the // whole screen with `kitten @ get-text --ansi` (see kitty-screen.mjs), // which gives every attribute and color, so its `attr` checks are complete; // "foot" fences the same way and reads the screen as plain text with its // `pipe-visible` action, bound to a key and pressed with wtype (see // foot-screen.mjs), so its `attr` checks are untested and the title comes // from sway; "urxvt" fences with an OSC 777 command to an extension the // harness loads into rxvt-unicode (urxvt/cc-harness), which writes the // screen, with each cell's rendition, the cursor and the bells, to a file. // The steps, for xterm: // // 1. Confirm the screen is the case's size (XTWINOPS 18). // 2. Write the case's input. // 3. Fence: a DECRQCRA with an identifier no case uses. The terminal // answers requests in order, so when the fence's answer arrives, // everything the case sent has been processed, and whatever came back // before it is the case's reply. // 4. Read back what the expected result checks: the cursor (CPR, adjusted // for origin mode), whether it is visible (DECRQM 25), each cell's // character and attributes (DECRQCRA on that one cell, with xterm's // XTCHECKSUM extension set to report the code point, and then the code // point plus attribute bits), and the title (from X, with xdotool). // // What cannot be read back this way is "untested", never a failure: the // bell, the working directory, progress reports, the pointer shape, pending // wrap, and the colors, italic, faint and the rest of an `attr` line beyond // bold, underline, blink, inverse and invisible. // // DECRQCRA is the one thing this needs that a default xterm refuses: its // `disallowedWindowOps` names GetChecksum. run.mjs lifts that one // restriction, so the cases about DECRQCRA itself are expected to differ. import { execFileSync, execSync } from "node:child_process"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { parseCase, escapeBytes } from "../website/assets/vtcase.js"; import { parseScreen, hasAttr, describe } from "./kitty-screen.mjs"; import { parseFootText } from "./foot-screen.mjs"; const [casePath, resultPath] = process.argv.slice(2); const { input, expected: expectedText, sentinel, windowName, profile = "xterm" } = JSON.parse(readFileSync(casePath, "utf8")); const testCase = parseCase(input, expectedText); const ESC = "\x1b"; const decoder = new TextDecoder("latin1"); // The tty, raw: no echo, no line editing, and no output processing, so that // a case's LF reaches the terminal as LF and not as CR LF. execSync("stty raw -echo -opost -onlcr -ixon", { stdio: ["inherit", "inherit", "inherit"] }); let buffer = ""; let waiters = []; process.stdin.on("data", (chunk) => { buffer += decoder.decode(chunk); for (const w of waiters.slice()) w(); }); function write(data) { process.stdout.write(typeof data === "string" ? Buffer.from(data, "latin1") : Buffer.from(data)); } const bytesOf = (s) => Uint8Array.from(s, (ch) => ch.charCodeAt(0)); // Wait until the input holds a match for `re`; return what came before the // match and the match itself, and consume both. Rejects after `ms`. function readUntil(re, ms = 3000) { return new Promise((resolve, reject) => { const attempt = () => { const m = re.exec(buffer); if (!m) return false; const before = buffer.slice(0, m.index); buffer = buffer.slice(m.index + m[0].length); waiters = waiters.filter((w) => w !== check); clearTimeout(timer); resolve({ before, match: m }); return true; }; const check = () => attempt(); const timer = setTimeout(() => { waiters = waiters.filter((w) => w !== check); reject(new Error(`timed out waiting for ${re}; had ${escapeBytes(bytesOf(buffer.slice(-200)))}`)); }, ms); if (!attempt()) waiters.push(check); }); } async function query(seq, re) { write(seq); return (await readUntil(re)).match; } // One cell's value from DECRQCRA, as the low 16 bits xterm reports. async function cellChecksum(row, col) { const m = await query(`${ESC}[1;1;${row};${col};${row};${col}*y`, /\x1bP1!~([0-9A-Fa-f]{4})\x1b\\/); return parseInt(m[1], 16); } function title() { if (profile === "foot") return swayTitle(); try { const env = { ...process.env, LANG: "en_US.UTF-8" }; const win = execFileSync("xdotool", ["search", "--classname", windowName], { encoding: "utf8", env }).trim().split("\n")[0]; return execFileSync("xdotool", ["getwindowname", win], { encoding: "utf8", env }).replace(/\n$/, ""); } catch { return null; } } // The title of the window whose app-id is the harness's, from sway. function swayTitle() { try { const tree = JSON.parse(execFileSync("swaymsg", ["-t", "get_tree", "--raw"], { encoding: "utf8" })); const find = (node) => (node.app_id === windowName ? node : [...(node.nodes ?? []), ...(node.floating_nodes ?? [])].map(find).find(Boolean)); const win = find(tree); return win ? (win.name ?? "") : null; } catch { return null; } } // xterm's checksum attribute bits (screen.c, xtermCheckRect). const ATTR_BITS = { invisible: 0x8, underline: 0x10, inverse: 0x20, blink: 0x40, bold: 0x80 }; function judgeAttrs(words, bits) { let untested = false; for (const word of words) { const negated = word.startsWith("-"); const name = negated ? word.slice(1) : word; if (name in ATTR_BITS) { if ((bits & ATTR_BITS[name]) !== 0 === negated) return "fail"; } else { untested = true; } } return untested ? "untested" : "pass"; } async function run() { const { expected, steps } = testCase; const rows = expected.rows.length; const cols = expected.cols; const checks = []; let size = null; try { size = await query(`${ESC}[18t`, /\x1b\[8;(\d+);(\d+)t/); } catch { // Not answered: carry on, trusting the size the terminal was opened at. } if (size && (+size[1] !== rows || +size[2] !== cols)) { return { status: "untested", reason: `the terminal is ${size[1]}x${size[2]}, not ${rows}x${cols}`, checks }; } if (profile === "kitty") return runFenced(rows, cols, steps, expected, checks, KITTY); if (profile === "foot") return runFenced(rows, cols, steps, expected, checks, FOOT); if (profile === "urxvt") return runFenced(rows, cols, steps, expected, checks, URXVT); // All of it in one write, as one read: the cases are written that way // (see the UTF-8 page), and xterm decodes a stray byte by what follows it // in the same read. const all = new Uint8Array(steps.reduce((n, s) => n + s.bytes.length, 0)); steps.reduce((at, s) => (all.set(s.bytes, at), at + s.bytes.length), 0); write(all); let reply; try { write(fenceSeq()); reply = (await readUntil(fenceRe(), 3000)).before; } catch { return { status: "untested", reason: "the fence was not answered after the input: the case left the terminal inside a string, or below level 4, where DECRQCRA is not answered", checks }; } // The cursor first, while origin mode is as the case left it. const cursorCheck = expected.checks.find((c) => c.kind === "cursor"); const origin = (await query(`${ESC}[?6$p`, /\x1b\[\?6;(\d)\$y/))[1] === "1"; if (cursorCheck) { const cpr = await query(`${ESC}[6n`, /\x1b\[(\d+);(\d+)R/); let row = +cpr[1]; let col = +cpr[2]; if (origin) { const tb = await query(`${ESC}P$qr${ESC}\\`, /\x1bP(\d)\$r(?:(\d+);(\d+))?r\x1b\\/); if (tb[2]) row += +tb[2] - 1; const lr = (await query(`${ESC}[?69$p`, /\x1b\[\?69;(\d)\$y/))[1] === "1"; if (lr) { const s = await query(`${ESC}P$qs${ESC}\\`, /\x1bP(\d)\$r(?:(\d+);(\d+))?s\x1b\\/); if (s[2]) col += +s[2] - 1; } } const want = `${cursorCheck.row},${cursorCheck.col}`; const got = `${row},${col}`; checks.push({ kind: "cursor", status: want === got ? "pass" : "fail", want, got }); } // DECRQCRA counts from the margins in origin mode; the screen is read in // absolute positions, so turn it off. (That moves the cursor, which has // already been read.) if (origin) write(`${ESC}[?6l`); // Each cell: the code point, unmasked (XTCHECKSUM 23). A character from a // national replacement set is stored as its ASCII byte, so where that does // not match, ask again through the character set mapping (XTCHECKSUM 7). write(`${ESC}[23#y`); const cells = []; for (let r = 0; r < rows; r++) { const line = []; for (let c = 0; c < cols; c++) line.push(await cellChecksum(r + 1, c + 1)); cells.push(line); } let screenOk = true; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const want = expected.rows[r][c]; const wantCode = want === "_" ? 0x20 : want.codePointAt(0) & 0xffff; // xterm's DECRQCRA skips characters from U+FFF0 to U+FFFF, U+FFFD // among them, and the right half of a wide character is one of those; // a blank is a space. So 0 can only be one of those two. if (cells[r][c] === 0 && (want === "\ufffd" || want === "_")) continue; const got = cells[r][c]; if (got === wantCode) continue; write(`${ESC}[7#y`); const mapped = await cellChecksum(r + 1, c + 1); write(`${ESC}[23#y`); if (mapped === wantCode) { cells[r][c] = mapped; continue; } screenOk = false; } } const shown = cells.map((line) => `|${line.map((v) => (v === 0 ? "\ufffd" : v === 0x20 ? "_" : String.fromCodePoint(v))).join("")}|`); checks.push({ kind: "screen", status: screenOk ? "pass" : "fail", want: expected.rows.map((r) => `|${r.join("")}|`), got: shown }); for (const check of expected.checks) { switch (check.kind) { case "cursor": break; case "reply": { const want = escapeBytes(check.value) || "none"; const got = escapeBytes(bytesOf(reply)) || "none"; checks.push({ kind: "reply", status: want === got ? "pass" : "fail", want, got }); break; } case "cursor-visible": { const m = await query(`${ESC}[?25$p`, /\x1b\[\?25;(\d)\$y/); const got = m[1] === "1"; checks.push({ kind: "cursor-visible", status: got === check.value ? "pass" : "fail", want: check.value, got }); break; } case "attr": { const plain = cells[check.row - 1]?.[check.col - 1]; if (plain === undefined) { checks.push({ kind: "attr", status: "fail", want: check.attrs.join(" "), got: "off screen" }); break; } write(`${ESC}[23#y`); const code = await cellChecksum(check.row, check.col); write(`${ESC}[21#y`); const withAttrs = await cellChecksum(check.row, check.col); write(`${ESC}[23#y`); const bits = (withAttrs - code) & 0xffff; const words = check.attrs.flatMap((a) => (a === "plain" ? ["-bold", "-underline", "-blink", "-inverse", "-invisible", "italic"] : [a])); const got = Object.entries(ATTR_BITS).filter(([, b]) => bits & b).map(([n]) => n).join(" ") || "none of bold, underline, blink, inverse, invisible"; checks.push({ kind: "attr", status: judgeAttrs(words, bits), want: check.attrs.join(" "), got, at: `${check.row},${check.col}` }); break; } case "title": { const got = title(); const want = check.value === "" ? sentinel : check.value; checks.push({ kind: "title", status: got === null ? "untested" : got === want ? "pass" : "fail", want: check.value, got: got === sentinel ? "" : got }); break; } default: checks.push({ kind: check.kind, status: "untested" }); } } const statuses = checks.map((c) => c.status); const status = statuses.includes("fail") ? "fail" : statuses.includes("untested") ? "partial" : "pass"; return { status, checks }; } // kitty: fence with XTGETTCAP, whose answer names what was asked for. const TCAP_NAME = Buffer.from("cc-harness-fence").toString("hex"); function kittyText() { return execFileSync("kitten", ["@", "--to", process.env.KITTY_LISTEN_ON, "get-text", "--match", `id:${process.env.KITTY_WINDOW_ID}`, "--extent", "screen", "--ansi"], { encoding: "utf8" }); } // foot has no way to be asked for its screen but a key binding: the flake // binds `pipe-visible` to Ctrl+Shift+F9, writing the visible text to // FOOT_SCREEN, and the key is pressed here with wtype, through the // compositor's virtual keyboard. const FOOT_SCREEN = "/tmp/cc-foot-screen"; async function footText() { rmSync(FOOT_SCREEN, { force: true }); execFileSync("wtype", ["-M", "ctrl", "-M", "shift", "-k", "F9", "-m", "shift", "-m", "ctrl"]); for (let waited = 0; !existsSync(FOOT_SCREEN); waited += 20) { if (waited > 3000) throw new Error("foot did not write the screen"); await new Promise((r) => setTimeout(r, 20)); } return readFileSync(FOOT_SCREEN, "utf8"); } // The fence kitty and foot answer: an XTGETTCAP for a name no case asks // for, which they answer with the name. const TCAP_FENCE = { seq: `${ESC}P+q${TCAP_NAME}${ESC}\\`, re: new RegExp(`\\x1bP[01]\\+r${TCAP_NAME}(?:=[0-9A-Fa-f]*)?\\x1b\\\\`, "i"), }; // rxvt-unicode answers no request that reads the screen, so the harness // loads an extension of its own into it (emulators/urxvt/cc-harness), which // answers OSC 777 commands: `start` counts bells from then on, and `dump` // writes the screen, the cursor and the bell count to a file. Each is // answered with an acknowledgement, which is the fence. const URXVT_DUMP = "/tmp/cc-urxvt-screen.json"; const URXVT_ACK = /\x1b\]777;cc-harness;ack\x07/; function urxvtScreen(rows, cols) { const dump = JSON.parse(readFileSync(URXVT_DUMP, "utf8")); // Color 0 and 1 are the default foreground and background; the palette // starts at 2. const color = (index) => (index < 2 ? null : { index: index - 2 }); // A 24-bit color is kept in a slot past the 256 of the palette, which // the extension cannot turn back into RGB: such a cell's colors are // unreadable. const truecolor = (c) => c.fg > 257 || c.bg > 257; const grid = dump.rows.slice(0, rows).map((line) => line.slice(0, cols).map((c) => ({ text: c.t, style: { bold: !!c.b, faint: false, italic: !!c.i, blink: !!c.k, inverse: !!c.v, invisible: false, strikethrough: false, overline: false, underline: c.u ? "single" : "none", fg: truecolor(c) ? null : color(c.fg), bg: truecolor(c) ? null : color(c.bg), link: false, ...(truecolor(c) ? { unreadable: true } : {}), }, })), ); return { grid, sawTab: false, cursor: { row: dump.cursor[0] + 1, col: Math.min(dump.cursor[1], dump.ncol - 1) + 1 }, bells: dump.bells }; } // How to read the screen back in each terminal fenced this way: the fences // before and after the case's input, and whether what is read has the cells' // attributes. A reader whose screen() also gives the cursor and a count of // bells has those judged from it. const KITTY = { before: TCAP_FENCE, after: TCAP_FENCE, screen: async (rows, cols) => parseScreen(kittyText(), rows, cols), attrs: true, name: "kitty" }; const FOOT = { before: TCAP_FENCE, after: TCAP_FENCE, screen: async (rows, cols) => parseFootText(await footText(), rows, cols), attrs: false, name: "foot" }; const URXVT = { before: { seq: `${ESC}]777;cc-harness;start\x07`, re: URXVT_ACK }, after: { seq: `${ESC}]777;cc-harness;dump;${URXVT_DUMP}\x07`, re: URXVT_ACK }, screen: async (rows, cols) => urxvtScreen(rows, cols), attrs: true, name: "rxvt-unicode", }; async function runFenced(rows, cols, steps, expected, checks, reader) { // kitty's -T pins the title against the program, so the sentinel is set // here instead, and fenced, before the case begins. write(`${ESC}]2;${sentinel}${ESC}\\`); write(reader.before.seq); await readUntil(reader.before.re, 3000); const all = new Uint8Array(steps.reduce((n, s) => n + s.bytes.length, 0)); steps.reduce((at, s) => (all.set(s.bytes, at), at + s.bytes.length), 0); write(all); let reply; try { write(reader.after.seq); reply = (await readUntil(reader.after.re, 3000)).before; } catch { return { status: "untested", reason: "the fence was not answered after the input: the case left the terminal inside a string", checks }; } const screen = await reader.screen(rows, cols); const cursorCheck = expected.checks.find((c) => c.kind === "cursor"); if (cursorCheck && screen.cursor) { const want = `${cursorCheck.row},${cursorCheck.col}`; const got = `${screen.cursor.row},${screen.cursor.col}`; checks.push({ kind: "cursor", status: want === got ? "pass" : "fail", want, got }); } else if (cursorCheck) { try { const cpr = await query(`${ESC}[6n`, /\x1b\[(\d+);(\d+)R/); let row = +cpr[1]; let col = +cpr[2]; const origin = (await query(`${ESC}[?6$p`, /\x1b\[\?6;(\d)\$y/))[1] === "1"; if (origin) { const tb = await query(`${ESC}P$qr${ESC}\\`, /\x1bP(\d)\$r(?:(\d+);(\d+))?r\x1b\\/); if (tb[2]) row += +tb[2] - 1; const lr = (await query(`${ESC}[?69$p`, /\x1b\[\?69;(\d)\$y/))[1] === "1"; if (lr) { const s = await query(`${ESC}P$qs${ESC}\\`, /\x1bP(\d)\$r(?:(\d+);(\d+))?s\x1b\\/); if (s[2]) col += +s[2] - 1; } } const want = `${cursorCheck.row},${cursorCheck.col}`; const got = `${row},${col}`; checks.push({ kind: "cursor", status: want === got ? "pass" : "fail", want, got }); } catch (e) { checks.push({ kind: "cursor", status: "untested", got: String(e) }); } } const { grid, sawTab } = screen; let screenOk = true; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const want = expected.rows[r][c]; const got = grid[r][c].text; const ok = want === "_" ? got === " " || got === "" : got === want; if (!ok) screenOk = false; } } const shown = grid.map((line) => `|${line.map((cell) => (cell.text === " " || cell.text === "" ? "_" : cell.text)).join("")}|`); // kitty and foot write a run of blanks a tab made as one tab, and how many // it stood for is lost; expanded to the default stops, a mismatch could be // that, so it is not counted as a failure. checks.push({ kind: "screen", status: screenOk ? "pass" : sawTab ? "untested" : "fail", want: expected.rows.map((r) => `|${r.join("")}|`), got: shown, ...(sawTab && !screenOk ? { reason: `${reader.name}'s text has a tab, which hides how many blanks it stood for` } : {}) }); for (const check of expected.checks) { switch (check.kind) { case "cursor": break; case "reply": { const want = escapeBytes(check.value) || "none"; const got = escapeBytes(bytesOf(reply)) || "none"; checks.push({ kind: "reply", status: want === got ? "pass" : "fail", want, got }); break; } case "cursor-visible": { try { const m = await query(`${ESC}[?25$p`, /\x1b\[\?25;(\d)\$y/); const got = m[1] === "1"; checks.push({ kind: "cursor-visible", status: got === check.value ? "pass" : "fail", want: check.value, got }); } catch { checks.push({ kind: "cursor-visible", status: "untested" }); } break; } case "attr": { if (!reader.attrs) { checks.push({ kind: "attr", status: "untested", want: check.attrs.join(" "), at: `${check.row},${check.col}` }); break; } const cell = grid[check.row - 1]?.[check.col - 1]; if (!cell) { checks.push({ kind: "attr", status: "fail", want: check.attrs.join(" "), got: "off screen" }); break; } if (cell.style.unreadable) { checks.push({ kind: "attr", status: "untested", want: check.attrs.join(" "), at: `${check.row},${check.col}` }); break; } const verdicts = check.attrs.map((w) => hasAttr(cell.style, w)); const status = verdicts.includes(false) ? "fail" : verdicts.includes(null) ? "untested" : "pass"; checks.push({ kind: "attr", status, want: check.attrs.join(" "), got: describe(cell.style), at: `${check.row},${check.col}` }); break; } case "bell": { if (screen.bells === undefined) { checks.push({ kind: "bell", status: "untested" }); break; } checks.push({ kind: "bell", status: screen.bells === check.value ? "pass" : "fail", want: String(check.value), got: String(screen.bells) }); break; } case "title": { const got = title(); const want = check.value === "" ? sentinel : check.value; checks.push({ kind: "title", status: got === null ? "untested" : got === want ? "pass" : "fail", want: check.value, got: got === sentinel ? "" : got }); break; } default: checks.push({ kind: check.kind, status: "untested" }); } } const statuses = checks.map((c) => c.status); const status = statuses.includes("fail") ? "fail" : statuses.includes("untested") ? "partial" : "pass"; return { status, checks }; } const FENCE = 31337; function fenceSeq() { return `${ESC}[${FENCE};1;1;1;1;1*y`; } function fenceRe() { return new RegExp(`\\x1bP${FENCE}!~([0-9A-Fa-f]{4})\\x1b\\\\`); } let result; try { result = await run(); } catch (e) { result = { status: "error", reason: String(e) }; } writeFileSync(resultPath, JSON.stringify(result) + "\n"); process.exit(0);