test-firefox-sender-context.mjs

13.6 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 { createServer as createHttpServer } from "node:http";8import { tmpdir } from "node:os";9import { join, resolve } from "node:path";10import net from "node:net";11import { start } from "geckodriver";1213const execFileAsync = promisify(execFile);14const ROOT = resolve(new URL("..", import.meta.url).pathname);15const DIST = join(ROOT, "dist-firefox");16const PROBE = join(ROOT, "tests/fixtures/firefox-sender-probe.js");17const WEB_EXT = join(ROOT, "node_modules/.bin/web-ext");18const EXTENSION_ID = "[email protected]";1920function findFirefoxBinary() {21  const candidates = [22    process.env.PEACH_FIREFOX_BINARY,23    process.env.FIREFOX_BIN,24    "/usr/bin/firefox",25    "/usr/local/bin/firefox",26    "/Applications/Firefox.app/Contents/MacOS/firefox",27    "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",28    join(process.env.HOME ?? "", "Library/Caches/ms-playwright/firefox-1538/firefox/Nightly.app/Contents/MacOS/firefox"),29    join(process.env.HOME ?? "", "Library/Caches/ms-playwright/firefox-1511/firefox/Nightly.app/Contents/MacOS/firefox"),30  ].filter(Boolean);31  return candidates.find((candidate) => existsSync(candidate));32}3334function findFreePort() {35  return new Promise((resolvePort, reject) => {36    const server = net.createServer();37    server.once("error", reject);38    server.listen(0, "127.0.0.1", () => {39      const address = server.address();40      if (!address || typeof address === "string") {41        server.close();42        reject(new Error("Could not allocate a WebDriver port"));43        return;44      }45      server.close(() => resolvePort(address.port));46    });47  });48}4950async function waitForDriver(port, driver) {51  const deadline = Date.now() + 30_000;52  while (Date.now() < deadline) {53    try {54      const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 5000);55      if (response.ok && (await response.json()).value?.ready) return;56    } catch {57      if (driver.exitCode !== null || driver.signalCode !== null) {58        throw new Error(`geckodriver exited during startup (${driver.exitCode ?? driver.signalCode})`);59      }60    }61    await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));62  }63  throw new Error("geckodriver did not become ready within 30 seconds");64}6566async function fetchWithTimeout(url, options, timeoutMs) {67  const controller = new AbortController();68  let timeoutId;69  const timeout = new Promise((_, reject) => {70    timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);71  });72  try {73    return await Promise.race([74      fetch(url, { ...options, signal: controller.signal }),75      timeout,76    ]);77  } catch (error) {78    controller.abort();79    throw error;80  } finally {81    clearTimeout(timeoutId);82  }83}8485async function webdriverRequest(port, path, options = {}) {86  const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {87    ...options,88    headers: { "content-type": "application/json", ...(options.headers ?? {}) },89  }, 10_000);90  const body = await response.json();91  if (!response.ok || body.value?.error) {92    throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);93  }94  return body.value;95}9697async function stopDriver(driver) {98  if (!driver || driver.exitCode !== null || driver.signalCode !== null) return;99  const exited = new Promise((resolveExit) => driver.once("exit", resolveExit));100  driver.kill("SIGTERM");101  let timeoutId;102  const stopped = await Promise.race([103    exited.then(() => true),104    new Promise((resolveTimeout) => {105      timeoutId = setTimeout(() => resolveTimeout(false), 5000);106    }),107  ]);108  clearTimeout(timeoutId);109  if (!stopped && driver.exitCode === null && driver.signalCode === null) {110    driver.kill("SIGKILL");111    await exited;112  }113}114115async function buildTestExtension(tempRoot) {116  const sourceDir = join(tempRoot, "extension");117  const artifactsDir = join(tempRoot, "artifacts");118  await cp(DIST, sourceDir, { recursive: true });119  await mkdir(artifactsDir, { recursive: true });120  await cp(PROBE, join(sourceDir, "firefox-sender-probe.js"));121122  const manifestPath = join(sourceDir, "manifest.json");123  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));124  manifest.content_scripts.push({125    matches: ["<all_urls>"],126    js: ["firefox-sender-probe.js"],127    run_at: "document_start",128    all_frames: false,129  });130  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);131132  await execFileAsync(WEB_EXT, [133    "build",134    "--source-dir", sourceDir,135    "--artifacts-dir", artifactsDir,136    "--filename", "peach-firefox-sender-context.xpi",137    "--overwrite-dest",138    "--no-input",139  ], { cwd: ROOT });140  return join(artifactsDir, "peach-firefox-sender-context.xpi");141}142143async function run() {144  if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");145  if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");146147  const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-sender-"));148  let driver;149  let driverPort;150  let sessionId;151  let fixtureServer;152  try {153    const xpiPath = await buildTestExtension(tempRoot);154    const firefoxBinary = findFirefoxBinary();155    // Match the Playwright Firefox release environment: this host cannot map156    // the hardware compositor, and Marionette must inherit the same fallback.157    Object.assign(process.env, {158      MOZ_HEADLESS: "1",159      MOZ_DISABLE_CONTENT_SANDBOX: "1",160      MOZ_DISABLE_RDD_SANDBOX: "1",161      LIBGL_ALWAYS_SOFTWARE: "1",162    });163    const port = await findFreePort();164    driverPort = port;165    driver = await start({166      host: "127.0.0.1",167      port,168      geckoDriverVersion: "0.37.1",169      ...(firefoxBinary ? { binary: firefoxBinary } : {}),170      log: "error",171      allowSystemAccess: true,172      spawnOpts: {173        env: {174          ...process.env,175          MOZ_HEADLESS: "1",176          MOZ_DISABLE_CONTENT_SANDBOX: "1",177          MOZ_DISABLE_RDD_SANDBOX: "1",178          LIBGL_ALWAYS_SOFTWARE: "1",179        },180      },181    });182    await waitForDriver(port, driver);183184    const capabilities = {185      browserName: "firefox",186      acceptInsecureCerts: true,187      "moz:firefoxOptions": {188        args: ["-headless", "-no-remote"],189        ...(firefoxBinary ? { binary: firefoxBinary } : {}),190        prefs: {191          "browser.shell.checkDefaultBrowser": false,192          "browser.startup.page": 0,193          "browser.startup.homepage": "about:blank",194          "security.sandbox.content.level": 0,195          "security.sandbox.gpu.level": 0,196          "security.sandbox.rdd.level": 0,197          "gfx.webrender.all": false,198          "gfx.webrender.software": false,199          "layers.acceleration.disabled": true,200          "media.hardware-video-decoding.force-enabled": false,201          "remote.system-access-check.enabled": false,202        },203      },204    };205    const session = await webdriverRequest(port, "/session", {206      method: "POST",207      body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),208    });209    sessionId = session.sessionId;210211    fixtureServer = createHttpServer((_request, response) => {212      response.writeHead(200, { "content-type": "text/html; charset=utf-8" });213      response.end("<!doctype html><html><head><title>Peach sender test</title></head><body></body></html>");214    });215    await new Promise((resolveListen, rejectListen) => {216      fixtureServer.once("error", rejectListen);217      fixtureServer.listen(0, "127.0.0.1", resolveListen);218    });219    const fixtureAddress = fixtureServer.address();220    if (!fixtureAddress || typeof fixtureAddress === "string") {221      throw new Error("Could not allocate the Firefox fixture server port");222    }223    const fixtureOrigin = `http://127.0.0.1:${fixtureAddress.port}`;224225    const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {226      method: "POST",227      body: JSON.stringify({228        path: xpiPath,229        temporary: true,230      }),231    });232    if (install?.id && install.id !== EXTENSION_ID) {233      throw new Error(`Unexpected Firefox add-on id: ${install.id}`);234    }235236    await webdriverRequest(port, `/session/${sessionId}/moz/context`, {237      method: "POST",238      body: JSON.stringify({ context: "chrome" }),239    });240    const addonInfo = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {241      method: "POST",242      body: JSON.stringify({243        script: `244          const done = arguments[arguments.length - 1];245          const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");246          const { ExtensionPermissions } = ChromeUtils.importESModule("resource://gre/modules/ExtensionPermissions.sys.mjs");247          AddonManager.getAddonByID(arguments[0]).then(async (addon) => {248            if (!addon) return null;249            const policy = WebExtensionPolicy.getByID(addon.id);250            await ExtensionPermissions.add(251              addon.id,252              { permissions: [], origins: ["<all_urls>"] },253              policy.extension,254            );255            await addon.reload();256            const reloadedAddon = await AddonManager.getAddonByID(addon.id);257            return {258              active: reloadedAddon.isActive,259              permissions: await ExtensionPermissions.get(addon.id),260            };261          }).then(done, (error) => done({ error: String(error), stack: error?.stack }));262        `,263        args: [EXTENSION_ID],264      }),265    });266    await webdriverRequest(port, `/session/${sessionId}/moz/context`, {267      method: "POST",268      body: JSON.stringify({ context: "content" }),269    });270    if (!addonInfo?.active || !addonInfo.permissions?.origins?.includes("<all_urls>")) {271      throw new Error(`Firefox add-on host permission was not granted: ${JSON.stringify(addonInfo)}`);272    }273274    await webdriverRequest(port, `/session/${sessionId}/url`, {275      method: "POST",276      body: JSON.stringify({ url: `${fixtureOrigin}/` }),277    });278    // Firefox may finish navigation before document_start content scripts have279    // registered their relay listener; wait for the extension boundary to settle.280    await new Promise((resolveDelay) => setTimeout(resolveDelay, 2000));281    const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {282      method: "POST",283      body: JSON.stringify({284        script: `285          const done = arguments[arguments.length - 1];286          const source = "peach-firefox-sender-probe";287          const send = (message) => new Promise((resolve) => {288            const id = crypto.randomUUID();289            const timer = setTimeout(() => {290              window.removeEventListener("message", onResponse);291              resolve({ ok: false, error: "probe_timeout" });292            }, 5000);293            function onResponse(event) {294              let response;295              try {296                response = JSON.parse(event.data);297              } catch {298                return;299              }300              if (response?.source !== source + ":response" || response.id !== id) return;301              clearTimeout(timer);302              window.removeEventListener("message", onResponse);303              resolve(response.response);304            }305            window.addEventListener("message", onResponse);306            window.postMessage(JSON.stringify({ source, id, message }), "*");307          });308          Promise.all([309            send({ type: "GET_SETTINGS" }),310            send({ type: "GET_ENTRIES" }),311            send({ type: "GET_RECOVERY_WORDS" }),312            send({ type: "STORAGE_SESSION" }),313          ]).then(([allowed, entries, recovery, storage]) => done({314            pageOrigin: location.origin,315            probeReady: document.documentElement.dataset.peachFirefoxSenderProbe ?? null,316            allowed,317            entries,318            recovery,319            storage,320          }));321        `,322        args: [],323      }),324    });325326    if (result.pageOrigin !== fixtureOrigin) {327      throw new Error(`Unexpected page origin: ${result.pageOrigin}`);328    }329    if (result.probeReady !== "ready") {330      throw new Error(`Firefox content-script probe did not execute: ${JSON.stringify(result)}`);331    }332    if (Object.hasOwn(result.storage ?? {}, "peach_session_dk")) {333      throw new Error(`Firefox content-script accessed the session escrow key: ${JSON.stringify(result.storage)}`);334    }335    if (result.allowed?.error === "invalid_sender") {336      throw new Error("Content-script-allowed GET_SETTINGS was rejected");337    }338    for (const [name, response] of [["GET_ENTRIES", result.entries], ["GET_RECOVERY_WORDS", result.recovery]]) {339      if (response?.error !== "invalid_sender") {340        throw new Error(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify({ result, response })}`);341      }342    }343    console.log("Firefox sender-context test passed: real content-script GET_ENTRIES and GET_RECOVERY_WORDS were rejected, GET_SETTINGS remained allowed.");344  } finally {345    if (driver && sessionId) {346      try {347        await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });348      } catch {349        // The browser may already have exited after a failed startup.350      }351    }352    if (fixtureServer) {353      await new Promise((resolveClose) => fixtureServer.close(resolveClose));354    }355    await stopDriver(driver);356    await rm(tempRoot, { recursive: true, force: true });357  }358}359360run().catch((error) => {361  console.error(error instanceof Error ? error.message : error);362  process.exitCode = 1;363});