// 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 not a rendering test -- nothing here lays out a page. But it is the only // place the served bytes can be seen without a browser, so the CSS a phone is the // only real judge of (breakpoint, sticky ARM bar, thumb floors) is asserted at the // byte level, the safety sentences are asserted as text read out of the markup, // and the script itself is executed. Behaviour plus those pins is what a raw // string gets. tests/run_tests.sh skips this file 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("