test-firefox-sender-context.mjs

9.0 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    join(process.env.HOME ?? "", "Library/Caches/ms-playwright/firefox-1538/firefox/Nightly.app/Contents/MacOS/firefox"),28    join(process.env.HOME ?? "", "Library/Caches/ms-playwright/firefox-1511/firefox/Nightly.app/Contents/MacOS/firefox"),29  ].filter(Boolean);30  return candidates.find((candidate) => existsSync(candidate));31}3233function findFreePort() {34  return new Promise((resolvePort, reject) => {35    const server = net.createServer();36    server.once("error", reject);37    server.listen(0, "127.0.0.1", () => {38      const address = server.address();39      if (!address || typeof address === "string") {40        server.close();41        reject(new Error("Could not allocate a WebDriver port"));42        return;43      }44      server.close(() => resolvePort(address.port));45    });46  });47}4849async function waitForDriver(port) {50  const deadline = Date.now() + 30_000;51  while (Date.now() < deadline) {52    try {53      const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 500);54      if (response.ok && (await response.json()).value?.ready) return;55    } catch {56      // geckodriver is still starting.57    }58    await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));59  }60  throw new Error("geckodriver did not become ready within 30 seconds");61}6263async function fetchWithTimeout(url, options, timeoutMs) {64  const controller = new AbortController();65  let timeoutId;66  const timeout = new Promise((_, reject) => {67    timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);68  });69  try {70    return await Promise.race([71      fetch(url, { ...options, signal: controller.signal }),72      timeout,73    ]);74  } finally {75    clearTimeout(timeoutId);76    controller.abort();77  }78}7980async function webdriverRequest(port, path, options = {}) {81  const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {82    ...options,83    headers: { "content-type": "application/json", ...(options.headers ?? {}) },84  }, 10_000);85  const body = await response.json();86  if (!response.ok || body.value?.error) {87    throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);88  }89  return body.value;90}9192async function buildTestExtension(tempRoot) {93  const sourceDir = join(tempRoot, "extension");94  const artifactsDir = join(tempRoot, "artifacts");95  await cp(DIST, sourceDir, { recursive: true });96  await mkdir(artifactsDir, { recursive: true });97  await cp(PROBE, join(sourceDir, "firefox-sender-probe.js"));9899  const manifestPath = join(sourceDir, "manifest.json");100  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));101  manifest.content_scripts.push({102    matches: ["<all_urls>"],103    js: ["firefox-sender-probe.js"],104    run_at: "document_start",105    all_frames: false,106  });107  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);108109  await execFileAsync(WEB_EXT, [110    "build",111    "--source-dir", sourceDir,112    "--artifacts-dir", artifactsDir,113    "--filename", "peach-firefox-sender-context.xpi",114    "--overwrite-dest",115    "--no-input",116  ], { cwd: ROOT });117  return join(artifactsDir, "peach-firefox-sender-context.xpi");118}119120async function run() {121  if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");122  if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");123124  const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-sender-"));125  let driver;126  let driverPort;127  let sessionId;128  try {129    const xpiPath = await buildTestExtension(tempRoot);130    const firefoxBinary = findFirefoxBinary();131    // Match the Playwright Firefox release environment: this host cannot map132    // the hardware compositor, and Marionette must inherit the same fallback.133    Object.assign(process.env, {134      MOZ_HEADLESS: "1",135      MOZ_DISABLE_CONTENT_SANDBOX: "1",136      MOZ_DISABLE_RDD_SANDBOX: "1",137      LIBGL_ALWAYS_SOFTWARE: "1",138    });139    const port = await findFreePort();140    driverPort = port;141    driver = await start({142      host: "127.0.0.1",143      port,144      geckoDriverVersion: "0.37.1",145      ...(firefoxBinary ? { binary: firefoxBinary } : {}),146      log: "error",147      spawnOpts: {148        env: {149          ...process.env,150          MOZ_HEADLESS: "1",151          MOZ_DISABLE_CONTENT_SANDBOX: "1",152          MOZ_DISABLE_RDD_SANDBOX: "1",153          LIBGL_ALWAYS_SOFTWARE: "1",154        },155      },156    });157    await waitForDriver(port);158159    const capabilities = {160      browserName: "firefox",161      acceptInsecureCerts: true,162      "moz:firefoxOptions": {163        args: ["-headless", "-no-remote"],164        ...(firefoxBinary ? { binary: firefoxBinary } : {}),165        prefs: {166          "browser.shell.checkDefaultBrowser": false,167          "browser.startup.page": 0,168          "browser.startup.homepage": "about:blank",169        },170      },171    };172    const session = await webdriverRequest(port, "/session", {173      method: "POST",174      body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),175    });176    sessionId = session.sessionId;177178    const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {179      method: "POST",180      body: JSON.stringify({181        addon: (await readFile(xpiPath)).toString("base64"),182        temporary: true,183      }),184    });185    if (install?.id && install.id !== EXTENSION_ID) {186      throw new Error(`Unexpected Firefox add-on id: ${install.id}`);187    }188189    await webdriverRequest(port, `/session/${sessionId}/url`, {190      method: "POST",191      body: JSON.stringify({ url: "https://example.com/" }),192    });193    const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {194      method: "POST",195      body: JSON.stringify({196        script: `197          const done = arguments[arguments.length - 1];198          const source = "peach-firefox-sender-probe";199          const send = (message) => new Promise((resolve) => {200            const id = crypto.randomUUID();201            const timer = setTimeout(() => {202              window.removeEventListener("message", onMessage);203              resolve({ ok: false, error: "probe_timeout" });204            }, 5000);205            function onMessage(event) {206              if (event.source !== window || event.data?.source !== source || event.data.id !== id) return;207              clearTimeout(timer);208              window.removeEventListener("message", onMessage);209              resolve(event.data.response);210            }211            window.addEventListener("message", onMessage);212            window.postMessage({ source, id, message }, window.location.origin);213          });214          Promise.all([215            send({ type: "GET_SETTINGS" }),216            send({ type: "GET_ENTRIES" }),217            send({ type: "GET_RECOVERY_WORDS" }),218          ]).then(([allowed, entries, recovery]) => done({219            pageOrigin: location.origin,220            allowed,221            entries,222            recovery,223          }));224        `,225        args: [],226      }),227    });228229    if (result.pageOrigin !== "https://example.com") {230      throw new Error(`Unexpected page origin: ${result.pageOrigin}`);231    }232    if (result.allowed?.error === "invalid_sender") {233      throw new Error("Content-script-allowed GET_SETTINGS was rejected");234    }235    for (const [name, response] of [["GET_ENTRIES", result.entries], ["GET_RECOVERY_WORDS", result.recovery]]) {236      if (response?.error !== "invalid_sender") {237        throw new Error(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify(response)}`);238      }239    }240    console.log("Firefox sender-context test passed: real content-script GET_ENTRIES and GET_RECOVERY_WORDS were rejected, GET_SETTINGS remained allowed.");241  } finally {242    if (driver && sessionId) {243      try {244        await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });245      } catch {246        // The browser may already have exited after a failed startup.247      }248    }249    driver?.kill("SIGTERM");250    await rm(tempRoot, { recursive: true, force: true });251  }252}253254run().catch((error) => {255  console.error(error instanceof Error ? error.message : error);256  process.exitCode = 1;257});