test-firefox-sender-context.mjs

9.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    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, driver) {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      if (driver.exitCode !== null || driver.signalCode !== null) {57        throw new Error(`geckodriver exited during startup (${driver.exitCode ?? driver.signalCode})`);58      }59    }60    await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));61  }62  throw new Error("geckodriver did not become ready within 30 seconds");63}6465async function fetchWithTimeout(url, options, timeoutMs) {66  const controller = new AbortController();67  let timeoutId;68  const timeout = new Promise((_, reject) => {69    timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);70  });71  try {72    return await Promise.race([73      fetch(url, { ...options, signal: controller.signal }),74      timeout,75    ]);76  } catch (error) {77    controller.abort();78    throw error;79  } finally {80    clearTimeout(timeoutId);81  }82}8384async function webdriverRequest(port, path, options = {}) {85  const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {86    ...options,87    headers: { "content-type": "application/json", ...(options.headers ?? {}) },88  }, 10_000);89  const body = await response.json();90  if (!response.ok || body.value?.error) {91    throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);92  }93  return body.value;94}9596async function buildTestExtension(tempRoot) {97  const sourceDir = join(tempRoot, "extension");98  const artifactsDir = join(tempRoot, "artifacts");99  await cp(DIST, sourceDir, { recursive: true });100  await mkdir(artifactsDir, { recursive: true });101  await cp(PROBE, join(sourceDir, "firefox-sender-probe.js"));102103  const manifestPath = join(sourceDir, "manifest.json");104  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));105  manifest.content_scripts.push({106    matches: ["<all_urls>"],107    js: ["firefox-sender-probe.js"],108    run_at: "document_start",109    all_frames: false,110  });111  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);112113  await execFileAsync(WEB_EXT, [114    "build",115    "--source-dir", sourceDir,116    "--artifacts-dir", artifactsDir,117    "--filename", "peach-firefox-sender-context.xpi",118    "--overwrite-dest",119    "--no-input",120  ], { cwd: ROOT });121  return join(artifactsDir, "peach-firefox-sender-context.xpi");122}123124async function run() {125  if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");126  if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");127128  const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-sender-"));129  let driver;130  let driverPort;131  let sessionId;132  try {133    const xpiPath = await buildTestExtension(tempRoot);134    const firefoxBinary = findFirefoxBinary();135    // Match the Playwright Firefox release environment: this host cannot map136    // the hardware compositor, and Marionette must inherit the same fallback.137    Object.assign(process.env, {138      MOZ_HEADLESS: "1",139      MOZ_DISABLE_CONTENT_SANDBOX: "1",140      MOZ_DISABLE_RDD_SANDBOX: "1",141      LIBGL_ALWAYS_SOFTWARE: "1",142    });143    const port = await findFreePort();144    driverPort = port;145    driver = await start({146      host: "127.0.0.1",147      port,148      geckoDriverVersion: "0.37.1",149      ...(firefoxBinary ? { binary: firefoxBinary } : {}),150      log: "error",151      spawnOpts: {152        env: {153          ...process.env,154          MOZ_HEADLESS: "1",155          MOZ_DISABLE_CONTENT_SANDBOX: "1",156          MOZ_DISABLE_RDD_SANDBOX: "1",157          LIBGL_ALWAYS_SOFTWARE: "1",158        },159      },160    });161    await waitForDriver(port, driver);162163    const capabilities = {164      browserName: "firefox",165      acceptInsecureCerts: true,166      "moz:firefoxOptions": {167        args: ["-headless", "-no-remote"],168        ...(firefoxBinary ? { binary: firefoxBinary } : {}),169        prefs: {170          "browser.shell.checkDefaultBrowser": false,171          "browser.startup.page": 0,172          "browser.startup.homepage": "about:blank",173        },174      },175    };176    const session = await webdriverRequest(port, "/session", {177      method: "POST",178      body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),179    });180    sessionId = session.sessionId;181182    const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {183      method: "POST",184      body: JSON.stringify({185        addon: (await readFile(xpiPath)).toString("base64"),186        temporary: true,187      }),188    });189    if (install?.id && install.id !== EXTENSION_ID) {190      throw new Error(`Unexpected Firefox add-on id: ${install.id}`);191    }192193    await webdriverRequest(port, `/session/${sessionId}/url`, {194      method: "POST",195      body: JSON.stringify({ url: "https://example.com/" }),196    });197    const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {198      method: "POST",199      body: JSON.stringify({200        script: `201          const done = arguments[arguments.length - 1];202          const source = "peach-firefox-sender-probe";203          const send = (message) => new Promise((resolve) => {204            const id = crypto.randomUUID();205            const timer = setTimeout(() => {206              window.removeEventListener("message", onMessage);207              resolve({ ok: false, error: "probe_timeout" });208            }, 5000);209            function onMessage(event) {210              if (event.source !== window || event.data?.source !== source || event.data.id !== id) return;211              clearTimeout(timer);212              window.removeEventListener("message", onMessage);213              resolve(event.data.response);214            }215            window.addEventListener("message", onMessage);216            window.postMessage({ source, id, message }, window.location.origin);217          });218          Promise.all([219            send({ type: "GET_SETTINGS" }),220            send({ type: "GET_ENTRIES" }),221            send({ type: "GET_RECOVERY_WORDS" }),222          ]).then(([allowed, entries, recovery]) => done({223            pageOrigin: location.origin,224            allowed,225            entries,226            recovery,227          }));228        `,229        args: [],230      }),231    });232233    if (result.pageOrigin !== "https://example.com") {234      throw new Error(`Unexpected page origin: ${result.pageOrigin}`);235    }236    if (result.allowed?.error === "invalid_sender") {237      throw new Error("Content-script-allowed GET_SETTINGS was rejected");238    }239    for (const [name, response] of [["GET_ENTRIES", result.entries], ["GET_RECOVERY_WORDS", result.recovery]]) {240      if (response?.error !== "invalid_sender") {241        throw new Error(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify(response)}`);242      }243    }244    console.log("Firefox sender-context test passed: real content-script GET_ENTRIES and GET_RECOVERY_WORDS were rejected, GET_SETTINGS remained allowed.");245  } finally {246    if (driver && sessionId) {247      try {248        await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });249      } catch {250        // The browser may already have exited after a failed startup.251      }252    }253    driver?.kill("SIGTERM");254    await rm(tempRoot, { recursive: true, force: true });255  }256}257258run().catch((error) => {259  console.error(error instanceof Error ? error.message : error);260  process.exitCode = 1;261});