123import { 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 136 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 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});