152 lines
5.2 KiB
JavaScript
152 lines
5.2 KiB
JavaScript
/**
|
|
* Run a page in headless Firefox (via geckodriver, W3C WebDriver) and
|
|
* print a result object.
|
|
*
|
|
* node dev/cdp-firefox.mjs <url> <expression> [timeoutMs=25000] [pollMs=250]
|
|
*
|
|
* <expression> is an arrow-less script evaluated with
|
|
* `execute/sync` (WebDriver classic) — e.g.
|
|
* `return window.__SAVES_UI__;` — polled until it returns non-null,
|
|
* then printed as JSON. The page under test is expected to set that
|
|
* global when its flow finishes (success or failure).
|
|
*
|
|
* Needs: firefox + geckodriver on PATH, a static server for the page.
|
|
*/
|
|
import { spawn, execFileSync } from 'node:child_process';
|
|
import { existsSync, readdirSync } from 'node:fs';
|
|
|
|
const [url, expr, timeoutMs = '25000', pollMs = '250'] = process.argv.slice(2);
|
|
if (!url || !expr) {
|
|
console.error('usage: node dev/cdp-firefox.mjs <url> <expression> [timeoutMs] [pollMs]');
|
|
process.exit(2);
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// --- find firefox + geckodriver ------------------------------------------
|
|
let geckodriver;
|
|
try {
|
|
geckodriver = execFileSync('sh', ['-c', 'command -v geckodriver'], { encoding: 'utf8' }).trim();
|
|
} catch {
|
|
console.error('need firefox + geckodriver on PATH');
|
|
process.exit(2);
|
|
}
|
|
|
|
// The firefox binary. On snap systems `command -v firefox` is a shell
|
|
// wrapper (geckodriver rejects non-ELF), so also try the real binary
|
|
// inside the snap revision directories.
|
|
const firefoxCandidates = [];
|
|
try {
|
|
const ff = execFileSync('sh', ['-c', 'command -v firefox'], { encoding: 'utf8' }).trim();
|
|
if (ff) firefoxCandidates.push(ff);
|
|
} catch { /* not on PATH */ }
|
|
try {
|
|
const snapRev = readdirSync('/snap/firefox', { withFileTypes: true }).map((d) => d.name);
|
|
for (const rev of [null, ...snapRev]) {
|
|
const base = rev ? `/snap/firefox/${rev}` : '/snap/firefox/current';
|
|
const p = `${base}/usr/lib/firefox/firefox`;
|
|
if (existsSync(p)) firefoxCandidates.push(p);
|
|
}
|
|
} catch { /* no /snap/firefox (non-snap install) */ }
|
|
if (firefoxCandidates.length === 0) {
|
|
console.error('no firefox binary found (PATH or /snap/firefox)');
|
|
process.exit(2);
|
|
}
|
|
const PORT = 4444 + (process.pid % 100);
|
|
const udd = `/tmp/wd-firefox-${process.pid}-${Date.now()}`;
|
|
const driver = spawn(geckodriver, ['--port', String(PORT), '--log', 'fatal'], { stdio: 'ignore' });
|
|
const base = `http://127.0.0.1:${PORT}`;
|
|
|
|
const fail = (msg, code = 1) => {
|
|
try { driver.kill('SIGKILL'); } catch { /* already dead */ }
|
|
console.error(msg);
|
|
process.exit(code);
|
|
};
|
|
|
|
const wd = async (method, path, body) => {
|
|
const res = await fetch(base + path, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const text = await res.text();
|
|
let data = null;
|
|
try { data = JSON.parse(text); } catch { /* empty body (DELETE) */ }
|
|
if (!res.ok) throw new Error(`WebDriver ${method} ${path} → ${res.status}: ${text.slice(0, 300)}`);
|
|
return data;
|
|
};
|
|
|
|
try {
|
|
// --- wait for the driver ------------------------------------------------
|
|
let up = false;
|
|
for (let i = 0; i < 60; i++) {
|
|
try {
|
|
const v = await (await fetch(`${base}/status`)).json();
|
|
if (v?.value?.ready || v?.status === 'ok' || v?.value) { up = true; break; }
|
|
} catch { /* not up yet */ }
|
|
await sleep(250);
|
|
}
|
|
if (!up) fail('geckodriver never came up');
|
|
|
|
// --- a headless session (try each firefox candidate; geckodriver
|
|
// rejects wrapper scripts) -------------------------------------------
|
|
let sess;
|
|
let lastSessErr = null;
|
|
for (const ff of firefoxCandidates) {
|
|
try {
|
|
sess = await wd('POST', '/session', {
|
|
capabilities: {
|
|
alwaysMatch: {
|
|
browserName: 'firefox',
|
|
'moz:firefoxOptions': {
|
|
args: ['-headless'],
|
|
binary: ff,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
break;
|
|
} catch (err) {
|
|
lastSessErr = err;
|
|
// A rejected binary leaves no session; other errors are fatal-ish,
|
|
// but keep trying the next candidate.
|
|
}
|
|
}
|
|
if (!sess) fail(`no firefox candidate accepted: ${String(lastSessErr?.message ?? lastSessErr)}`);
|
|
const sid = sess.value?.sessionId;
|
|
if (!sid) fail(`no session id in ${JSON.stringify(sess).slice(0, 200)}`);
|
|
|
|
// --- navigate -------------------------------------------------------------
|
|
await wd('POST', `/session/${sid}/url`, { url });
|
|
|
|
// --- poll the expression until it yields a value ---------------------------
|
|
const deadline = Date.now() + Number(timeoutMs);
|
|
let out;
|
|
let lastErr = null;
|
|
while (Date.now() < deadline) {
|
|
await sleep(Number(pollMs));
|
|
try {
|
|
const r = await wd('POST', `/session/${sid}/execute/sync`, {
|
|
script: expr,
|
|
args: [],
|
|
});
|
|
if (r.value !== undefined && r.value !== null) {
|
|
out = r.value;
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
lastErr = String(err?.message ?? err);
|
|
}
|
|
}
|
|
|
|
await wd('DELETE', `/session/${sid}`).catch(() => {});
|
|
|
|
if (out === undefined) {
|
|
fail(`timeout waiting for a value: ${lastErr ?? 'the expression kept returning null'}`);
|
|
}
|
|
console.log(typeof out === 'string' ? out : JSON.stringify(out, null, 2));
|
|
if (out && typeof out === 'object' && out.pass === false) process.exitCode = 1;
|
|
} finally {
|
|
try { driver.kill('SIGKILL'); } catch { /* already dead */ }
|
|
}
|