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
279 lines
11 KiB
JavaScript
279 lines
11 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 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);
|
||
})();
|