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 { createServer as createHttpServer } from "node:http";8import { tmpdir } from "node:os";9import { join, resolve } from "node:path";10import net from "node:net";11import { start } from "geckodriver";1213const execFileAsync = promisify(execFile);14const ROOT = resolve(new URL("..", import.meta.url).pathname);15const DIST = join(ROOT, "dist-firefox");16const PROBE = join(ROOT, "tests/fixtures/firefox-sender-probe.js");17const WEB_EXT = join(ROOT, "node_modules/.bin/web-ext");18const EXTENSION_ID = "[email protected]";1920function findFirefoxBinary() {21 const candidates = [22 process.env.PEACH_FIREFOX_BINARY,23 process.env.FIREFOX_BIN,24 "/usr/bin/firefox",25 "/usr/local/bin/firefox",26 "/Applications/Firefox.app/Contents/MacOS/firefox",27 "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",28 join(process.env.HOME ?? "", "Library/Caches/ms-playwright/firefox-1538/firefox/Nightly.app/Contents/MacOS/firefox"),29 join(process.env.HOME ?? "", "Library/Caches/ms-playwright/firefox-1511/firefox/Nightly.app/Contents/MacOS/firefox"),30 ].filter(Boolean);31 return candidates.find((candidate) => existsSync(candidate));32}3334function findFreePort() {35 return new Promise((resolvePort, reject) => {36 const server = net.createServer();37 server.once("error", reject);38 server.listen(0, "127.0.0.1", () => {39 const address = server.address();40 if (!address || typeof address === "string") {41 server.close();42 reject(new Error("Could not allocate a WebDriver port"));43 return;44 }45 server.close(() => resolvePort(address.port));46 });47 });48}4950async function waitForDriver(port, driver) {51 const deadline = Date.now() + 30_000;52 while (Date.now() < deadline) {53 try {54 const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 5000);55 if (response.ok && (await response.json()).value?.ready) return;56 } catch {57 if (driver.exitCode !== null || driver.signalCode !== null) {58 throw new Error(`geckodriver exited during startup (${driver.exitCode ?? driver.signalCode})`);59 }60 }61 await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));62 }63 throw new Error("geckodriver did not become ready within 30 seconds");64}6566async function fetchWithTimeout(url, options, timeoutMs) {67 const controller = new AbortController();68 let timeoutId;69 const timeout = new Promise((_, reject) => {70 timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);71 });72 try {73 return await Promise.race([74 fetch(url, { ...options, signal: controller.signal }),75 timeout,76 ]);77 } catch (error) {78 controller.abort();79 throw error;80 } finally {81 clearTimeout(timeoutId);82 }83}8485async function webdriverRequest(port, path, options = {}) {86 const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {87 ...options,88 headers: { "content-type": "application/json", ...(options.headers ?? {}) },89 }, 10_000);90 const body = await response.json();91 if (!response.ok || body.value?.error) {92 throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);93 }94 return body.value;95}9697async function stopDriver(driver) {98 if (!driver || driver.exitCode !== null || driver.signalCode !== null) return;99 const exited = new Promise((resolveExit) => driver.once("exit", resolveExit));100 driver.kill("SIGTERM");101 let timeoutId;102 const stopped = await Promise.race([103 exited.then(() => true),104 new Promise((resolveTimeout) => {105 timeoutId = setTimeout(() => resolveTimeout(false), 5000);106 }),107 ]);108 clearTimeout(timeoutId);109 if (!stopped && driver.exitCode === null && driver.signalCode === null) {110 driver.kill("SIGKILL");111 await exited;112 }113}114115async function buildTestExtension(tempRoot) {116 const sourceDir = join(tempRoot, "extension");117 const artifactsDir = join(tempRoot, "artifacts");118 await cp(DIST, sourceDir, { recursive: true });119 await mkdir(artifactsDir, { recursive: true });120 await cp(PROBE, join(sourceDir, "firefox-sender-probe.js"));121122 const manifestPath = join(sourceDir, "manifest.json");123 const manifest = JSON.parse(await readFile(manifestPath, "utf8"));124 manifest.content_scripts.push({125 matches: ["<all_urls>"],126 js: ["firefox-sender-probe.js"],127 run_at: "document_start",128 all_frames: false,129 });130 await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);131132 await execFileAsync(WEB_EXT, [133 "build",134 "--source-dir", sourceDir,135 "--artifacts-dir", artifactsDir,136 "--filename", "peach-firefox-sender-context.xpi",137 "--overwrite-dest",138 "--no-input",139 ], { cwd: ROOT });140 return join(artifactsDir, "peach-firefox-sender-context.xpi");141}142143async function run() {144 if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");145 if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");146147 const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-sender-"));148 let driver;149 let driverPort;150 let sessionId;151 let fixtureServer;152 try {153 const xpiPath = await buildTestExtension(tempRoot);154 const firefoxBinary = findFirefoxBinary();155 156 157 Object.assign(process.env, {158 MOZ_HEADLESS: "1",159 MOZ_DISABLE_CONTENT_SANDBOX: "1",160 MOZ_DISABLE_RDD_SANDBOX: "1",161 LIBGL_ALWAYS_SOFTWARE: "1",162 });163 const port = await findFreePort();164 driverPort = port;165 driver = await start({166 host: "127.0.0.1",167 port,168 geckoDriverVersion: "0.37.1",169 ...(firefoxBinary ? { binary: firefoxBinary } : {}),170 log: "error",171 allowSystemAccess: true,172 spawnOpts: {173 env: {174 ...process.env,175 MOZ_HEADLESS: "1",176 MOZ_DISABLE_CONTENT_SANDBOX: "1",177 MOZ_DISABLE_RDD_SANDBOX: "1",178 LIBGL_ALWAYS_SOFTWARE: "1",179 },180 },181 });182 await waitForDriver(port, driver);183184 const capabilities = {185 browserName: "firefox",186 acceptInsecureCerts: true,187 "moz:firefoxOptions": {188 args: ["-headless", "-no-remote"],189 ...(firefoxBinary ? { binary: firefoxBinary } : {}),190 prefs: {191 "browser.shell.checkDefaultBrowser": false,192 "browser.startup.page": 0,193 "browser.startup.homepage": "about:blank",194 "security.sandbox.content.level": 0,195 "security.sandbox.gpu.level": 0,196 "security.sandbox.rdd.level": 0,197 "gfx.webrender.all": false,198 "gfx.webrender.software": false,199 "layers.acceleration.disabled": true,200 "media.hardware-video-decoding.force-enabled": false,201 "remote.system-access-check.enabled": false,202 },203 },204 };205 const session = await webdriverRequest(port, "/session", {206 method: "POST",207 body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),208 });209 sessionId = session.sessionId;210211 fixtureServer = createHttpServer((_request, response) => {212 response.writeHead(200, { "content-type": "text/html; charset=utf-8" });213 response.end("<!doctype html><html><head><title>Peach sender test</title></head><body></body></html>");214 });215 await new Promise((resolveListen, rejectListen) => {216 fixtureServer.once("error", rejectListen);217 fixtureServer.listen(0, "127.0.0.1", resolveListen);218 });219 const fixtureAddress = fixtureServer.address();220 if (!fixtureAddress || typeof fixtureAddress === "string") {221 throw new Error("Could not allocate the Firefox fixture server port");222 }223 const fixtureOrigin = `http://127.0.0.1:${fixtureAddress.port}`;224225 const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {226 method: "POST",227 body: JSON.stringify({228 path: xpiPath,229 temporary: true,230 }),231 });232 if (install?.id && install.id !== EXTENSION_ID) {233 throw new Error(`Unexpected Firefox add-on id: ${install.id}`);234 }235236 await webdriverRequest(port, `/session/${sessionId}/moz/context`, {237 method: "POST",238 body: JSON.stringify({ context: "chrome" }),239 });240 const addonInfo = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {241 method: "POST",242 body: JSON.stringify({243 script: `244 const done = arguments[arguments.length - 1];245 const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");246 const { ExtensionPermissions } = ChromeUtils.importESModule("resource://gre/modules/ExtensionPermissions.sys.mjs");247 AddonManager.getAddonByID(arguments[0]).then(async (addon) => {248 if (!addon) return null;249 const policy = WebExtensionPolicy.getByID(addon.id);250 await ExtensionPermissions.add(251 addon.id,252 { permissions: [], origins: ["<all_urls>"] },253 policy.extension,254 );255 await addon.reload();256 const reloadedAddon = await AddonManager.getAddonByID(addon.id);257 return {258 active: reloadedAddon.isActive,259 permissions: await ExtensionPermissions.get(addon.id),260 };261 }).then(done, (error) => done({ error: String(error), stack: error?.stack }));262 `,263 args: [EXTENSION_ID],264 }),265 });266 await webdriverRequest(port, `/session/${sessionId}/moz/context`, {267 method: "POST",268 body: JSON.stringify({ context: "content" }),269 });270 if (!addonInfo?.active || !addonInfo.permissions?.origins?.includes("<all_urls>")) {271 throw new Error(`Firefox add-on host permission was not granted: ${JSON.stringify(addonInfo)}`);272 }273274 await webdriverRequest(port, `/session/${sessionId}/url`, {275 method: "POST",276 body: JSON.stringify({ url: `${fixtureOrigin}/` }),277 });278 279 280 await new Promise((resolveDelay) => setTimeout(resolveDelay, 2000));281 const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {282 method: "POST",283 body: JSON.stringify({284 script: `285 const done = arguments[arguments.length - 1];286 const source = "peach-firefox-sender-probe";287 const send = (message) => new Promise((resolve) => {288 const id = crypto.randomUUID();289 const timer = setTimeout(() => {290 window.removeEventListener("message", onResponse);291 resolve({ ok: false, error: "probe_timeout" });292 }, 5000);293 function onResponse(event) {294 let response;295 try {296 response = JSON.parse(event.data);297 } catch {298 return;299 }300 if (response?.source !== source + ":response" || response.id !== id) return;301 clearTimeout(timer);302 window.removeEventListener("message", onResponse);303 resolve(response.response);304 }305 window.addEventListener("message", onResponse);306 window.postMessage(JSON.stringify({ source, id, message }), "*");307 });308 Promise.all([309 send({ type: "GET_SETTINGS" }),310 send({ type: "GET_ENTRIES" }),311 send({ type: "GET_RECOVERY_WORDS" }),312 send({ type: "STORAGE_SESSION" }),313 ]).then(([allowed, entries, recovery, storage]) => done({314 pageOrigin: location.origin,315 probeReady: document.documentElement.dataset.peachFirefoxSenderProbe ?? null,316 allowed,317 entries,318 recovery,319 storage,320 }));321 `,322 args: [],323 }),324 });325326 if (result.pageOrigin !== fixtureOrigin) {327 throw new Error(`Unexpected page origin: ${result.pageOrigin}`);328 }329 if (result.probeReady !== "ready") {330 throw new Error(`Firefox content-script probe did not execute: ${JSON.stringify(result)}`);331 }332 if (Object.hasOwn(result.storage ?? {}, "peach_session_dk")) {333 throw new Error(`Firefox content-script accessed the session escrow key: ${JSON.stringify(result.storage)}`);334 }335 if (result.allowed?.error === "invalid_sender") {336 throw new Error("Content-script-allowed GET_SETTINGS was rejected");337 }338 for (const [name, response] of [["GET_ENTRIES", result.entries], ["GET_RECOVERY_WORDS", result.recovery]]) {339 if (response?.error !== "invalid_sender") {340 throw new Error(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify({ result, response })}`);341 }342 }343 console.log("Firefox sender-context test passed: real content-script GET_ENTRIES and GET_RECOVERY_WORDS were rejected, GET_SETTINGS remained allowed.");344 } finally {345 if (driver && sessionId) {346 try {347 await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });348 } catch {349 350 }351 }352 if (fixtureServer) {353 await new Promise((resolveClose) => fixtureServer.close(resolveClose));354 }355 await stopDriver(driver);356 await rm(tempRoot, { recursive: true, force: true });357 }358}359360run().catch((error) => {361 console.error(error instanceof Error ? error.message : error);362 process.exitCode = 1;363});