Files
keyrelay/tests/test_page.js
T
ducoterra ece0313d02 feat: relative mouse on the HID interface, and the verifier to prove it
The board now enumerates as a boot keyboard plus a relative mouse on the same
HID interface. The pointer is captured, never grabbed: Pointer Lock in the
page, so Esc (the browser's own, never forwarded) always frees it, and every
frame restates the whole button byte so a frame lost in the WiFi cannot leave
a button held. Losing the lock, the tab or the link runs the same release
funnel as a stuck key, and boot protocol or `mouse off` refuses mouse frames
with E11 rather than sending bytes a boot interface would misparse as keys.

src/mouse.h owns the report, src/output.h the queues and the funnel; the page,
relayctl.py and watch_target.py carry the button bits and the evdev decoding.
The verifier's own tables are pinned now — REL_WHEEL is 8, not 2, and the
first version guessed from the report's field order, so a working wheel looked
dead while the summary said no scroll happened.

LLM-Generated: true
2026-11-08 23:27:42 -05:00

497 lines
20 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);
};
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}}`);
class FakeWebSocket {
constructor() {
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 },
location: { protocol: "http:", 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,
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; },
};`;
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
const headerText = fs.readFileSync(headerPath, "utf8");
const from = headerText.indexOf("<script>");
const to = headerText.indexOf("<\/script>", from);
if (from < 0 || to < 0) {
console.error("no <script> block in " + headerPath);
process.exit(1);
}
const pageScript = headerText.slice(from + "<script>".length, to);
const context = vm.createContext(sandbox);
vm.runInContext(pageScript + expose, context);
const x = sandbox.__x;
const logEl = x.logEl;
// ---------------------------------------------------------------------------
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("element ids");
const markup = fs.readFileSync(headerPath, "utf8");
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);
})();