/** * Run a page in headless Firefox (via geckodriver, W3C WebDriver) and * print a result object. * * node dev/cdp-firefox.mjs [timeoutMs=25000] [pollMs=250] * * 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 [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 */ } }