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
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
// 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user