test-firefox-sender-context.mjs

8.2 KB · raw

1#!/usr/bin/env node23import { execFile } from "node:child_process";4import { promisify } from "node:util";5import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";6import { existsSync } from "node:fs";7import { tmpdir } from "node:os";8import { join, resolve } from "node:path";9import net from "node:net";10import { start } from "geckodriver";1112const execFileAsync = promisify(execFile);13const ROOT = resolve(new URL("..", import.meta.url).pathname);14const DIST = join(ROOT, "dist-firefox");15const PROBE = join(ROOT, "tests/fixtures/firefox-sender-probe.js");16const WEB_EXT = join(ROOT, "node_modules/.bin/web-ext");17const EXTENSION_ID = "[email protected]";1819function findFirefoxBinary() {20  const candidates = [21    process.env.PEACH_FIREFOX_BINARY,22    process.env.FIREFOX_BIN,23    "/usr/bin/firefox",24    "/usr/local/bin/firefox",25    "/Applications/Firefox.app/Contents/MacOS/firefox",26    "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",27  ].filter(Boolean);28  return candidates.find((candidate) => existsSync(candidate));29}3031function findFreePort() {32  return new Promise((resolvePort, reject) => {33    const server = net.createServer();34    server.once("error", reject);35    server.listen(0, "127.0.0.1", () => {36      const address = server.address();37      if (!address || typeof address === "string") {38        server.close();39        reject(new Error("Could not allocate a WebDriver port"));40        return;41      }42      server.close(() => resolvePort(address.port));43    });44  });45}4647async function waitForDriver(port) {48  const deadline = Date.now() + 30_000;49  while (Date.now() < deadline) {50    try {51      const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 500);52      if (response.ok && (await response.json()).value?.ready) return;53    } catch {54      // geckodriver is still starting.55    }56    await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));57  }58  throw new Error("geckodriver did not become ready within 30 seconds");59}6061async function fetchWithTimeout(url, options, timeoutMs) {62  const controller = new AbortController();63  let timeoutId;64  const timeout = new Promise((_, reject) => {65    timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);66  });67  try {68    return await Promise.race([69      fetch(url, { ...options, signal: controller.signal }),70      timeout,71    ]);72  } finally {73    clearTimeout(timeoutId);74    controller.abort();75  }76}7778async function webdriverRequest(port, path, options = {}) {79  const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {80    ...options,81    headers: { "content-type": "application/json", ...(options.headers ?? {}) },82  }, 10_000);83  const body = await response.json();84  if (!response.ok || body.value?.error) {85    throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);86  }87  return body.value;88}8990async function buildTestExtension(tempRoot) {91  const sourceDir = join(tempRoot, "extension");92  const artifactsDir = join(tempRoot, "artifacts");93  await cp(DIST, sourceDir, { recursive: true });94  await mkdir(artifactsDir, { recursive: true });95  await cp(PROBE, join(sourceDir, "firefox-sender-probe.js"));9697  const manifestPath = join(sourceDir, "manifest.json");98  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));99  manifest.content_scripts.push({100    matches: ["<all_urls>"],101    js: ["firefox-sender-probe.js"],102    run_at: "document_start",103    all_frames: false,104  });105  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);106107  await execFileAsync(WEB_EXT, [108    "build",109    "--source-dir", sourceDir,110    "--artifacts-dir", artifactsDir,111    "--filename", "peach-firefox-sender-context.xpi",112    "--overwrite-dest",113    "--no-input",114  ], { cwd: ROOT });115  return join(artifactsDir, "peach-firefox-sender-context.xpi");116}117118async function run() {119  if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");120  if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");121122  const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-sender-"));123  let driver;124  let driverPort;125  let sessionId;126  try {127    const xpiPath = await buildTestExtension(tempRoot);128    const firefoxBinary = findFirefoxBinary();129    const port = await findFreePort();130    driverPort = port;131    driver = await start({132      host: "127.0.0.1",133      port,134      geckoDriverVersion: "0.37.1",135      ...(firefoxBinary ? { binary: firefoxBinary } : {}),136      log: "error",137    });138    await waitForDriver(port);139140    const capabilities = {141      browserName: "firefox",142      acceptInsecureCerts: true,143      "moz:firefoxOptions": {144        args: ["-headless"],145        ...(firefoxBinary ? { binary: firefoxBinary } : {}),146        prefs: {147          "browser.shell.checkDefaultBrowser": false,148          "browser.startup.page": 0,149          "browser.startup.homepage": "about:blank",150        },151      },152    };153    const session = await webdriverRequest(port, "/session", {154      method: "POST",155      body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),156    });157    sessionId = session.sessionId;158159    const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {160      method: "POST",161      body: JSON.stringify({162        addon: (await readFile(xpiPath)).toString("base64"),163        temporary: true,164      }),165    });166    if (install?.id && install.id !== EXTENSION_ID) {167      throw new Error(`Unexpected Firefox add-on id: ${install.id}`);168    }169170    await webdriverRequest(port, `/session/${sessionId}/url`, {171      method: "POST",172      body: JSON.stringify({ url: "https://example.com/" }),173    });174    const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {175      method: "POST",176      body: JSON.stringify({177        script: `178          const done = arguments[arguments.length - 1];179          const source = "peach-firefox-sender-probe";180          const send = (message) => new Promise((resolve) => {181            const id = crypto.randomUUID();182            const timer = setTimeout(() => {183              window.removeEventListener("message", onMessage);184              resolve({ ok: false, error: "probe_timeout" });185            }, 5000);186            function onMessage(event) {187              if (event.source !== window || event.data?.source !== source || event.data.id !== id) return;188              clearTimeout(timer);189              window.removeEventListener("message", onMessage);190              resolve(event.data.response);191            }192            window.addEventListener("message", onMessage);193            window.postMessage({ source, id, message }, window.location.origin);194          });195          Promise.all([196            send({ type: "GET_SETTINGS" }),197            send({ type: "GET_ENTRIES" }),198            send({ type: "GET_RECOVERY_WORDS" }),199          ]).then(([allowed, entries, recovery]) => done({200            pageOrigin: location.origin,201            allowed,202            entries,203            recovery,204          }));205        `,206        args: [],207      }),208    });209210    if (result.pageOrigin !== "https://example.com") {211      throw new Error(`Unexpected page origin: ${result.pageOrigin}`);212    }213    if (result.allowed?.error === "invalid_sender") {214      throw new Error("Content-script-allowed GET_SETTINGS was rejected");215    }216    for (const [name, response] of [["GET_ENTRIES", result.entries], ["GET_RECOVERY_WORDS", result.recovery]]) {217      if (response?.error !== "invalid_sender") {218        throw new Error(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify(response)}`);219      }220    }221    console.log("Firefox sender-context test passed: real content-script GET_ENTRIES and GET_RECOVERY_WORDS were rejected, GET_SETTINGS remained allowed.");222  } finally {223    if (driver && sessionId) {224      try {225        await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });226      } catch {227        // The browser may already have exited after a failed startup.228      }229    }230    driver?.kill("SIGTERM");231    await rm(tempRoot, { recursive: true, force: true });232  }233}234235run().catch((error) => {236  console.error(error instanceof Error ? error.message : error);237  process.exitCode = 1;238});