Files
keyrelay/tests/test_page.js
T
ducoterra c43a088ce8 phase: 04_controller_page_responsive
**Final verification pass** (all three task reports PASS; tasks already in `complete/`). One real defect found and fixed.

**Commands / results.** `bash tests/run_tests.sh` → `RESULT: all suites passed`, **5 104** checks (page **220**). `SANITIZE=1 bash tests/run_tests.sh` → passed. `bash .agents/validate.sh` → `validation OK`; bridge/console/rollback `SUCCESS`; `page bytes: 59262`; `README/AGENTS claims: OK`; `repo hygiene: OK`. Real-browser leg (headless Chromium, served assembled bytes): no horizontal scroll at 360/390/768/1440, ARM first-in-`main` and sticky, log height-capped, all 5 folds openable, all 3 changed page statements execute.

**Fix.** The folded notes' `<summary>` — the only route to five pinned safety sentences — rendered **17 px** on coarse pointers, under the page's own 44 px thumb floor, while every byte-level assertion stayed green. `src/web_page.h`: `button.k,.pill,input,summary{min-height:44px}` + `summary{padding:13px 0}`; `tests/test_page.js` +1 check (219→220); mutation-tested (reverting the CSS fails host *and* browser legs, then restored byte-for-byte).

**Criteria.** 1 ✓ browser-measured (physical phone still unobserved). 2 ✓ 220 checks + prose untouched. 3 ✓ page 59 286→**59 262 B** (−24 vs phase start), README = gate figure. 4 ✓ one `test_page` row at 220, intro 5 104 = measured. 5 ✓ gate green; phases 01–03 assertions unchanged apart from the re-pinned byte constant.

**Docs.** Re-pinned 59 262 (README, `config.h`, `test_conn.cpp`), totals 5 103→5 104, README Tests row 220; re-measured footprint rows (flash 1 257 901 / 1 178 141 / 1 157 593, TLS +6 176/+100 308); fixed stale "48 KB"/"~50 KB" (×4 src comments, ×2 README) and the 51 626-byte page-delivery row; added **bug 14** with root cause and renamed *Thirteen*→*Fourteen bugs* in README + AGENTS.md's two stale references; added the Phase 04 browser+device row to `.agents/HW_VERIFICATION.md`.

**Deviations.** Device leg stays open: live board serves 51 626 B (pre-phase image); no reflash attempted, per the standing no-unattended-reflash rule — no byte-equality claim made. `ruff check tools tests`: 29 findings, identical at HEAD and `--isolated`, `E9,F` clean, no repo lint config or gate step → left untouched. `.agents/validate.sh` is **untracked and has never been committed** (`git log --all --` empty) though README/AGENTS/PLAN cite it 13× — harness needs `git add` it; I did not stage anything.
2026-11-09 11:00:27 -05:00

