#!/usr/bin/env node import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import net from "node:net"; import { start } from "geckodriver"; const execFileAsync = promisify(execFile); const ROOT = resolve(new URL("..", import.meta.url).pathname); const DIST = join(ROOT, "dist-firefox"); const PROBE = join(ROOT, "tests/fixtures/firefox-sender-probe.js"); const WEB_EXT = join(ROOT, "node_modules/.bin/web-ext"); const EXTENSION_ID = "peach@peachpasswords.com"; function findFirefoxBinary() { const candidates = [ process.env.PEACH_FIREFOX_BINARY, process.env.FIREFOX_BIN, "/usr/bin/firefox", "/usr/local/bin/firefox", "/Applications/Firefox.app/Contents/MacOS/firefox", "/Applications/Firefox Nightly.app/Contents/MacOS/firefox", ].filter(Boolean); return candidates.find((candidate) => existsSync(candidate)); } function findFreePort() { return new Promise((resolvePort, reject) => { const server = net.createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(); reject(new Error("Could not allocate a WebDriver port")); return; } server.close(() => resolvePort(address.port)); }); }); } async function waitForDriver(port) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { try { const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 500); if (response.ok && (await response.json()).value?.ready) return; } catch { // geckodriver is still starting. } await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); } throw new Error("geckodriver did not become ready within 30 seconds"); } async function fetchWithTimeout(url, options, timeoutMs) { const controller = new AbortController(); let timeoutId; const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs); }); try { return await Promise.race([ fetch(url, { ...options, signal: controller.signal }), timeout, ]); } finally { clearTimeout(timeoutId); controller.abort(); } } async function webdriverRequest(port, path, options = {}) { const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, { ...options, headers: { "content-type": "application/json", ...(options.headers ?? {}) }, }, 10_000); const body = await response.json(); if (!response.ok || body.value?.error) { throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`); } return body.value; } async function buildTestExtension(tempRoot) { const sourceDir = join(tempRoot, "extension"); const artifactsDir = join(tempRoot, "artifacts"); await cp(DIST, sourceDir, { recursive: true }); await mkdir(artifactsDir, { recursive: true }); await cp(PROBE, join(sourceDir, "firefox-sender-probe.js")); const manifestPath = join(sourceDir, "manifest.json"); const manifest = JSON.parse(await readFile(manifestPath, "utf8")); manifest.content_scripts.push({ matches: [""], js: ["firefox-sender-probe.js"], run_at: "document_start", all_frames: false, }); await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); await execFileAsync(WEB_EXT, [ "build", "--source-dir", sourceDir, "--artifacts-dir", artifactsDir, "--filename", "peach-firefox-sender-context.xpi", "--overwrite-dest", "--no-input", ], { cwd: ROOT }); return join(artifactsDir, "peach-firefox-sender-context.xpi"); } async function run() { if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first"); if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first"); const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-sender-")); let driver; let driverPort; let sessionId; try { const xpiPath = await buildTestExtension(tempRoot); const firefoxBinary = findFirefoxBinary(); const port = await findFreePort(); driverPort = port; driver = await start({ host: "127.0.0.1", port, geckoDriverVersion: "0.37.1", ...(firefoxBinary ? { binary: firefoxBinary } : {}), log: "error", }); await waitForDriver(port); const capabilities = { browserName: "firefox", acceptInsecureCerts: true, "moz:firefoxOptions": { args: ["-headless"], ...(firefoxBinary ? { binary: firefoxBinary } : {}), prefs: { "browser.shell.checkDefaultBrowser": false, "browser.startup.page": 0, "browser.startup.homepage": "about:blank", }, }, }; const session = await webdriverRequest(port, "/session", { method: "POST", body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }), }); sessionId = session.sessionId; const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, { method: "POST", body: JSON.stringify({ addon: (await readFile(xpiPath)).toString("base64"), temporary: true, }), }); if (install?.id && install.id !== EXTENSION_ID) { throw new Error(`Unexpected Firefox add-on id: ${install.id}`); } await webdriverRequest(port, `/session/${sessionId}/url`, { method: "POST", body: JSON.stringify({ url: "https://example.com/" }), }); const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, { method: "POST", body: JSON.stringify({ script: ` const done = arguments[arguments.length - 1]; const source = "peach-firefox-sender-probe"; const send = (message) => new Promise((resolve) => { const id = crypto.randomUUID(); const timer = setTimeout(() => { window.removeEventListener("message", onMessage); resolve({ ok: false, error: "probe_timeout" }); }, 5000); function onMessage(event) { if (event.source !== window || event.data?.source !== source || event.data.id !== id) return; clearTimeout(timer); window.removeEventListener("message", onMessage); resolve(event.data.response); } window.addEventListener("message", onMessage); window.postMessage({ source, id, message }, window.location.origin); }); Promise.all([ send({ type: "GET_SETTINGS" }), send({ type: "GET_ENTRIES" }), send({ type: "GET_RECOVERY_WORDS" }), ]).then(([allowed, entries, recovery]) => done({ pageOrigin: location.origin, allowed, entries, recovery, })); `, args: [], }), }); if (result.pageOrigin !== "https://example.com") { throw new Error(`Unexpected page origin: ${result.pageOrigin}`); } if (result.allowed?.error === "invalid_sender") { throw new Error("Content-script-allowed GET_SETTINGS was rejected"); } for (const [name, response] of [["GET_ENTRIES", result.entries], ["GET_RECOVERY_WORDS", result.recovery]]) { if (response?.error !== "invalid_sender") { throw new Error(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify(response)}`); } } console.log("Firefox sender-context test passed: real content-script GET_ENTRIES and GET_RECOVERY_WORDS were rejected, GET_SETTINGS remained allowed."); } finally { if (driver && sessionId) { try { await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" }); } catch { // The browser may already have exited after a failed startup. } } driver?.kill("SIGTERM"); await rm(tempRoot, { recursive: true, force: true }); } } run().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });