Files
keyrelay/tests/test_page.js
T
ducoterra 9bc8d5213c Initial import: keyrelay, a WiFi-controlled USB HID keyboard
Browser or terminal controller drives an ESP32-S3 that acts as a Logitech
G815 into a target machine running no software. Disarmed by default; arming
requires the passphrase, the board button, or the serial console, and every
fail-safe path cancels in-flight work.

- src/: firmware with pure, host-testable modules (keystate, proto, text,
  ws_codec, sha1), the WebSocket control server, and the served page
- tests/: host suites for the pure modules, relayctl parity, and page JS
- tools/: relayctl terminal controller, serial console, bootloader helper,
  and an evdev target watcher
- platformio.ini: bridge (USB HID) and console (no-HID) environments
- README.md: single source of truth for behaviour, protocol and safety

LLM-Generated: true
2026-11-08 00:20:33 -05:00

279 lines
11 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); },
getAttribute() { return null; }, setAttribute() {}, focus() {},
};
return el;
}
// 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 sandbox = {
document: {
getElementById: byId,
createElement: t => makeEl(t),
querySelectorAll: () => [],
addEventListener() {},
body: makeEl("body"),
hidden: false,
},
window: { addEventListener() {} },
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,
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 textOf = f => Buffer.from(f.slice(f.indexOf(":") + 1), "hex").toString();
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("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);
})();