orbit/dev/shot-firefox.mjs

122 lines
4.0 KiB
JavaScript

/**
* Headless-Firefox screenshot helper (W3C WebDriver via geckodriver):
*
* node dev/shot-firefox.mjs <url> <pollExpr> <outPng> [timeoutMs=25000]
*
* <pollExpr> 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 <outPng>.
*/
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 <url> <pollExpr> <outPng> [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 */ }
}