// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License. // See LICENSE.md for license information. // SPDX-License-Identifier: MIT // // webDriver.mjs - run a JoeyLib web program (make web) in headless Chromium // and script it over the DevTools protocol, for the web gates and joey-run. // // Usage (Node 22 or later: global WebSocket and fetch; emsdk's node is): // node webDriver.mjs --chrome PATH --url URL [options] // Options: // --seconds N give up after N seconds (default 60) // --until TEXT finish as soon as the program's joeylog.txt holds TEXT // --step STEP one step of a script run in order once the page is // open (instead of --until), given once per step: // wait:S wait S seconds (fractions allowed) // type:TEXT type TEXT, a character every 150 ms, // as key events carrying their text // hold:KEY:MS hold KEY (a DOM key name: p, Escape, // " ") for MS milliseconds // reload load the page again (what a program // kept in IndexedDB comes back) // shot:FILE a PNG of the canvas alone, now // pad:press:CONTROLS, pad:release:CONTROLS // hold or let go of the --gamepads // pads' controls, comma-separated as // joyGate.sh names them (A-left, // B-button1, ...) // pad:unplug the --gamepads pads go away // --screenshot FILE at the end, a PNG of the canvas alone // --gamepads two gamepads (A and B) in place of the browser's own // (navigator.getGamepads), connected from the start // and driven by pad: steps: standard mapping, the left // stick to the end of its travel, button 0 the south // button and button 1 the east // --log FILE at the end, the program's joeylog.txt // --file PATH:OUT at the end, the page's file PATH (its in-memory file // system) into OUT; given once per file // --audio FILE record what the program plays, as a 16-bit stereo WAV: // SDL3 plays through a ScriptProcessorNode, so a script // put in the page before the program's own wraps the // node's onaudioprocess and keeps a copy of every buffer // it fills; the WAV, and the --step script, start with // the first one // Console lines and uncaught exceptions go to stderr as they happen. Exits 0 // when it ran to the end (or reached --until), 1 when --until never came, the // page threw, or Chromium could not be driven. import { spawn } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const POLL_MS = 250; const WAV_HEADER = 44; const WAV_CHANNELS = 2; const WAV_BYTES = 2; const SAMPLE_SCALE = 32767; const TYPE_DELAY_MS = 150; const START_TRIES = 80; const DEFAULT_SECS = 60; const MS_PER_SECOND = 1000; const DEBUG_PORT_MIN = 30000; const DEBUG_PORT_SPAN = 20000; // The program's log, read from the in-memory file system ("" until there is one). const READ_LOG = "(function () { try { return Module.FS.readFile('/joeylog.txt', { encoding: 'utf8' }); } catch (e) { return ''; } })()"; // The recorder --audio puts in the page: every buffer SDL's ScriptProcessorNode // fills, kept as 16-bit stereo samples. const AUDIO_RECORDER = `(function () { var original = AudioContext.prototype.createScriptProcessor; window.joeyAudio = { rate: 0, chunks: [] }; AudioContext.prototype.createScriptProcessor = function (size, inputs, outputs) { var node = original.call(this, size, inputs, outputs); var context = this; var handler = null; Object.defineProperty(node, "onaudioprocess", { get: function () { return handler; }, set: function (fn) { handler = fn; node.addEventListener("audioprocess", function (e) { var out = e.outputBuffer; var left = out.getChannelData(0); var right = out.numberOfChannels > 1 ? out.getChannelData(1) : left; var chunk = new Int16Array(left.length * 2); var i; fn(e); for (i = 0; i < left.length; i++) { chunk[i * 2] = Math.max(-1, Math.min(1, left[i])) * ${SAMPLE_SCALE}; chunk[i * 2 + 1] = Math.max(-1, Math.min(1, right[i])) * ${SAMPLE_SCALE}; } window.joeyAudio.rate = context.sampleRate; window.joeyAudio.chunks.push(chunk); }); } }); return node; }; })();`; // Whether the page has played its first buffer. const AUDIO_STARTED = "(function () { return !!(window.joeyAudio && window.joeyAudio.chunks.length > 0); })()"; // The recording, as base64 of its little-endian samples. const AUDIO_TAKE = "(function () { var a = window.joeyAudio; var n = 0; var all; var at = 0; var bytes; var text = ''; var i; if (!a) { return null; } a.chunks.forEach(function (c) { n += c.length; }); all = new Int16Array(n); a.chunks.forEach(function (c) { all.set(c, at); at += c.length; }); bytes = new Uint8Array(all.buffer); for (i = 0; i < bytes.length; i++) { text += String.fromCharCode(bytes[i]); } return JSON.stringify({ rate: a.rate, data: btoa(text) }); })()"; // The canvas's place on the page, for the screenshot's clip. // The --gamepads pads, put in the page before the program's own scripts: // navigator.getGamepads answers with them, and window.joeyPads holds and // lets go of their controls (stick A or B, then left/right/up/down/button0/ // button1; holding both of an axis's directions is the positive one). const GAMEPADS = `(function () { var BUTTONS = 17; var AXES = 4; var pads = []; function make(index) { var pad = { id: "JoeyLib gate pad " + "AB"[index] + " (STANDARD GAMEPAD)", index: index, connected: true, mapping: "standard", timestamp: 0, axes: [], buttons: [], held: {} }; var i; for (i = 0; i < AXES; i++) { pad.axes.push(0); } for (i = 0; i < BUTTONS; i++) { pad.buttons.push({ pressed: false, touched: false, value: 0 }); } return pad; } function apply(pad) { var h = pad.held; pad.axes[0] = h.right ? 1 : (h.left ? -1 : 0); pad.axes[1] = h.down ? 1 : (h.up ? -1 : 0); [h.button0, h.button1].forEach(function (on, i) { pad.buttons[i] = { pressed: !!on, touched: !!on, value: on ? 1 : 0 }; }); pad.timestamp = performance.now(); } function announce(type, pad) { var e = new Event(type); e.gamepad = pad; window.dispatchEvent(e); } pads = [make(0), make(1)]; Object.defineProperty(navigator, "getGamepads", { configurable: true, value: function () { return [pads[0] || null, pads[1] || null, null, null]; } }); window.joeyPads = { set: function (down, controls) { controls.forEach(function (c) { var pad = pads["AB".indexOf(c.charAt(0))]; if (pad) { pad.held[c.slice(2)] = down; apply(pad); } }); }, unplug: function () { var gone = pads; pads = []; gone.forEach(function (pad) { pad.connected = false; announce("gamepaddisconnected", pad); }); } }; })();`; const CANVAS_RECT = "(function () { var r = document.getElementById('canvas').getBoundingClientRect(); return JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height }); })()"; function parseArgs(argv) { const opts = { seconds: DEFAULT_SECS, steps: [], files: [] }; let i; for (i = 2; i < argv.length; i++) { const name = argv[i]; const value = argv[i + 1]; switch (name) { case "--chrome": opts.chrome = value; i++; break; case "--url": opts.url = value; i++; break; case "--seconds": opts.seconds = Number(value); i++; break; case "--until": opts.until = value; i++; break; case "--step": opts.steps.push(value); i++; break; case "--screenshot": opts.screenshot = value; i++; break; case "--gamepads": opts.gamepads = true; break; case "--log": opts.log = value; i++; break; case "--audio": opts.audio = value; i++; break; case "--file": opts.files.push(value); i++; break; default: throw new Error("unknown option " + name); } } if (!opts.chrome || !opts.url) { throw new Error("--chrome and --url are required"); } return opts; } function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); } // One DevTools connection: send() returns the command's result; events go to // the handler. function connect(wsUrl, onEvent) { return new Promise(function (resolve, reject) { const ws = new WebSocket(wsUrl); const pending = new Map(); let nextId = 1; ws.onopen = function () { resolve({ send: function (method, params) { const id = nextId++; ws.send(JSON.stringify({ id: id, method: method, params: params || {} })); return new Promise(function (ok, fail) { pending.set(id, { ok: ok, fail: fail }); }); }, close: function () { ws.close(); } }); }; ws.onerror = function () { reject(new Error("cannot connect to " + wsUrl)); }; ws.onmessage = function (msg) { const data = JSON.parse(msg.data); if (data.id !== undefined && pending.has(data.id)) { const p = pending.get(data.id); pending.delete(data.id); if (data.error) { p.fail(new Error(data.error.message)); } else { p.ok(data.result); } } else if (data.method) { onEvent(data.method, data.params); } }; }); } // One --step. async function runStep(cdp, step) { const at = step.indexOf(":"); const verb = step.slice(0, at); const rest = step.slice(at + 1); if (step === "reload") { await cdp.send("Page.reload"); } else if (verb === "wait") { await sleep(Number(rest) * MS_PER_SECOND); } else if (verb === "type") { for (const ch of rest) { await keyEvent(cdp, "keyDown", ch); await keyEvent(cdp, "keyUp", ch); await sleep(TYPE_DELAY_MS); } } else if (verb === "shot") { await canvasShot(cdp, rest); } else if (verb === "pad") { const sep = rest.indexOf(":"); const action = sep < 0 ? rest : rest.slice(0, sep); const controls = sep < 0 ? [] : rest.slice(sep + 1).split(","); if (action === "unplug") { await evaluate(cdp, "window.joeyPads.unplug()"); } else if (action === "press" || action === "release") { await evaluate(cdp, "window.joeyPads.set(" + (action === "press") + ", " + JSON.stringify(controls) + ")"); } else { throw new Error("unknown step " + step); } } else if (verb === "hold") { const last = rest.lastIndexOf(":"); const key = rest.slice(0, last); await keyEvent(cdp, "keyDown", key); await sleep(Number(rest.slice(last + 1))); await keyEvent(cdp, "keyUp", key); } else { throw new Error("unknown step " + step); } } // A PNG of the canvas alone into file. async function canvasShot(cdp, file) { const clip = JSON.parse(await evaluate(cdp, CANVAS_RECT)); clip.scale = 1; const shot = await cdp.send("Page.captureScreenshot", { format: "png", clip: clip }); writeFileSync(file, Buffer.from(shot.data, "base64")); } // A 16-bit stereo WAV of the samples. function wavFile(rate, samples) { const header = Buffer.alloc(WAV_HEADER); header.write("RIFF", 0); header.writeUInt32LE(WAV_HEADER - 8 + samples.length, 4); header.write("WAVEfmt ", 8); header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(WAV_CHANNELS, 22); header.writeUInt32LE(rate, 24); header.writeUInt32LE(rate * WAV_CHANNELS * WAV_BYTES, 28); header.writeUInt16LE(WAV_CHANNELS * WAV_BYTES, 32); header.writeUInt16LE(WAV_BYTES * 8, 34); header.write("data", 36); header.writeUInt32LE(samples.length, 40); return Buffer.concat([header, samples]); } async function evaluate(cdp, expression) { const r = await cdp.send("Runtime.evaluate", { expression: expression, returnByValue: true }); return r.result.value; } // A key down and up; a printable key carries its text, so the page gets the // character too. async function keyEvent(cdp, type, key) { const params = { type: type, key: key }; if (key.length === 1) { params.text = key; params.code = /[a-z]/i.test(key) ? "Key" + key.toUpperCase() : (/[0-9]/.test(key) ? "Digit" + key : (key === " " ? "Space" : undefined)); } else { params.code = key; params.windowsVirtualKeyCode = key === "Escape" ? 27 : (key === "Enter" ? 13 : 0); } await cdp.send("Input.dispatchKeyEvent", params); } async function main() { const opts = parseArgs(process.argv); const profile = mkdtempSync(join(tmpdir(), "joeylib-web-")); const debugPort = DEBUG_PORT_MIN + Math.floor(Math.random() * DEBUG_PORT_SPAN); const chrome = spawn(opts.chrome, [ "--headless=new", "--no-sandbox", "--no-first-run", "--mute-audio", "--autoplay-policy=no-user-gesture-required", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--window-size=1024,800", "--user-data-dir=" + profile, "--remote-debugging-port=" + debugPort, "about:blank" ], { stdio: "ignore" }); let failed = false; let cdp = null; try { let version = null; let tries; for (tries = 0; tries < START_TRIES && version === null; tries++) { try { version = await (await fetch("http://127.0.0.1:" + debugPort + "/json/list")).json(); } catch (e) { await sleep(POLL_MS); } } if (version === null) { throw new Error("Chromium did not start"); } const page = version.find(function (t) { return t.type === "page"; }); cdp = await connect(page.webSocketDebuggerUrl, function (method, params) { if (method === "Runtime.consoleAPICalled") { process.stderr.write("console: " + params.args.map(function (a) { return a.value !== undefined ? a.value : a.description; }).join(" ") + "\n"); } else if (method === "Runtime.exceptionThrown") { failed = true; process.stderr.write("exception: " + (params.exceptionDetails.exception ? params.exceptionDetails.exception.description : params.exceptionDetails.text) + "\n"); } }); await cdp.send("Runtime.enable"); await cdp.send("Page.enable"); if (opts.audio !== undefined) { await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: AUDIO_RECORDER }); } if (opts.gamepads) { await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: GAMEPADS }); } await cdp.send("Page.navigate", { url: opts.url }); const deadline = Date.now() + opts.seconds * MS_PER_SECOND; let reached = false; if (opts.until !== undefined) { while (Date.now() < deadline && !reached) { reached = (await evaluate(cdp, READ_LOG)).indexOf(opts.until) >= 0; if (!reached) { await sleep(POLL_MS); } } if (!reached) { process.stderr.write("webDriver: the log never held '" + opts.until + "'\n"); failed = true; } } else { // With --audio the steps are timed from the first buffer played, // where the recording starts. while (opts.audio !== undefined && Date.now() < deadline && !(await evaluate(cdp, AUDIO_STARTED))) { await sleep(POLL_MS); } for (const step of opts.steps) { await runStep(cdp, step); } } if (opts.audio !== undefined) { const taken = await evaluate(cdp, AUDIO_TAKE); if (taken === null) { throw new Error("the page has no audio recorder"); } const audio = JSON.parse(taken); writeFileSync(opts.audio, wavFile(audio.rate, Buffer.from(audio.data, "base64"))); } if (opts.log !== undefined) { writeFileSync(opts.log, await evaluate(cdp, READ_LOG)); } for (const file of opts.files) { const at = file.lastIndexOf(":"); const path = file.slice(0, at); const data = await evaluate(cdp, "(function () { try { var b = Module.FS.readFile(" + JSON.stringify(path) + "); var t = ''; var i; for (i = 0; i < b.length; i++) { t += String.fromCharCode(b[i]); } return btoa(t); } catch (e) { return null; } })()"); if (data === null) { throw new Error("the page has no file " + path); } writeFileSync(file.slice(at + 1), Buffer.from(data, "base64")); } if (opts.screenshot !== undefined) { await canvasShot(cdp, opts.screenshot); } } catch (e) { process.stderr.write("webDriver: " + e.message + "\n"); failed = true; } finally { if (cdp !== null) { cdp.close(); } chrome.kill("SIGKILL"); rmSync(profile, { recursive: true, force: true }); } process.exit(failed ? 1 : 0); } main();