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 ].filter(Boolean);28 return candidates.find((candidate) => existsSync(candidate));29}3031function findFreePort() {32 return new Promise((resolvePort, reject) => {33 const server = net.createServer();34 server.once("error", reject);35 server.listen(0, "127.0.0.1", () => {36 const address = server.address();37 if (!address || typeof address === "string") {38 server.close();39 reject(new Error("Could not allocate a WebDriver port"));40 return;41 }42 server.close(() => resolvePort(address.port));43 });44 });45}4647async function waitForDriver(port) {48 const deadline = Date.now() + 30_000;49 while (Date.now() < deadline) {50 try {51 const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 500);52 if (response.ok && (await response.json()).value?.ready) return;53 } catch {54 55 }56 await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));57 }58 throw new Error("geckodriver did not become ready within 30 seconds");59}6061async function fetchWithTimeout(url, options, timeoutMs) {62 const controller = new AbortController();63 let timeoutId;64 const timeout = new Promise((_, reject) => {65 timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);66 });67 try {68 return await Promise.race([69 fetch(url, { ...options, signal: controller.signal }),70 timeout,71 ]);72 } finally {73 clearTimeout(timeoutId);74 controller.abort();75 }76}7778async function webdriverRequest(port, path, options = {}) {79 const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {80 ...options,81 headers: { "content-type": "application/json", ...(options.headers ?? {}) },82 }, 10_000);83 const body = await response.json();84 if (!response.ok || body.value?.error) {85 throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);86 }87 return body.value;88}8990async function buildTestExtension(tempRoot) {91 const sourceDir = join(tempRoot, "extension");92 const artifactsDir = join(tempRoot, "artifacts");93 await cp(DIST, sourceDir, { recursive: true });94 await mkdir(artifactsDir, { recursive: true });95 await cp(PROBE, join(sourceDir, "firefox-sender-probe.js"));9697 const manifestPath = join(sourceDir, "manifest.json");98 const manifest = JSON.parse(await readFile(manifestPath, "utf8"));99 manifest.content_scripts.push({100 matches: ["<all_urls>"],101 js: ["firefox-sender-probe.js"],102 run_at: "document_start",103 all_frames: false,104 });105 await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);106107 await execFileAsync(WEB_EXT, [108 "build",109 "--source-dir", sourceDir,110 "--artifacts-dir", artifactsDir,111 "--filename", "peach-firefox-sender-context.xpi",112 "--overwrite-dest",113 "--no-input",114 ], { cwd: ROOT });115 return join(artifactsDir, "peach-firefox-sender-context.xpi");116}117118async function run() {119 if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");120 if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");121122 const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-sender-"));123 let driver;124 let driverPort;125 let sessionId;126 try {127 const xpiPath = await buildTestExtension(tempRoot);128 const firefoxBinary = findFirefoxBinary();129 const port = await findFreePort();130 driverPort = port;131 driver = await start({132 host: "127.0.0.1",133 port,134 geckoDriverVersion: "0.37.1",135 ...(firefoxBinary ? { binary: firefoxBinary } : {}),136 log: "error",137 });138 await waitForDriver(port);139140 const capabilities = {141 browserName: "firefox",142 acceptInsecureCerts: true,143 "moz:firefoxOptions": {144 args: ["-headless"],145 ...(firefoxBinary ? { binary: firefoxBinary } : {}),146 prefs: {147 "browser.shell.checkDefaultBrowser": false,148 "browser.startup.page": 0,149 "browser.startup.homepage": "about:blank",150 },151 },152 };153 const session = await webdriverRequest(port, "/session", {154 method: "POST",155 body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),156 });157 sessionId = session.sessionId;158159 const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {160 method: "POST",161 body: JSON.stringify({162 addon: (await readFile(xpiPath)).toString("base64"),163 temporary: true,164 }),165 });166 if (install?.id && install.id !== EXTENSION_ID) {167 throw new Error(`Unexpected Firefox add-on id: ${install.id}`);168 }169170 await webdriverRequest(port, `/session/${sessionId}/url`, {171 method: "POST",172 body: JSON.stringify({ url: "https://example.com/" }),173 });174 const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {175 method: "POST",176 body: JSON.stringify({177 script: `178 const done = arguments[arguments.length - 1];179 const source = "peach-firefox-sender-probe";180 const send = (message) => new Promise((resolve) => {181 const id = crypto.randomUUID();182 const timer = setTimeout(() => {183 window.removeEventListener("message", onMessage);184 resolve({ ok: false, error: "probe_timeout" });185 }, 5000);186 function onMessage(event) {187 if (event.source !== window || event.data?.source !== source || event.data.id !== id) return;188 clearTimeout(timer);189 window.removeEventListener("message", onMessage);190 resolve(event.data.response);191 }192 window.addEventListener("message", onMessage);193 window.postMessage({ source, id, message }, window.location.origin);194 });195 Promise.all([196 send({ type: "GET_SETTINGS" }),197 send({ type: "GET_ENTRIES" }),198 send({ type: "GET_RECOVERY_WORDS" }),199 ]).then(([allowed, entries, recovery]) => done({200 pageOrigin: location.origin,201 allowed,202 entries,203 recovery,204 }));205 `,206 args: [],207 }),208 });209210 if (result.pageOrigin !== "https://example.com") {211 throw new Error(`Unexpected page origin: ${result.pageOrigin}`);212 }213 if (result.allowed?.error === "invalid_sender") {214 throw new Error("Content-script-allowed GET_SETTINGS was rejected");215 }216 for (const [name, response] of [["GET_ENTRIES", result.entries], ["GET_RECOVERY_WORDS", result.recovery]]) {217 if (response?.error !== "invalid_sender") {218 throw new Error(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify(response)}`);219 }220 }221 console.log("Firefox sender-context test passed: real content-script GET_ENTRIES and GET_RECOVERY_WORDS were rejected, GET_SETTINGS remained allowed.");222 } finally {223 if (driver && sessionId) {224 try {225 await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });226 } catch {227 228 }229 }230 driver?.kill("SIGTERM");231 await rm(tempRoot, { recursive: true, force: true });232 }233}234235run().catch((error) => {236 console.error(error instanceof Error ? error.message : error);237 process.exitCode = 1;238});