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 "security.sandbox.content.level": 0,174 "security.sandbox.gpu.level": 0,175 "security.sandbox.rdd.level": 0,176 "gfx.webrender.all": false,177 "gfx.webrender.software": false,178 "layers.acceleration.disabled": true,179 "media.hardware-video-decoding.force-enabled": false,180 },181 },182 };183 const session = await webdriverRequest(port, "/session", {184 method: "POST",185 body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),186 });187 sessionId = session.sessionId;188189 const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {190 method: "POST",191 body: JSON.stringify({192 addon: (await readFile(xpiPath)).toString("base64"),193 temporary: true,194 }),195 });196 if (install?.id && install.id !== EXTENSION_ID) {197 throw new Error(`Unexpected Firefox add-on id: ${install.id}`);198 }199200 await webdriverRequest(port, `/session/${sessionId}/url`, {201 method: "POST",202 body: JSON.stringify({ url: "https://example.com/" }),203 });204 205 206 await new Promise((resolveDelay) => setTimeout(resolveDelay, 2000));207 const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {208 method: "POST",209 body: JSON.stringify({210 script: `211 const done = arguments[arguments.length - 1];212 const source = "peach-firefox-sender-probe";213 const send = (message) => new Promise((resolve) => {214 const id = crypto.randomUUID();215 const timer = setTimeout(() => {216 window.removeEventListener("message", onResponse);217 resolve({ ok: false, error: "probe_timeout" });218 }, 5000);219 function onResponse(event) {220 let response;221 try {222 response = JSON.parse(event.data);223 } catch {224 return;225 }226 if (response?.source !== source + ":response" || response.id !== id) return;227 clearTimeout(timer);228 window.removeEventListener("message", onResponse);229 resolve(response.response);230 }231 window.addEventListener("message", onResponse);232 window.postMessage(JSON.stringify({ source, id, message }), "*");233 });234 Promise.all([235 send({ type: "GET_SETTINGS" }),236 send({ type: "GET_ENTRIES" }),237 send({ type: "GET_RECOVERY_WORDS" }),238 ]).then(([allowed, entries, recovery]) => done({239 pageOrigin: location.origin,240 probeReady: document.documentElement.dataset.peachFirefoxSenderProbe ?? null,241 allowed,242 entries,243 recovery,244 }));245 `,246 args: [],247 }),248 });249250 if (result.pageOrigin !== "https://example.com") {251 throw new Error(`Unexpected page origin: ${result.pageOrigin}`);252 }253 if (result.probeReady !== "ready") {254 throw new Error(`Firefox content-script probe did not execute: ${JSON.stringify(result)}`);255 }256 if (result.allowed?.error === "invalid_sender") {257 throw new Error("Content-script-allowed GET_SETTINGS was rejected");258 }259 for (const [name, response] of [["GET_ENTRIES", result.entries], ["GET_RECOVERY_WORDS", result.recovery]]) {260 if (response?.error !== "invalid_sender") {261 throw new Error(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify({ result, response })}`);262 }263 }264 console.log("Firefox sender-context test passed: real content-script GET_ENTRIES and GET_RECOVERY_WORDS were rejected, GET_SETTINGS remained allowed.");265 } finally {266 if (driver && sessionId) {267 try {268 await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });269 } catch {270 271 }272 }273 driver?.kill("SIGTERM");274 await rm(tempRoot, { recursive: true, force: true });275 }276}277278run().catch((error) => {279 console.error(error instanceof Error ? error.message : error);280 process.exitCode = 1;281});