/** * Screenshot helper for this env: geckodriver + headless Firefox. * node dev/wd-shot.mjs [width] [height] [timeoutMs] * is a WebDriver execute/sync script (function body) that runs on * the page before the screenshot; may be '' to skip. The page is given time * to finish loading (the expression is polled until it returns non-null). */ import { spawn, execFileSync } from 'node:child_process'; import { existsSync, readdirSync, writeFileSync } from 'node:fs'; const [url, setupExpr, shotFile, w = '1280', h = '720', timeoutMs = '30000'] = process.argv.slice(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 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 { /* ignore */ } 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'); process.exit(2); } const PORT = 4460 + (process.pid % 50); 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 { /* ok */ } 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 { /* ignore */ } 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; if (!sid) fail('no session id'); if (w && h) { try { await wd('POST', `/session/${sid}/window`, { handle: 'current', state: { width: Number(w), height: Number(h) } }); } catch { /* keep default size */ } } await wd('POST', `/session/${sid}/url`, { url }); if (setupExpr) { const deadline = Date.now() + Number(timeoutMs); let last = null; while (Date.now() < deadline) { await sleep(250); try { const r = await wd('POST', `/session/${sid}/execute/sync`, { script: setupExpr, args: [] }); if (r.value !== undefined && r.value !== null) { last = r.value; break; } } catch (err) { last = `ERR ${String(err?.message ?? err)}`; } } if (last === null) fail('setup expression never returned a value'); if (typeof last === 'string' && last.startsWith('ERR')) fail(last); console.log('setup →', JSON.stringify(last).slice(0, 400)); await sleep(400); } const shot = await wd('GET', `/session/${sid}/screenshot`); writeFileSync(shotFile, Buffer.from(shot.value, 'base64')); console.log('saved', shotFile); await wd('DELETE', `/session/${sid}`).catch(() => {}); } finally { try { driver.kill('SIGKILL'); } catch { /* ok */ } }