**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.
1105 lines
55 KiB
JavaScript
1105 lines
55 KiB
JavaScript
// 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(/—/g, "\u2014").replace(/–/g, "\u2013")
|
||
.replace(/’|‘/g, "'").replace(/”|“/g, '"')
|
||
.replace(/·/g, "\u00b7").replace(/ /g, " ")
|
||
.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"')
|
||
.replace(/&/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);
|
||
})();
|