444 lines
19 KiB
JavaScript
444 lines
19 KiB
JavaScript
// 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)
|
|
// open:PAGE load PAGE, a page beside --url's
|
|
// (same site, same browser storage)
|
|
// 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; a PATH without a leading / is in the
|
|
// program's write folder, Module.joeyWriteFolder) 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
|
|
// --sound play through the system's sound output (PULSE_SINK
|
|
// in the environment picks the device) rather than
|
|
// muted: a muted page's audio runs on a fake clock of
|
|
// Chromium's, which can drift off real time, so a
|
|
// recording that must keep time plays to a sink
|
|
// 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 its write folder in the in-memory file system
|
|
// ("" until there is one).
|
|
const READ_LOG = "(function () { try { return Module.FS.readFile(Module.joeyWriteFolder + '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;
|
|
case "--sound": opts.sound = true; 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; url is --url's, beside which open: finds its page.
|
|
async function runStep(cdp, step, url) {
|
|
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 === "open") {
|
|
await cdp.send("Page.navigate", { url: new URL(rest, url).href });
|
|
} 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",
|
|
...(opts.sound ? [] : ["--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, opts.url);
|
|
}
|
|
}
|
|
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 p = " + JSON.stringify(path) + "; var b = Module.FS.readFile(p.charAt(0) === '/' ? p : Module.joeyWriteFolder + p); 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();
|