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) {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 57 }58 await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));59 }60 throw new Error("geckodriver did not become ready within 30 seconds");61}6263async function fetchWithTimeout(url, options, timeoutMs) {64 const controller = new AbortController();65 let timeoutId;66 const timeout = new Promise((_, reject) => {67 timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);68 });69 try {70 return await Promise.race([71 fetch(url, { ...options, signal: controller.signal }),72 timeout,73 ]);74 } finally {75 clearTimeout(timeoutId);76 controller.abort();77 }78}7980async function webdriverRequest(port, path, options = {}) {81 const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {82 ...options,83 headers: { "content-type": "application/json", ...(options.headers ?? {}) },84 }, 10_000);85 const body = await response.json();86 if (!response.ok || body.value?.error) {87 throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);88 }89 return body.value;90}9192async function buildTestExtension(tempRoot) {93 const sourceDir = join(tempRoot, "extension");94 const artifactsDir = join(tempRoot, "artifacts");95 await cp(DIST, sourceDir, { recursive: true });96 await mkdir(artifactsDir, { recursive: true });97 await cp(PROBE, join(sourceDir, "firefox-sender-probe.js"));9899 const manifestPath = join(sourceDir, "manifest.json");100 const manifest = JSON.parse(await readFile(manifestPath, "utf8"));101 manifest.content_scripts.push({102 matches: ["<all_urls>"],103 js: ["firefox-sender-probe.js"],104 run_at: "document_start",105 all_frames: false,106 });107 await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);108109 await execFileAsync(WEB_EXT, [110 "build",111 "--source-dir", sourceDir,112 "--artifacts-dir", artifactsDir,113 "--filename", "peach-firefox-sender-context.xpi",114 "--overwrite-dest",115 "--no-input",116 ], { cwd: ROOT });117 return join(artifactsDir, "peach-firefox-sender-context.xpi");118}119120async function run() {121 if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");122 if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");123124 const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-sender-"));125 let driver;126 let driverPort;127 let sessionId;128 try {129 const xpiPath = await buildTestExtension(tempRoot);130 const firefoxBinary = findFirefoxBinary();131 132 133 Object.assign(process.env, {134 MOZ_HEADLESS: "1",135 MOZ_DISABLE_CONTENT_SANDBOX: "1",136 MOZ_DISABLE_RDD_SANDBOX: "1",137 LIBGL_ALWAYS_SOFTWARE: "1",138 });139 const port = await findFreePort();140 driverPort = port;141 driver = await start({142 host: "127.0.0.1",143 port,144 geckoDriverVersion: "0.37.1",145 ...(firefoxBinary ? { binary: firefoxBinary } : {}),146 log: "error",147 spawnOpts: {148 env: {149 ...process.env,150 MOZ_HEADLESS: "1",151 MOZ_DISABLE_CONTENT_SANDBOX: "1",152 MOZ_DISABLE_RDD_SANDBOX: "1",153 LIBGL_ALWAYS_SOFTWARE: "1",154 },155 },156 });157 await waitForDriver(port);158159 const capabilities = {160 browserName: "firefox",161 acceptInsecureCerts: true,162 "moz:firefoxOptions": {163 args: ["-headless", "-no-remote"],164 ...(firefoxBinary ? { binary: firefoxBinary } : {}),165 prefs: {166 "browser.shell.checkDefaultBrowser": false,167 "browser.startup.page": 0,168 "browser.startup.homepage": "about:blank",169 },170 },171 };172 const session = await webdriverRequest(port, "/session", {173 method: "POST",174 body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),175 });176 sessionId = session.sessionId;177178 const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {179 method: "POST",180 body: JSON.stringify({181 addon: (await readFile(xpiPath)).toString("base64"),182 temporary: true,183 }),184 });185 if (install?.id && install.id !== EXTENSION_ID) {186 throw new Error(`Unexpected Firefox add-on id: ${install.id}`);187 }188189 await webdriverRequest(port, `/session/${sessionId}/url`, {190 method: "POST",191 body: JSON.stringify({ url: "https://example.com/" }),192 });193 const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {194 method: "POST",195 body: JSON.stringify({196 script: `197 const done = arguments[arguments.length - 1];198 const source = "peach-firefox-sender-probe";199 const send = (message) => new Promise((resolve) => {200 const id = crypto.randomUUID();201 const timer = setTimeout(() => {202 window.removeEventListener("message", onMessage);203 resolve({ ok: false, error: "probe_timeout" });204 }, 5000);205 function onMessage(event) {206 if (event.source !== window || event.data?.source !== source || event.data.id !== id) return;207 clearTimeout(timer);208 window.removeEventListener("message", onMessage);209 resolve(event.data.response);210 }211 window.addEventListener("message", onMessage);212 window.postMessage({ source, id, message }, window.location.origin);213 });214 Promise.all([215 send({ type: "GET_SETTINGS" }),216 send({ type: "GET_ENTRIES" }),217 send({ type: "GET_RECOVERY_WORDS" }),218 ]).then(([allowed, entries, recovery]) => done({219 pageOrigin: location.origin,220 allowed,221 entries,222 recovery,223 }));224 `,225 args: [],226 }),227 });228229 if (result.pageOrigin !== "https://example.com") {230 throw new Error(`Unexpected page origin: ${result.pageOrigin}`);231 }232 if (result.allowed?.error === "invalid_sender") {233 throw new Error("Content-script-allowed GET_SETTINGS was rejected");234 }235 for (const [name, response] of [["GET_ENTRIES", result.entries], ["GET_RECOVERY_WORDS", result.recovery]]) {236 if (response?.error !== "invalid_sender") {237 throw new Error(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify(response)}`);238 }239 }240 console.log("Firefox sender-context test passed: real content-script GET_ENTRIES and GET_RECOVERY_WORDS were rejected, GET_SETTINGS remained allowed.");241 } finally {242 if (driver && sessionId) {243 try {244 await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });245 } catch {246 247 }248 }249 driver?.kill("SIGTERM");250 await rm(tempRoot, { recursive: true, force: true });251 }252}253254run().catch((error) => {255 console.error(error instanceof Error ? error.message : error);256 process.exitCode = 1;257});