// bidi.mjs — drive headless Firefox over WebDriver BiDi and run a smoke script. // Script: export const steps = [{ name, js, wait?, actions?, settle?, expect?, shot?, save? }] (optional: export const viewport, // devicePixelRatio — a phone's is 2-3) // js: async function body evaluated in the page, returns JSON-able · expect(v) → boolean (PASS/FAIL) // actions: BiDi input.performActions sources · shot: page screenshot path · save: path for a returned PNG data URL // Exit code = number of failed expectations + console errors. Needs Firefox and Node ≥ 22 (global WebSocket). import { spawn } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; const [url, scriptPath] = process.argv.slice(2); const { steps, viewport = { width: 1400, height: 900 }, devicePixelRatio } = await import(pathToFileURL(resolve(scriptPath)).href); const port = 9300 + Math.floor(Math.random() * 500); const prof = mkdtempSync(join(tmpdir(), 'ffbidi-')); writeFileSync(join(prof, 'user.js'), 'user_pref("browser.startup.page", 0);\nuser_pref("browser.startup.homepage", "about:blank");\n' + 'user_pref("browser.newtabpage.enabled", false);\n' // no start page: its console noise is not ours + 'user_pref("webgl.force-enabled", true);\n'); // headless: skip the GPU blocklist that intermittently refuses WebGL mkdirSync(join(prof, 'tmp')); const ff = spawn('firefox', ['--headless', '--no-remote', '--profile', prof, `--remote-debugging-port=${port}`], { stdio: ['ignore', 'ignore', 'ignore'], env: { ...process.env, TMPDIR: join(prof, 'tmp') } }); // its temp files go too const cleanup = () => { // always: kill Firefox and drop its throwaway profile (/tmp may be RAM) try { ff.kill(); } catch { /* already gone */ } try { rmSync(prof, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch { /* best effort */ } }; process.on('exit', cleanup); for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => process.exit(130)); let ws; for (let i = 0; i < 60 && !ws; i++) { await new Promise(r => setTimeout(r, 500)); try { ws = await new Promise((res, rej) => { const s = new WebSocket(`ws://127.0.0.1:${port}/session`); s.onopen = () => res(s); s.onerror = rej; }); } catch { /* Firefox still starting */ } } if (!ws) { console.log('could not connect to Firefox'); process.exit(2); } let id = 0, failures = 0; const pending = new Map(), errors = []; ws.onmessage = ev => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } else if (m.method === 'log.entryAdded' && m.params.level === 'error' && !/downloadable font/.test(m.params.text)) errors.push(m.params.text); }; const send = (method, params) => new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); await send('session.new', { capabilities: {} }); await send('session.subscribe', { events: ['log.entryAdded'] }); const ctx = (await send('browsingContext.getTree', {})).result.contexts[0].context; await send('browsingContext.setViewport', { context: ctx, viewport, ...(devicePixelRatio ? { devicePixelRatio } : {}) }); await send('browsingContext.navigate', { context: ctx, url, wait: 'complete' }); for (const s of steps) { if (s.wait) await new Promise(r => setTimeout(r, s.wait)); if (s.actions) { const a = await send('input.performActions', { context: ctx, actions: s.actions }); if (a.error) console.log(`${s.name}: actions error ${a.error} ${a.message}`); await new Promise(r => setTimeout(r, s.settle ?? 400)); } const r = await send('script.evaluate', { expression: `(async () => { ${s.js} })().then(v => JSON.stringify(v))`, target: { context: ctx }, awaitPromise: true }); let text = r.result?.result?.value ?? JSON.stringify(r.result?.exceptionDetails?.text ?? r.error ?? r.result); let value; try { value = JSON.parse(text); } catch { value = text; } if (s.save && typeof value === 'string' && value.startsWith('data:image/png;base64,')) { writeFileSync(s.save, Buffer.from(value.split(',')[1], 'base64')); text = `saved ${s.save}`; } if (s.shot) { const c = await send('browsingContext.captureScreenshot', { context: ctx }); writeFileSync(s.shot, Buffer.from(c.result.data, 'base64')); text += ` (shot ${s.shot})`; } let verdict = ''; if (s.expect) { const ok = (() => { try { return !!s.expect(value); } catch { return false; } })(); verdict = ok ? 'PASS ' : 'FAIL '; if (!ok) failures++; } console.log(`${verdict}${s.name}: ${text.length > 600 ? text.slice(0, 600) + '…' : text}`); } if (errors.length) console.log(`FAIL console errors:\n${errors.join('\n')}`); ws.close(); ff.kill(); await new Promise(r => ff.once('exit', r).once('error', r) && setTimeout(r, 3000)); // let Firefox release the profile process.exit(failures + errors.length);