/** * Headless-Firefox screenshot helper (W3C WebDriver via geckodriver): * * node dev/shot-firefox.mjs [timeoutMs=25000] * * is evaluated with execute/sync and polled until it returns * a non-null value (the page should set the state first), then the * session's screenshot is saved to . */ import { spawn, execFileSync } from 'node:child_process'; import { existsSync, readdirSync, writeFileSync } from 'node:fs'; const [url, expr, outPng, timeoutMs = '30000'] = process.argv.slice(2); if (!url || !expr || !outPng) { console.error('usage: node dev/shot-firefox.mjs [timeoutMs]'); process.exit(2); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let geckodriver; try { geckodriver = execFileSync('sh', ['-c', 'command -v geckodriver'], { encoding: 'utf8' }).trim(); } catch { console.error('need firefox + geckodriver on PATH'); process.exit(2); } 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 */ } if (firefoxCandidates.length === 0) { console.error('no firefox binary found'); process.exit(2); } const PORT = 4444 + (process.pid % 100); const driver = spawn(geckodriver, ['--port', String(PORT), '--log', 'fatal'], { stdio: 'ignore' }); const base = `http://127.0.0.1:${PORT}`; const fail = (msg) => { try { driver.kill('SIGKILL'); } catch { /* dead */ } console.error(msg); process.exit(1); }; 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 */ } if (!res.ok) throw new Error(`WebDriver ${method} ${path} → ${res.status}: ${text.slice(0, 300)}`); return data; }; try { 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 */ } await sleep(250); } if (!up) fail('geckodriver never came up'); let sess; let lastErr = 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) { lastErr = String(err?.message ?? err); } } if (!sess) fail(`no firefox candidate accepted: ${lastErr}`); const sid = sess.value?.sessionId; await wd('POST', `/session/${sid}/window/size`, { width: 1280, height: 720 }).catch(() => {}); await wd('POST', `/session/${sid}/url`, { url }); const deadline = Date.now() + Number(timeoutMs); let pollErr = null; while (Date.now() < deadline) { await sleep(300); try { const r = await wd('POST', `/session/${sid}/execute/sync`, { script: expr, args: [] }); if (r.value !== undefined && r.value !== null) break; } catch (err) { pollErr = String(err?.message ?? err); } } await sleep(400); // let the last frame paint const shot = await wd('GET', `/session/${sid}/screenshot`); writeFileSync(outPng, Buffer.from(shot.value, 'base64')); console.log(`saved ${outPng}${pollErr ? ` (last poll error: ${pollErr})` : ''}`); await wd('DELETE', `/session/${sid}`).catch(() => {}); } finally { try { driver.kill('SIGKILL'); } catch { /* dead */ } }