worldmap-viewer

git clone https://git.godosa.eu/worldmap-viewer

master

raw · 5067 bytes

// 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);