diff options
| author | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
| commit | 3443c1c65e9f1753e1e656b35d08416c1fa298f2 (patch) | |
| tree | 4e43236f460145a4d75d1b4616dcb7aa6ef08f51 /viewer/tools/bidi.mjs | |
| download | worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.tar.gz worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.zip | |
worldmap-viewer: initial public history
Diffstat (limited to 'viewer/tools/bidi.mjs')
| -rw-r--r-- | viewer/tools/bidi.mjs | 83 |
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); |