1105 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tests/test_page.js -- behaviour tests for the controller page's script.
//
// node tests/test_page.js
//
// The page is the primary interface, and it lives inside a C++ raw string where no
// compiler can see it: a typo there does not fail a build, it fails silently in
// somebody's browser while a target is live. So the script is extracted from
// src/web_page.h and run against a minimal DOM shim -- enough to exercise the rules
// that matter, which are what the log is allowed to contain, which characters may
// leave the tab, and when a run of pasted text stops.
//
// It is not a rendering test -- nothing here lays out a page. But it is the only
// place the served bytes can be seen without a browser, so the CSS a phone is the
// only real judge of (breakpoint, sticky ARM bar, thumb floors) is asserted at the
// byte level, the safety sentences are asserted as text read out of the markup,
// and the script itself is executed. Behaviour plus those pins is what a raw
// string gets. tests/run_tests.sh skips this file when node is not installed.
"use strict";
const fs = require("fs");
const path = require("path");
const vm = require("vm");
// ---------------------------------------------------------------------------
// A DOM small enough to read, complete enough to run the page.
// ---------------------------------------------------------------------------
function makeEl(tagName) {
const el = {
tagName: (tagName || "div").toUpperCase(), dataset: {}, className: "",
textContent: "", innerHTML: "", value: "", checked: false, disabled: false,
readOnly: false, children: [], listeners: {},
scrollTop: 0, scrollHeight: 0,
classList: { toggle() {}, add() {}, remove() {}, contains() { return false; } },
get firstChild() { return this.children[0] || null; },
get firstElementChild() { return this.children.find(c => c.tagName) || null; },
get lastElementChild() {
const e = this.children.filter(c => c.tagName);
return e.length ? e[e.length - 1] : null;
},
get childElementCount() { return this.children.filter(c => c.tagName).length; },
appendChild(c) { this.children.push(c); return c; },
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; },
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
// Element handlers are reachable too: the mouse pad is clicked, and a real click is
// what the browser turns into a Pointer Lock request.
fire(t, ev) {
for (const fn of (this.listeners[t] || [])) fn(ev || { preventDefault() {} });
},
getAttribute() { return null; }, setAttribute() {}, focus() {},
};
return el;
}
// Document- and window-level listeners are recorded rather than dropped. Every
// fail-safe this page has is triggered by something the browser does to the document --
// blur, visibilitychange, pointerlockchange -- so a shim that swallows them cannot
// prove them.
function makeListeners() {
const map = {};
return {
map,
add(t, fn) { (map[t] = map[t] || []).push(fn); },
fire(t, ev) {
for (const fn of (map[t] || [])) fn(ev || { preventDefault() {}, target: null });
},
};
}
const docListeners = makeListeners();
const winListeners = makeListeners();
// The shim hands out an element for any id, which is far less work than a real DOM.
// The weakness in that is obvious, so it is compensated for: every id the script asks
// for is recorded and checked against the ids that are actually in the markup at the
// end. A mistyped id is a null dereference in a browser, and silence here.
const registry = new Map();
const requestedIds = new Set();
const byId = id => {
requestedIds.add(id);
if (!registry.has(id)) registry.set(id, makeEl("div"));
return registry.get(id);
};
class FakeWebSocket {
constructor(url) {
this.url = url; // what the page asked to connect to
this.readyState = 1; // OPEN
FakeWebSocket.instances.push(this);
setTimeout(() => this.onopen && this.onopen(), 0);
}
send(text) { sentFrames.push(text); }
close() { this.readyState = 3; }
}
FakeWebSocket.OPEN = 1;
FakeWebSocket.instances = [];
const doc = {
getElementById: byId,
createElement: t => makeEl(t),
querySelectorAll: () => [],
addEventListener: docListeners.add,
body: makeEl("body"),
hidden: false,
// Pointer Lock, as much of it as the page touches. exitPointerLock fires the change
// event because a real browser does, which is what makes the release path testable.
pointerLockElement: null,
exitPointerLock() {
if (!doc.pointerLockElement) return;
doc.pointerLockElement = null;
docListeners.fire("pointerlockchange", {});
},
};
const sandbox = {
document: doc,
window: { addEventListener: winListeners.add },
// A TLS board never hands its page an http: origin (:80 answers a redirect), so
// the origin this suite boots on is https:, like the real first load. The
// connect-path section switches it deliberately to walk both the refusal and
// the -D KR_TLS=0 rollback.
location: { protocol: "https:", host: "keyrelay.local" },
sessionStorage: { getItem: () => null, setItem() {} },
WebSocket: FakeWebSocket,
setInterval: () => 0, // the reconnect/keepalive loop stays shut in a test
setTimeout, clearTimeout, Date, JSON, Math, Promise, TextEncoder, console,
Number, String, Object, Array, Buffer, parseInt,
};
// The page keeps its state in script-scope `let`s, so the harness reaches into that
// scope once, here, instead of duplicating the page's logic in the test.
const expose = `
;globalThis.__x = {
log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText,
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect, boot,
servedInClear,
get tlsBuild() { return KR_TLS_BUILD; }, set tlsBuild(v) { KR_TLS_BUILD = v; },
TEXT_FRAME_MAX, TEXT_JOB_MAX, TEXT_PACE_MS,
takeMouse, dropMouse, flushMouse, mouseFrame, paintMouse, refreshMouseUi,
MOUSE_DELTA_MAX, MOUSE_FRAME_MS, MOUSE_MAX_FRAMES, MOUSE_WHEEL_PX,
get mCaptured() { return mCaptured; }, set mCaptured(v) { mCaptured = v; },
get mButtons() { return mButtons; },
get pendX() { return pendX; }, get pendY() { return pendY; },
get ws() { return ws; },
get armed() { return armed; }, set armed(v) { armed = v; },
get authed() { return authed; }, set authed(v) { authed = v; },
get typing() { return typing; },
get autoReconnect() { return autoReconnect; },
get busyKnown() { return busyKnown; },
get takenOver() { return takenOver; },
};`;
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
const headerText = fs.readFileSync(headerPath, "utf8");
// The page is a chain of R"HTML( ... )HTML" raw-string chunks joined by exactly one
// #if KR_TLS build marker (task 05: the page has to know its own build before any
// socket exists). Assemble the bytes one build serves, the same assembly
// tests/test_relayctl.py and .agents/validate.sh perform -- three readers of one
// layout, each failing if the layout drifts.
function servedPage(text, tls) {
const chunks = [...text.matchAll(/R"HTML\(([\s\S]*?)\)HTML"/g)].map(m => m[1]);
const marks = [...text.matchAll(
/#if KR_TLS\n\s*"([^"]*)"\n#else\n\s*"([^"]*)"\n#endif/g)];
if (chunks.length === 0 || marks.length !== chunks.length - 1) {
console.error("web_page.h: raw-string chunks and #if KR_TLS markers no longer " +
"join up; fix this harness rather than shipping a guessed page");
process.exit(1);
}
let out = "";
chunks.forEach((c, i) => {
out += c;
if (i < marks.length) out += tls ? marks[i][1] : marks[i][2];
});
return out;
}
const served = servedPage(headerText, true); // the TLS build, by default
const rollbackPage = servedPage(headerText, false);
const from = served.indexOf("<script>");
const to = served.indexOf("<\/script>", from);
if (from < 0 || to < 0) {
console.error("no <script> block in " + headerPath);
process.exit(1);
}
const pageScript = served.slice(from + "<script>".length, to);
const context = vm.createContext(sandbox);
// filename so a throw inside the page names web_page.js, and so a V8 coverage
// run (NODE_V8_COVERAGE=… node tests/test_page.js) can pick the page script out.
vm.runInContext(pageScript + expose, context, { filename: "web_page.js" });
const x = sandbox.__x;
const logEl = x.logEl;
// Declared after FakeWebSocket's class body only because the class closes over
// it: send() runs long after this line has, and a hoisted `let` would be a
// temporal-dead-zone trap waiting for a socket that opens during page load.
const sentFrames = [];
const idleStatus = () => x.onFrame('S{"armed":1,"usb":1,"typing":0}');
const busyStatus = n => x.onFrame(`S{"armed":1,"usb":1,"typing":${n}}`);
// ---------------------------------------------------------------------------
let checks = 0, failures = 0;
const check = (cond, label) => {
checks++;
if (!cond) { failures++; console.log(" FAIL " + label); }
};
const checkEq = (a, b, label) => {
checks++;
if (a !== b) {
failures++;
console.log(` FAIL ${label}: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
};
const frames = () => sentFrames.filter(f => f.startsWith("P="));
const mframes = () => sentFrames.filter(f => f.startsWith("M="));
const textOf = f => Buffer.from(f.slice(f.indexOf(":") + 1), "hex").toString();
// The pad the page asks for Pointer Lock, wired the way a browser wires it: the request
// sets pointerLockElement and fires pointerlockchange.
function givePointerLock() {
const pad = byId("mPad");
pad.requestPointerLock = () => {
doc.pointerLockElement = pad;
docListeners.fire("pointerlockchange", {});
return Promise.resolve();
};
return pad;
}
function takePointerLockAway() {
doc.pointerLockElement = null;
docListeners.fire("pointerlockchange", {});
}
const move = (dx, dy) => docListeners.fire("mousemove", { movementX: dx, movementY: dy });
const btn = (b, down) => docListeners.fire(down ? "mousedown" : "mouseup",
{ button: b, preventDefault() {} });
const scroll = (dy, dx, mode) => docListeners.fire("wheel",
{ deltaY: dy, deltaX: dx || 0, deltaMode: mode || 0, preventDefault() {} });
const wait = ms => new Promise(r => setTimeout(r, ms));
console.log("the log stays readable");
logEl.children = []; // the page logs a prompt at load; start clean
x.log("");
x.log(" ");
x.log("\n\t ");
x.log(undefined);
x.log(null);
checkEq(logEl.childElementCount, 0,
"empty, whitespace-only and missing messages never render a line");
x.log("first line");
checkEq(logEl.childElementCount, 1, "a real message renders");
x.log("first line");
x.log("first line");
checkEq(logEl.childElementCount, 1, "an identical line is not repeated");
check(/×3$/.test(logEl.lastElementChild.textContent), "the repeat is counted, not stacked");
x.log("second line");
checkEq(logEl.childElementCount, 2, "a different message still gets its own line");
for (let i = 0; i < 400; i++) x.log("flood " + i);
check(logEl.childElementCount <= 150, "the log is bounded, so it cannot grow all day");
console.log("characters");
checkEq(x.untypeableChars("hello world\n").length, 0, "plain text and newlines are fine");
checkEq(x.untypeableChars("\t\ta\t").length, 0, "tabs are fine");
checkEq(x.untypeableChars("café").join(""), "é", "an accent is caught");
checkEq(x.untypeableChars("a\u2019b").join(""), "\u2019", "a curly quote is caught");
checkEq(x.untypeableChars("\u{1f600}").length, 1, "an astral character is caught");
byId("paste").value = "line1\r\nline2";
checkEq(x.pasteText(), "line1\nline2", "CRLF becomes one Enter, not two");
byId("paste").value = "a\rb";
checkEq(x.pasteText(), "a\nb", "and so does a lone CR");
checkEq(x.hexify("hi"), "6869", "hex encoding");
checkEq(x.hexify("a\n"), "610a", "a newline is one byte on the wire");
console.log("key names in the log");
checkEq(x.describePress("KeyA"), "KeyA", "a bare key is named by its code name");
checkEq(x.describePress("Enter"), "Enter", "Enter is named");
checkEq(x.describePress("ArrowRight"), "ArrowRight", "arrows are named");
checkEq(x.inPageField({ target: { tagName: "TEXTAREA" } }), true, "the text box is the page's own");
checkEq(x.inPageField({ target: { tagName: "INPUT" } }), true, "the passphrase box is too");
checkEq(!!x.inPageField({ target: { tagName: "BODY" } }), false, "the page itself forwards");
checkEq(!!x.inPageField({ target: null }), false, "a missing target does not throw");
(async () => {
// Bring the link up the way the page does, so send()'s guard is the real guard.
byId("token").value = "hunter2";
x.connect();
await wait(5); // onopen fires, and clears `authed`
x.authed = true;
x.armed = true;
byId("trailingEnter").checked = false;
byId("stripBad").checked = false;
console.log("sending text");
byId("paste").value = "hello";
sentFrames.length = 0;
let p = x.sendPaste();
await wait(5);
checkEq(frames().length, 1, "the frame goes out once the board is idle");
checkEq(frames()[0], "P=12:68656c6c6f", "carrying the text and the requested pace");
idleStatus();
await p;
checkEq(x.typing, false, "and the run reports itself finished");
// A paste longer than one frame: the board's `typing` count gates the next piece,
// so a board that is still busy must not be handed more to type.
const long = "x".repeat(300);
byId("paste").value = long;
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
checkEq(frames().length, 1, "one frame out while the board is idle");
busyStatus(118);
await wait(20);
checkEq(frames().length, 1, "the board saying it is still typing holds the next frame back");
for (let i = 0; i < 20 && x.typing; i++) { idleStatus(); await wait(3); }
await p;
const fs2 = frames();
checkEq(fs2.length, Math.ceil(300 / x.TEXT_FRAME_MAX), "300 characters is three frames");
for (const f of fs2) {
check(f.length <= 256, `every frame fits one WebSocket message (${f.length} bytes)`);
check(/^P=12:[0-9a-f]+$/.test(f), "every frame is well formed");
check(f.length <= 5 + 2 * x.TEXT_FRAME_MAX, "and none carries more than a frame's worth");
}
checkEq(fs2.map(f => f.slice(f.indexOf(":") + 1)).join("").length, 600,
"the frames between them carry every character exactly once");
checkEq(fs2.map(textOf).join(""), long, "and that is the text that was pasted");
console.log("refusing what it cannot type");
byId("paste").value = "bad\u2014text";
sentFrames.length = 0;
await x.sendPaste();
checkEq(frames().length, 0,
"an em dash refuses the whole paste -- nothing partial goes out");
byId("stripBad").checked = true;
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
for (let i = 0; i < 10 && x.typing; i++) { idleStatus(); await wait(3); }
await p;
checkEq(frames().map(textOf).join(""), "badtext", "and the opt-in drop sends the rest");
console.log("stopping");
byId("stripBad").checked = false;
byId("paste").value = "nope";
sentFrames.length = 0;
x.armed = false;
await x.sendPaste();
checkEq(frames().length, 0, "while disarmed, not one character is offered");
x.armed = true;
byId("paste").value = "y".repeat(300);
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
x.abortTyping("harness: stop typing");
idleStatus();
await p;
checkEq(frames().length, 1, "a stopped run does not send the frames that were left");
checkEq(x.typing, false, "and it ends, rather than hanging");
x.armed = true;
byId("paste").value = "z".repeat(300);
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
x.setArmed(false, "harness: disarmed mid-run"); // the board disarmed under us
idleStatus();
await p;
checkEq(frames().length, 1, "a disarm mid-run stops the run");
console.log("the mouse");
givePointerLock();
x.setArmed(true, "harness: armed");
x.authed = true;
// A capture with nothing behind it must not produce a single frame.
x.setArmed(false, "harness: disarmed");
sentFrames.length = 0;
x.takeMouse();
await wait(2);
checkEq(mframes().length, 0, "a disarmed bridge is not offered the mouse");
checkEq(x.mCaptured, false, "and the pointer is not taken");
x.setArmed(true, "harness: armed");
sentFrames.length = 0;
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, true, "capture takes the pointer when the bridge is armed");
checkEq(mframes().length, 0, "taking the pointer sends no movement nobody made");
move(30, -4);
sentFrames.length = 0;
x.flushMouse();
checkEq(mframes().length, 1, "one flush of one small movement is one frame");
checkEq(mframes()[0], "M=00:30:-4:0:0", "the button byte first, then this frame's movement");
sentFrames.length = 0;
x.flushMouse();
checkEq(mframes().length, 0, "a frame that says nothing is not sent at all");
// A fast flick: the report carries one signed byte per axis, so the tab splits it.
move(300, -260);
sentFrames.length = 0;
x.flushMouse();
const big = mframes();
const sum = i => big.reduce((a, f) => a + Number(f.split(":")[i]), 0);
checkEq(sum(1), 300, "300 px of X arrives as 300 px, split across reports");
checkEq(sum(2), -260, "and so does the Y");
check(big.every(f => [1, 2, 3, 4].every(i => Math.abs(Number(f.split(":")[i]))
<= x.MOUSE_DELTA_MAX)),
"and no single report asks for more than one signed byte");
// Order is the whole difference between a click and a drag.
move(12, 0);
sentFrames.length = 0;
btn(0, true);
const order = sentFrames.filter(f => f.startsWith("M="));
checkEq(order.length, 2, "a press with movement behind it is two frames");
checkEq(order[0], "M=00:12:0:0:0", "the movement first, under the old button state");
checkEq(order[1], "M=01:0:0:0:0", "then the button, so the target sees press then move");
checkEq(x.mButtons, 0x01, "left is held");
sentFrames.length = 0;
btn(0, false);
checkEq(mframes()[0], "M=00:0:0:0:0", "release restates the whole button byte as nothing");
checkEq(x.mButtons, 0, "and the tab agrees");
// Two buttons at once, and letting go of one: the byte that goes out is the state,
// not the change. (DOM button 0 = left -> 0x01, 2 = right -> 0x02, 1 = middle -> 0x04.)
btn(0, true);
btn(2, true);
sentFrames.length = 0;
btn(0, false);
checkEq(mframes()[0], "M=02:0:0:0:0", "right stays down when the left button goes up");
sentFrames.length = 0;
scroll(100, 0); // one detent's worth of pixels
scroll(100, 0);
scroll(25, 0); // not yet a detent: carried, not dropped
x.flushMouse();
checkEq(mframes()[0], "M=02:0:0:2:0", "wheel travels in detents and the remainder waits");
sentFrames.length = 0;
scroll(3, 0, 1); // deltaMode 1: lines, one detent each
x.flushMouse();
checkEq(mframes()[0], "M=02:0:0:3:0", "a line is a detent, whatever the pixel density");
// The fail-safes. Each one has to let go of the buttons, whatever else it does.
btn(2, false);
btn(0, true);
checkEq(x.mButtons, 0x01, "holding left again for the release tests");
sentFrames.length = 0;
takePointerLockAway();
checkEq(mframes()[0], "M=00:0:0:0:0",
"losing the pointer lock (Esc, focus, the browser) releases every button");
checkEq(x.mCaptured, false, "and the tab stops capturing");
x.takeMouse();
await wait(2);
btn(1, true);
sentFrames.length = 0;
doc.pointerLockElement = null; // the browser already dropped it; blur follows
winListeners.fire("blur", {});
check(mframes().includes("M=00:0:0:0:0"),
"a blur releases the buttons too");
checkEq(x.mCaptured, false, "and ends the capture");
// The stay-armed-on-blur option is an exception for the keyboard and must not become
// one for the cursor: a pointer nobody is looking at is not a pointer being aimed.
byId("keepArmed").checked = true;
x.setArmed(true, "harness: armed");
x.takeMouse();
await wait(2);
btn(0, true);
sentFrames.length = 0;
winListeners.fire("blur", {});
check(mframes().includes("M=00:0:0:0:0"),
"stay-armed-on-blur keeps the keyboard armed and still releases the mouse");
checkEq(x.mCaptured, false, "the capture does not survive it either");
byId("keepArmed").checked = false;
x.setArmed(true, "harness: armed");
x.takeMouse();
await wait(2);
btn(0, true);
sentFrames.length = 0;
x.setArmed(false, "harness: disarmed with a button held");
checkEq(x.mCaptured, false, "disarm ends the capture");
checkEq(x.mButtons, 0, "and the tab's own button state");
// The board releases the buttons itself when it disarms (src/output.h), so the tab
// sends an M= frame to a bridge that has already answered: silence here is correct.
checkEq(mframes().length, 0,
"disarm does not beg a bridge that has already let go");
// A board that cannot report a mouse: refused up front, and a running capture ended.
x.setArmed(true, "harness: armed");
sentFrames.length = 0;
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, false, "a board with no mouse interface does not take the pointer");
checkEq(mframes().length, 0, "and gets no frames");
checkEq(byId("mCapture").disabled, true, "the button says so by being disabled");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":1}');
checkEq(byId("mCapture").disabled, false,
"the button comes back when the board says it can report a mouse");
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, true, "and it captures again once the board can report one");
sentFrames.length = 0;
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
checkEq(x.mCaptured, false,
"a capture that the board can no longer honour is ended, not left hanging");
// Every frame the tab can emit has to be one the firmware parses: five fields, a hex
// button byte, signed decimal movements, and short enough for one WebSocket message.
const shape = /^M=[0-9a-f]{2}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}$/;
let malformed = 0, tooLong = 0;
for (const f of mframes()) {
if (!shape.test(f)) malformed++;
if (f.length > 256) tooLong++;
}
checkEq(malformed, 0, "every mouse frame this tab emitted parses");
checkEq(tooLong, 0, "and every one fits in one WebSocket message");
x.mCaptured = false;
x.setArmed(false, "harness: done");
console.log("the connect path");
// The board serves the page over https:// (and, in a -D KR_TLS=0 build, over http://).
// The socket scheme follows the page's own origin: an https: page that opened ws://
// would be blocked by the browser as mixed content, and the failure would look exactly
// like a dead board. The other direction is the guard task 05 added: a TLS build's
// page on an http: origin is bytes nobody legitimate served (a TLS board answers :80
// with a redirect), so it refuses -- and the rollback build, whose marker is 0,
// keeps its ws:// because that is the documented way back.
checkEq(Buffer.byteLength(served), Buffer.byteLength(rollbackPage),
"both builds' pages are the same byte count -- the marker branches are " +
"equal length, so every pinned page count is one number");
byId("token").value = "hunter2"; // connect() refuses without one
sandbox.location.protocol = "https:";
FakeWebSocket.instances.length = 0;
x.connect();
await wait(5);
let inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
check(inst !== undefined, "connect() opened a socket on an https: origin");
checkEq(inst.url, "wss://keyrelay.local/ws",
"an https: origin connects over wss:// -- the encrypted controller channel");
// The passphrase travels in the first frame, never in a URL: a query string lands in
// proxy logs, browser history and the Referer of anything the page later loads.
check(!/token|pass/i.test(inst.url), `the connect URL carries no credential (${inst.url})`);
// An http: origin on the TLS build: no socket, no frame, and the reason is said.
FakeWebSocket.instances.length = 0;
sentFrames.length = 0;
sandbox.location.protocol = "http:";
x.connect();
await wait(5);
checkEq(FakeWebSocket.instances.length, 0,
"a clear-loaded TLS page opens no socket -- it does not fall back to plaintext");
checkEq(sentFrames.length, 0,
"and sends not one frame -- above all not the T<passphrase>");
check(String(logEl.lastElementChild && logEl.lastElementChild.textContent)
.includes("this page arrived in the clear; reload over https"),
"and it says why, in the words the operator can act on");
// boot() on the same origin: the passphrase box itself comes disabled, with the
// banner up -- refusing the socket while still accepting the passphrase would be
// half a guard.
byId("token").disabled = false;
byId("connect").disabled = false;
byId("clearWarn").hidden = true;
x.boot();
checkEq(byId("token").disabled, true, "a clear-loaded page will not take a passphrase");
checkEq(byId("connect").disabled, true, "and its connect button is inert");
checkEq(byId("clearWarn").hidden, false, "and the banner explains the refusal");
checkEq(FakeWebSocket.instances.length, 0, "boot() on http: connects to nothing");
// The rollback build (-D KR_TLS=0) keeps today's behaviour on the same origin:
// ws:// is its documented way back, opted into by the build flag itself.
x.tlsBuild = 0;
byId("token").disabled = false;
byId("connect").disabled = false;
byId("clearWarn").hidden = true;
FakeWebSocket.instances.length = 0;
x.connect();
await wait(5);
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
check(inst !== undefined, "the KR_TLS=0 page still connects on an http: origin");
if (inst) checkEq(inst.url, "ws://keyrelay.local/ws",
"the rollback page speaks plaintext ws:// because that is the rollback");
x.tlsBuild = 1;
sandbox.location.protocol = "https:"; // as the shim started
// The plaintext spelling exists exactly once in the served bytes -- the fallback
// the rollback build reaches. A TLS board never hands a browser an http: origin
// (port 80 answers a redirect and a 426, pinned in tests/test_relayctl.py and
// walked in tests/test_conn.cpp), so one occurrence is the whole rollback and
// two would be a second, ungated path.
checkEq(served.split("ws://").length - 1, 1,
"the served page contains exactly one plaintext ws:// -- the rollback's path");
checkEq(served.split("wss://").length - 1, 1, "and exactly one wss://, its counterpart");
checkEq(served.split("new WebSocket(").length - 1, 1,
"one socket is opened in the whole page, and it opens the url just built");
check(served.includes("var KR_TLS_BUILD=1;"),
"the TLS build's assembled page carries its marker");
check(rollbackPage.includes("var KR_TLS_BUILD=0;"),
"and the assembled rollback page carries the other one");
console.log("the channel pill and the certificate line");
// Both render from the status frame only -- the page has no other way to know,
// and an operator compares these bytes against `cert` on the serial console.
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1,"fp":"0123abcd456789ef"}');
checkEq(byId("pTls").textContent, "tls", "a TLS board's pill says tls");
checkEq(byId("pTls").className, "pill on", "and it is the calm green state");
checkEq(byId("certFp").hidden, false, "the certificate line is up");
check(byId("certFp").textContent.includes("0123abcd456789ef"),
"the line renders exactly the fp the status frame carried");
check(byId("certFp").textContent.includes("cert"),
"and names where to confirm it (the serial `cert` command)");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":0,"fp":"0123abcd456789ef"}');
checkEq(byId("pTls").textContent, "plaintext", "a board without TLS says plaintext");
check(byId("pTls").className.includes("bad"),
"and says it in the alarming colour -- unencrypted is the state to notice");
checkEq(byId("certFp").hidden, true, "with tls:0 no fingerprint line renders, ...");
checkEq(byId("certFp").textContent, "",
"... even when the frame carried one anyway (the page does not launder it)");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1}');
checkEq(byId("certFp").hidden, true,
"and a tls:1 frame with no fp renders no line either -- nothing is invented");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"tls":1,"fp":"00aa11bb22cc33dd"}');
checkEq(byId("certFp").textContent.includes("00aa11bb22cc33dd"), true,
"a new status re-renders the new fingerprint");
console.log("the busy board and the take-over");
// Phase 03, page side. The board reports "held" to a socket it authenticated but
// did not adopt (the status frame belongs to the controller alone), and to a
// session it displaces (E12). The page's whole job is to make that visible, offer
// one deliberate click, and NOT fight: no retry loop, no auto-reclaim, and --
// until the click -- not one frame about the bridge.
// `busy` in a status frame, seen by a page that is not the controller, is the
// link pill's third honest state. A controller reading busy:1 is reading itself,
// and keeps "linked" -- the OK handler says so; applyStatus must not contradict it.
byId("takeover").hidden = true;
x.authed = false; x.armed = false;
x.onFrame('S{"armed":0,"usb":1,"typing":0,"busy":1}');
checkEq(byId("pLink").textContent, "held by another controller",
"a busy status frame drives the link pill's third state");
check(byId("pLink").className.includes("bad"),
"and it is not the calm 'linked' state");
checkEq(x.busyKnown, true, "and the page now knows the board is held");
x.onFrame("OK"); // adopted: the ordinary path
checkEq(byId("pLink").textContent, "linked",
"the adopted controller reads its own busy as linked, not as a rival");
// A refused upgrade: the board parks this socket as a pending claim -- `OK`, then
// the E6-and-drop that answers the ordinary `H` on a claim slot -- and the page
// must say "held", offer the button, and stop, instead of retrying every 2 s.
sandbox.location.protocol = "https:";
byId("token").value = "hunter2";
FakeWebSocket.instances.length = 0;
sentFrames.length = 0;
x.authed = false; x.armed = false;
x.connect();
await wait(5);
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
inst.onmessage({ data: "OK" }); // authenticated -- as a claim
const afterConnect = sentFrames.length; // T + H from the ordinary flow
inst.onmessage({ data: "E6:a pending claim may not send that" });
inst.onclose(); // and the claim is dropped
checkEq(byId("pLink").textContent, "held by another controller",
"the un-adopted socket reads the board's silence as 'held'");
checkEq(byId("takeover").hidden, false,
"and the take-over button appears -- only now that the board said held");
checkEq(sentFrames.length, afterConnect,
"after the refusal the page sends nothing on its own -- no retry, no X");
checkEq(x.autoReconnect, false,
"the reconnect loop is off: re-claiming automatically is a fight");
checkEq(FakeWebSocket.instances.length, 1, "and no second socket was opened");
// The one deliberate click: open, authenticate, ask for the bridge. T then X,
// in that order, and nothing else -- no arm, no state, not even an H.
byId("takeover").fire("click");
await wait(5);
checkEq(FakeWebSocket.instances.length, 2, "one click opened one takeover socket");
const t = FakeWebSocket.instances[1];
checkEq(sentFrames.slice(afterConnect).join(","), "Thunter2,X",
"the click sends T then X, in that order, and nothing else");
checkEq(byId("takeover").hidden, true, "and the button retires once the act is sent");
t.onmessage({ data: "OK" }); // adopted: OK, then the status
t.onmessage({ data: 'S{"armed":0,"usb":1,"typing":0,"busy":1}' });
checkEq(x.armed, false,
"the newcomer behaves like any controller, starting DISARMED");
checkEq(byId("pLink").textContent, "linked", "and it holds the bridge now");
const tlog = logEl.children.map(c => String(c.textContent)).join("\n");
check(tlog.includes("bridge taken over") && tlog.includes("DISARMED"),
"the log names what happened, including the state it landed in");
// E3 keeps its meaning next to E12's: "you were refused -- no room for a claim",
// not "you were dropped". It still disarms a page that asks; it does not claim
// this session was displaced. (Checked BEFORE the E12 walk, so "not a takeover"
// means E3 did not set it, not that an earlier takeover was already on record.)
x.setArmed(true, "harness: armed");
x.onFrame("E3:no room");
checkEq(x.armed, false, "E3 still refuses the ask and disarms");
checkEq(x.takenOver, false, "but E3 is not a takeover -- nothing displaced anyone");
const e3log = logEl.children.map(c => String(c.textContent)).join("\n");
check(e3log.includes("no room for a claim"),
"and its log line says 'no room', the sentence that distinguishes it from E12");
// The incumbent's side, which this page now is: E12 disarms, releases the mouse,
// ends a typing run mid-flight, marks the pill, and keeps the page down -- the
// same gesture the board itself ran through the funnel.
x.setArmed(true, "harness: armed");
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, true, "set-up: this session holds the mouse");
byId("paste").value = "run".repeat(100); // three frames' worth
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
x.onFrame("E12:session taken over");
checkEq(x.armed, false, "E12 disarms the incumbent immediately");
checkEq(x.mCaptured, false, "and drops the mouse");
x.onFrame('S{"armed":0,"usb":1,"typing":0}'); // release the run's waiter
await p;
checkEq(x.typing, false, "and ends the typing run");
checkEq(frames().length, 1, "the run stops -- no further P= frames after a takeover");
checkEq(byId("pLink").textContent, "taken over", "and the pill says what happened");
const ilog = logEl.children.map(c => String(c.textContent)).join("\n");
check(ilog.includes("another controller took over"),
"and the log carries the reason, in the incumbent's own words");
checkEq(x.autoReconnect, false, "and this page stays down until someone clicks");
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
const nSockets = FakeWebSocket.instances.length;
inst.onclose();
checkEq(FakeWebSocket.instances.length, nSockets,
"the close after E12 opens nothing -- no auto-reclaim");
checkEq(byId("pLink").textContent, "taken over", "the pill keeps the fact");
checkEq(byId("takeover").hidden, false,
"coming back is one deliberate click away, never zero");
x.setArmed(false, "harness: done");
console.log("arming and the S= state frames");
// Invariants 1 and 2, measured as bytes on the wire: disarmed forwards nothing,
// arming is a REQUEST the board answers (the page never flips its own state),
// and every keystroke restates the WHOLE state -- S=<mods>:<usages> -- so the
// next frame corrects any lost one. The safety section below pins what the page
// says; this pins what it sends, which is the half a restructure of the markup
// could silently have moved.
const sframes = () => sentFrames.filter(f => f.startsWith("S="));
const stateWire = []; // every S= frame emitted this section
const key = (type, code, extra) => {
const before = sentFrames.length;
docListeners.fire(type, Object.assign(
{ code, target: { tagName: "BODY" }, preventDefault() {} }, extra || {}));
for (const f of sentFrames.slice(before)) if (f.startsWith("S=")) stateWire.push(f);
};
sandbox.location.protocol = "https:";
byId("token").value = "hunter2";
x.authed = false; x.armed = false;
FakeWebSocket.instances.length = 0;
x.connect();
await wait(5); // onopen: T + H go out, authed stays false
sentFrames.length = 0;
byId("arm").fire("click");
checkEq(sentFrames.length, 0,
"an ARM click before the board has authenticated this socket asks for nothing");
const live = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
live.onmessage({ data: "OK" });
checkEq(x.authed, true, "the board's OK is what links the page");
sentFrames.length = 0;
byId("arm").fire("click");
checkEq(sentFrames.join(","), "A1",
"the ARM click sends A1 and nothing else -- one frame, one command");
checkEq(x.armed, false,
"and the page does not arm itself on its own say-so -- no optimistic flip, " +
"the status heartbeat confirms or it did not happen");
x.onFrame('S{"armed":1,"usb":1,"typing":0}');
checkEq(x.armed, true, "the board's status frame is what arms the page");
sentFrames.length = 0;
byId("arm").fire("click"); // click again while armed
checkEq(sentFrames.join(","), "A0,R",
"clicking again asks to disarm (A0) and releases every key (R) -- still no local flip");
x.onFrame('S{"armed":0,"usb":1,"typing":0}');
checkEq(x.armed, false, "and the disarm lands when the board says so");
sentFrames.length = 0;
key("keydown", "KeyA");
key("keyup", "KeyA");
checkEq(sentFrames.length, 0,
"while disarmed a whole press forwards nothing -- not even a restatement");
x.onFrame('S{"armed":1,"usb":1,"typing":0}'); // the board's own button arms it
checkEq(x.armed, true, "a board-side arm is mirrored here without any click");
sentFrames.length = 0;
key("keydown", "KeyA");
checkEq(sframes().join(","), "S=00:04",
"a press while armed restates the whole state: no modifiers, one usage (KeyA = 0x04)");
key("keydown", "KeyA", { repeat: true });
checkEq(sframes().length, 1,
"autorepeat is not a state change -- the held state already says the key is down");
key("keydown", "KeyB");
checkEq(sframes().slice(-1)[0], "S=00:04,05",
"the next restatement carries BOTH usages in press order, not an event for B alone");
key("keyup", "KeyA");
checkEq(sframes().slice(-1)[0], "S=00:05",
"a release is a full restatement too: what is still held, nothing about A");
key("keydown", "ShiftLeft");
checkEq(sframes().slice(-1)[0], "S=02:05",
"a modifier enters the mods byte (Shift = 0x02) and the held usage survives it");
key("keyup", "ShiftLeft");
checkEq(sframes().slice(-1)[0], "S=00:05", "and leaves it the same way");
key("keyup", "KeyB");
checkEq(sframes().slice(-1)[0], "S=00:",
"the EMPTY restatement travels -- keys-up is a state, not an absence of event");
sentFrames.length = 0;
key("keydown", "KeyA", { target: { tagName: "TEXTAREA" } });
checkEq(sframes().length, 0,
"a keystroke in the page's own text box belongs to the page, not to the target");
key("keydown", "ShiftLeft");
checkEq(sframes().slice(-1)[0], "S=02:", "set-up: Shift went down outside the field");
key("keyup", "ShiftLeft", { target: { tagName: "TEXTAREA" } });
checkEq(sframes().slice(-1)[0], "S=00:",
"a modifier released OVER the page's field is still let go -- releasing Shift " +
"above the text box must not strand it held on the target");
key("keydown", "KeyA"); // hold a key, then panic
sentFrames.length = 0;
key("keydown", "Escape", { ctrlKey: true, altKey: true });
checkEq(sentFrames.join(","), "R,A0",
"Ctrl+Alt+Esc releases every key (R) before disarming (A0), in that order");
checkEq(x.armed, false, "and the page lands disarmed with the keys reset");
check(stateWire.length > 8 &&
stateWire.every(f => /^S=[0-9a-f]{2}:(?:[0-9a-f]{2}(?:,[0-9a-f]{2})*)?$/.test(f)),
"every state frame this section emitted is the wire form the firmware parses " +
"(S=<hex mods>:<hex usages>) -- test_proto owns that parser");
console.log("responsive layout");
// Byte-level layout claims. This suite is the only place the page can be seen
// without a browser, and missing CSS fails silently -- it just renders the
// laptop layout onto a phone. Each check names the mechanism it pins, so a
// green run says the mechanism is in the served bytes, not that somebody felt
// good about a breakpoint.
function mediaBlock(query) {
const at = served.indexOf("@media " + query);
if (at < 0) return null;
const start = served.indexOf("{", at);
let depth = 0;
for (let i = start; i < served.length; i++) {
if (served[i] === "{") depth++;
else if (served[i] === "}" && --depth === 0) return served.slice(start, i + 1);
}
return null; // unbalanced braces: report as absent
}
const viewportMeta =
(served.match(/<meta name="viewport" content="([^"]*)"/) || [])[1] || "";
check(viewportMeta.includes("width=device-width"),
"the viewport sizes to the device instead of a fake desktop canvas");
check(viewportMeta.includes("viewport-fit=cover"),
"the viewport meta carries viewport-fit=cover -- without it env(safe-area-inset-*) " +
"is always 0 and the home indicator/notch sits on top of the page");
check(!/maximum-scale|user-scalable=no/.test(viewportMeta),
"and it does not disable pinch zoom -- a thumb floor must not be paid for with zoom");
const narrow = mediaBlock("(max-width:720px)");
check(narrow !== null, "the one 720 px breakpoint exists");
check(narrow !== null && /\.grid\s*\{[^}]*grid-template-columns\s*:\s*1fr/.test(narrow),
"at the breakpoint the card grid is forced to one column (the base rule keeps " +
"auto-fit/minmax(240px,1fr) for the laptop)");
check(narrow !== null && /main\s*\{[^}]*display\s*:\s*flex[^}]*flex-direction\s*:\s*column/.test(narrow),
"and main becomes a flex column there -- position:sticky on a grid item is pinned " +
"inside its own grid area and never sticks, so this display switch is what makes " +
"the ARM bar sticky for real");
check(narrow !== null &&
/input\[type=password\]\s*,\s*input\[type=text\]\s*,\s*textarea\s*\{[^}]*font-size\s*:\s*16px/.test(narrow),
"inputs and the text box step to 16 px below the breakpoint -- iOS Safari zooms " +
"the page on focusing a smaller field and never zooms back out");
check(narrow !== null && /#log\s*\{[^}]*max-height\s*:/.test(narrow),
"the log caps its own height on a phone instead of pushing the ARM bar off screen");
const coarse = mediaBlock("(pointer:coarse)");
check(coarse !== null, "a coarse-pointer query exists for the thumb floor");
check(coarse !== null &&
/button\.k[^{}]*\.pill[^{}]*input[^{}]*summary\s*\{[^}]*min-height\s*:\s*44px/.test(coarse),
"buttons, pills, inputs AND the folded notes' summary get a 44 px height floor on " +
"touch devices; the fine-pointer page is untouched");
check(coarse !== null && /min-width\s*:\s*44px/.test(coarse),
"and narrow targets (chord buttons, checkboxes) get the matching width floor");
// Why summary is in that list rather than a nice-to-have: task 02 moved five safety
// sentences behind a <details>, which made the summary the only way to read them on a
// phone. Its natural height is 17 px -- a tap target a thumb does not hit, in a build
// whose byte-level layout assertions would otherwise still all have been green. The
// rendered height is measured in a real browser at 360 px (.agents/HW_VERIFICATION.md);
// this pins that the rule the measurement depends on is still in the served bytes.
check(coarse !== null && /summary\s*\{[^}]*min-height\s*:\s*44px/.test(coarse) &&
/summary\s*\{[^}]*padding\s*:/.test(coarse),
"the fold's summary is a thumb-sized target, not the 17 px line of text it is by " +
"default -- it is the only route to the folded safety notes");
check(/\.arm\s*\{[^}]*position\s*:\s*sticky[^}]*top\s*:\s*0[^}]*z-index/.test(served),
"the ARM bar is sticky at the top of the scroll, above the cards -- the " +
"armed/disarmed state must survive any scroll position");
check(/#log\s*\{[^}]*overscroll-behavior\s*:\s*contain/.test(served),
"scrolling the log does not scroll the page out from under the ARM bar");
check(/padding-bottom\s*:\s*calc\(14px \+ env\(safe-area-inset-bottom\)\)/.test(served),
"main's bottom padding carries the safe-area inset, so the last card clears the " +
"home indicator (viewport-fit=cover above is what makes env() mean anything)");
check(/button\.k\s*\{[^}]*touch-action\s*:\s*manipulation/.test(served),
"controls set touch-action:manipulation -- no 300 ms double-tap-to-zoom between presses");
check(/button\.k\s*\{[^}]*user-select\s*:\s*none/.test(served) &&
/\.mousepad\s*\{[^}]*user-select\s*:\s*none/.test(served),
"pressing a control or the pad does not start a text selection");
check(/<main>\s*<button class="arm" id="arm"/.test(served),
"the ARM bar is still the first element in main -- on every screen it is the " +
"first thing below the header, no horizontal scroll assumed");
const narrowKv = narrow !== null && /\.kv\s*\{[^}]*display\s*:\s*flex[^}]*flex-wrap/.test(narrow);
check(narrowKv,
"the State card's counters collapse to a wrapped summary at the breakpoint " +
"instead of eight stacked rows -- they stay visible because they are the " +
"'is it typing' evidence, only shorter");
check(narrow !== null && /\.rowhead\s*\{[^}]*gap\s*:\s*\d+px/.test(narrow),
"and the Log card's option row tightens so the card is two lines on a phone: " +
"the options, then the log");
console.log("the safety statements that must never be tidied away");
// Every long hint on this page exists because somebody got hurt without it, and a
// page that reads as a wall of text invites exactly the edit that removes one. So
// each statement is pinned by the words the operator has to read, BEFORE the prose
// is folded -- the same assertions have to pass on the untrimmed page (they prove
// the sentences were there) and on the trimmed one (they prove the fold kept them).
//
// They match text, not markup: tags stripped, entities decoded, whitespace
// collapsed, case folded. That is what lets a sentence survive a rewrap and
// survive moving inside a <details>, while still failing the moment it is deleted.
// The matcher reads the served markup string rather than the shim's element tree --
// <details> needs no shim, and a safety note one tap away is still on the page as
// long as it is in the bytes the board serves. Anything matched here from the
// script alone would also match a comment, so the script's safety statements are
// pinned against `pageScript` explicitly instead.
const decode = s => s
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
.replace(/&mdash;/g, "\u2014").replace(/&ndash;/g, "\u2013")
.replace(/&rsquo;|&lsquo;/g, "'").replace(/&rdquo;|&ldquo;/g, '"')
.replace(/&middot;/g, "\u00b7").replace(/&nbsp;/g, " ")
.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"')
.replace(/&amp;/g, "&");
const pageTextOf = html => decode(html
.replace(/<script[\s\S]*?<\/script>/gi, " ") // pinned against pageScript instead
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim().toLowerCase();
const PAGE = pageTextOf(served);
const says = (...phrases) => phrases.every(p => PAGE.includes(p));
// (1) the arming banner -- the one paragraph that must never sit behind a tap.
const bannerHtml =
(served.match(/<div class="banner" id="banner">([\s\S]*?)<\/div>/) || [])[1] || "";
const bannerText = pageTextOf(bannerHtml);
check(bannerText.includes("every key"),
"the arming banner says EVERY key this tab presses goes to the target");
check(/\benter\b/.test(bannerText),
"and it names Enter by name -- the key that commits, the one nobody expects to travel");
check(bannerText.includes("the machine the board is plugged into"),
"and it says which machine receives them (not the one you are typing on)");
check(/only on a machine you own|explicitly allowed to type on/.test(bannerText),
"and it keeps the permission rule: a machine you own or may type on");
const mainAt = served.indexOf("<main");
const firstDetails = served.indexOf("<details", mainAt);
const bannerAt = served.indexOf('id="banner"', mainAt);
check(bannerAt > 0 && (firstDetails < 0 || bannerAt < firstDetails),
"the arming banner is not inside or after a <details> -- no safety warning about " +
"arming is ever one tap away from being unseen (holds with no folds at all)");
// (2) disarmed is the default, and what puts it back.
check(says("nothing is forwarded while disarmed"),
"the page still states the default: disarmed, nothing forwarded");
check(says("reload", "focus loss", "socket loss"),
"and still names what disarms you: reload, focus loss, socket loss");
// (3) the pointer: Esc is the rollback, and it releases.
check(says("esc frees the cursor and lets go of every button"),
"the mouse note still says Esc frees the cursor AND lets go of every button");
check(says("never reaches the target"),
"and that Esc belongs to the browser, so the pointer's rollback cannot be typed at the target");
check(says("cannot leave one held down"),
"and that restating the button byte every frame is what stops a lost frame sticking one");
// (4) a run of pasted text is cancelable, out loud.
check(says("abandons the rest of a run"),
"the send-text note still says stop typing / Ctrl+Alt+Esc abandons the rest of a run");
// (5) characters are positions.
check(says("characters are positions", "us layout"),
"the layout caveat is still on the page: positions, so the target must be on a US layout");
// (6) nothing stored, scheduled or replayed.
check(says("no history", "nothing is saved"),
"the send-text note still says nothing is stored, kept or typed later");
check(says("nothing scheduled"),
"and the mouse note still says nothing is scheduled (no macro, no saved gesture)");
// (7) the default-passphrase shout is the board's to raise, never static text: a
// sentence in the markup would shout on a board that has a real passphrase and go
// quiet on one that does not. It replaces the arming banner's own text, so it is
// unmissable and it cannot be folded away with the hints.
check(/if \(s\.dflt\)[\s\S]{0,400}default passphrase/i.test(pageScript),
"the default-passphrase shout exists and is driven by the board's dflt flag");
check(/if \(s\.dflt\)[\s\S]{0,160}\$\("banner"\)\.innerHTML/.test(pageScript),
"and it lands on the arming banner, the one place that is never behind a tap");
check(!PAGE.includes("default passphrase"),
"and no static default-passphrase sentence hides in the markup -- the board raises it or nobody does");
// (8) a takeover cannot arm (phase 03). Guarded here so a later prose fold on the
// Connection card cannot drop the sentence that says so.
check(says("cannot arm it by doing so", "every handover lands disarmed"),
"the Connection card still says taking the bridge cannot arm it -- every handover lands disarmed");
// (9) the clear-loaded-page warning (phase 02) -- hidden until true, but present.
check(says("this page arrived in the clear"),
"the clear-page warning is still in the served bytes");
check(says("reload over"), "and it names the fix rather than just the fear");
// (10) Ctrl+Alt+Del is the one chord that can reboot a target, and its warning is
// said at press time (the tooltip is a tooltip, not a manual, so this is where the
// sentence has to live).
check(/virtual terminal this REBOOTS the target/.test(pageScript),
"the Ctrl+Alt+Del reboot warning is still said when the confirm is armed");
console.log("the folded notes are folded, not deleted");
// Task 02 moved five paragraphs behind <details>. A fold is only safe while
// there IS something behind it, so each one is checked structurally: the count
// is pinned (deleting a whole note is a content decision, not a tidy-up, and it
// must fail a suite), every fold names what is behind the tap, and every fold
// still carries sentences. The safety checks above prove WHICH sentences survive;
// these prove the containers did not hollow out.
const folds = [...served.matchAll(/<details\b[^>]*>([\s\S]*?)<\/details>/gi)]
.map(m => m[0]);
checkEq(folds.length, 5,
"the page carries exactly the five folded notes (chords, passphrase, mouse, " +
"run, shortcuts) -- one fewer is a deleted note, not a tidied one");
const summaries = folds.map(f =>
(f.match(/<summary>([\s\S]*?)<\/summary>/i) || [])[1] || "");
check(summaries.every(t => t.trim().length > 0),
"every fold opens with a summary that names what is behind the tap -- a bare " +
"<details> renders as 'Details', content one guess away");
for (const want of ["why these are buttons", "what the passphrase does and does not buy",
"how a forwarded mouse behaves", "what a run is and is not",
"shortcuts while armed"]) {
check(summaries.join(" | ").toLowerCase().includes(want),
`the folded note "${want}" is still one of the page's five`);
}
folds.forEach((f, i) => {
const body = pageTextOf(f.replace(/<summary>[\s\S]*?<\/summary>/i, " "));
check(body.length >= 120,
`fold ${i + 1} (${summaries[i] || "?"}) still carries its sentences `
+ `(${body.length} chars) -- a folded note is not a deleted one`);
});
console.log("the page's constants are the board's constants");
// Hand-copied numbers, so they are checked against config.h here too: three readers
// of the same arithmetic (here, test_proto.cpp, test_relayctl.py), none of them
// trusting the page to have kept its own copy honest.
const configText = fs.readFileSync(path.join(__dirname, "..", "src", "config.h"), "utf8");
const configNum = name =>
Number((configText.match(new RegExp("\\b" + name + "\\s*=\\s*(\\d+)")) || [])[1]);
checkEq(x.TEXT_FRAME_MAX, configNum("kTextFrameMax"),
"TEXT_FRAME_MAX is still config.h's kTextFrameMax -- drift here is an E8 mid-paste");
checkEq(x.TEXT_JOB_MAX, configNum("kTextJobCap"),
"TEXT_JOB_MAX is still config.h's kTextJobCap");
console.log("element ids");
const markup = served;
const present = new Set();
for (const m of markup.matchAll(/id="([A-Za-z][A-Za-z0-9]*)"/g)) present.add(m[1]);
check(present.size > 15, `the markup's ids were parsed (${present.size})`);
const missing = [...requestedIds].filter(id => !present.has(id)).sort();
checkEq(missing.join(","), "",
"every element the script reached for exists in the page");
// Prove this check bites. The shim hands out an element for ANY id, so a
// mistyped id is invisible everywhere except right here -- which means a broken
// matcher would also read as green. Hand it an id that cannot be in the markup
// and require the same comparison to name exactly that one.
byId("zzCoverSelfTest__notAnElement");
const control = [...requestedIds].filter(id => !present.has(id));
checkEq(control.join(","), "zzCoverSelfTest__notAnElement",
"and the id-coverage comparison itself works: an id missing from the markup " +
"is named by it (the shim would never have noticed)");
console.log(`page ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
})();