Files
keyrelay/tests/test_page.js
T
ducoterra d629a0ff51 phase: 03_session_takeover
**Phase 03 `session_takeover` — final verification pass. All green.**

All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation.

**Completion criteria**
1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`).
2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s.
3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off).
4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion).
5. *Gate green + README/AGENTS say takeover cannot arm* — yes.

**Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records).

**Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`.

**Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`.

**Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`.
2026-11-09 08:57:35 -05:00

754 lines
35 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 a behaviour test, not a rendering test: no CSS, no layout, no real keyboard.
// tests/run_tests.sh skips it 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);
vm.runInContext(pageScript + expose, context);
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("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");
console.log(`page ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
})();