aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/tools/bidi.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'viewer/tools/bidi.mjs')
-rw-r--r--viewer/tools/bidi.mjs83
1 files changed, 83 insertions, 0 deletions
diff --git a/viewer/tools/bidi.mjs b/viewer/tools/bidi.mjs
new file mode 100644
index 0000000..ca75210
--- /dev/null
+++ b/viewer/tools/bidi.mjs
@@ -0,0 +1,83 @@
+// bidi.mjs <url> <script.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);