// End-to-end harness: launches a real Chromium with the unpacked MV3 // build loaded, collects every uncaught page error and console.error, and // exposes the popup flows the tests drive. // // This runs inside the pinned Playwright container; see script/test-e2e. // It is deliberately NOT part of make check — REPO_POLICIES.md caps // make test at 20 seconds and a browser suite does not fit. "use strict"; const fs = require("fs"); const os = require("os"); const path = require("path"); const { chromium } = require("playwright-core"); const { installNetworkStubs } = require("./network"); const REPO_ROOT = path.resolve(__dirname, "..", ".."); const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome"); // Page errors that are known, tracked, and deliberately tolerated. Every // entry must name the issue that will remove it. This list is the one // concession in an otherwise zero-tolerance policy: an uncaught error is // how this harness caught issue #150 in the first place. const ALLOWED_ERRORS = [ { // libsodium ships a WASM build and an asm.js fallback. The // extension CSP (script-src 'self', with no wasm-unsafe-eval) // refuses the WASM module on every popup load; libsodium catches // it and falls back to asm.js, so the wallet works. Deciding // which backend actually ships is issue #182, and this entry gets // deleted when that lands. issue: "#182", pattern: /Refused to compile or instantiate WebAssembly module/, }, ]; function isAllowed(text) { return ALLOWED_ERRORS.some((a) => a.pattern.test(text)); } class ErrorCollector { constructor() { this.entries = []; } record(kind, text) { const line = kind + ": " + String(text).split("\n")[0]; if (isAllowed(line)) return; this.entries.push(line); } mark() { return this.entries.length; } since(mark) { return this.entries.slice(mark); } } function attachErrorListeners(ctx, errors) { const attachPage = (page) => { page.on("pageerror", (err) => { errors.record("pageerror", err.message || String(err)); }); page.on("console", (msg) => { if (msg.type() === "error") { errors.record("console.error", msg.text()); } }); }; ctx.pages().forEach(attachPage); ctx.on("page", attachPage); // Per-page listeners only: the context-level "weberror" event covers // the same page exceptions and would double-report them. Playwright // exposes no error EVENT for service workers, so an uncaught // exception in the background worker is not visible here — everything // this suite drives lives in the popup page. That is an error-channel // gap only: worker NETWORK traffic is intercepted and reported like // any other, and assertWorkerTrafficIntercepted() below fails the run // if it ever stops being. } async function serviceWorker(ctx) { const [existing] = ctx.serviceWorkers(); if (existing) return existing; return ctx.waitForEvent("serviceworker", { timeout: 30000 }); } // How long to wait for the background worker's first outbound request. // Measured at roughly 650ms after the route is installed; the margin is // for a loaded machine, not for hope. const WORKER_TRAFFIC_TIMEOUT_MS = 30000; // ctx.route() only sees service-worker requests when Playwright runs with // PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e // sets. Without it the worker's traffic — notably the phishing blocklist // fetch src/background/index.js issues at startup — goes to the real // internet, and nothing says so, because src/shared/phishingDomains.js // swallows fetch failures. A harness whose isolation can lapse in silence // is worthless, so this does not take the flag on trust: the background // worker's own startup fetch has to show up in the route handler, or the // suite refuses to run. // // Deliberately NOT a synthetic probe fetched through worker.evaluate(): // evaluating in an extension worker this early kills it (the call fails // with "Target page, context or browser has been closed" and the worker // disappears), which would break the very thing being measured. Observing // traffic the extension already generates costs nothing and cannot // perturb it. async function assertWorkerTrafficIntercepted(stubs) { const seen = await stubs.waitForServiceWorkerTraffic( WORKER_TRAFFIC_TIMEOUT_MS, ); if (seen) return seen; throw new Error( "no service-worker request reached the route handler within " + WORKER_TRAFFIC_TIMEOUT_MS + "ms, so background worker traffic is escaping this harness and " + "going to the real internet. Run the suite through " + "script/test-e2e, which sets " + "PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1. If a " + "Playwright upgrade dropped that flag, replace the mechanism or " + "downgrade the isolation claims in tests/e2e/network.js and " + "README.md — do not delete this check. If instead the " + "background worker legitimately stopped making startup " + "requests, this check needs a new anchor, because there is no " + "longer any worker traffic to observe", ); } async function launch(routeOpts) { if (!fs.existsSync(path.join(EXT_PATH, "manifest.json"))) { throw new Error( "no unpacked build at " + EXT_PATH + " — run make build before the e2e suite", ); } const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-e2e-")); const ctx = await chromium.launchPersistentContext(userDir, { // channel: "chromium" is load-bearing. The default headless mode // uses the headless shell, which silently refuses to load // extensions: there is no error at all, the service worker simply // never appears. This cost real debugging time once already. channel: "chromium", headless: true, args: [ "--disable-extensions-except=" + EXT_PATH, "--load-extension=" + EXT_PATH, // The container runs unprivileged; Chrome's sandbox needs // capabilities the harness deliberately does not grant it. "--no-sandbox", // Belt to the interception braces: nothing that slips past // the route handler can resolve a name, so a request that // escapes cannot actually reach the internet. Detection is // still assertWorkerTrafficIntercepted()'s job — this only // bounds the damage while a gap goes unnoticed. Playwright // fulfils routed requests without touching the resolver, and // it drives the browser over a pipe, so neither is affected. "--host-resolver-rules=MAP * ~NOTFOUND", ], }); const cleanup = async () => { await ctx.close().catch(() => {}); fs.rmSync(userDir, { recursive: true, force: true }); }; try { const errors = new ErrorCollector(); attachErrorListeners(ctx, errors); routeOpts.report = (text) => errors.record("network", text); const stubs = await installNetworkStubs(ctx, routeOpts); await assertWorkerTrafficIntercepted(stubs); // The extension id is derived from the unpacked path, so it // changes and must never be hardcoded. It is the host part of the // service worker URL. const sw = await serviceWorker(ctx); const id = new URL(sw.url()).host; return { ctx, errors, extensionId: id, popupUrl: "chrome-extension://" + id + "/src/popup/index.html", close: cleanup, }; } catch (e) { // Anything that fails after the browser is up has to tear it down // on the way out: an orphaned context keeps node alive forever, // turning a clean failure into a hung run. await cleanup(); throw e; } } // ---------------------------------------------------------------- flows const PASSWORD = "e2e-harness-password"; async function visible(page, selector, timeout = 15000) { await page.waitForSelector(selector, { state: "visible", timeout }); } async function openPopup(ctx, popupUrl) { const page = await ctx.newPage(); await page.goto(popupUrl); return page; } // Full wallet creation through the real UI: BIP-39 generation, libsodium // vault encryption and extension storage persistence, for real. async function createWallet(page) { await page.click("#btn-welcome-add"); await visible(page, "#view-add-wallet"); await page.click("#btn-generate-phrase"); await page.waitForFunction(() => { const el = document.getElementById("wallet-mnemonic"); return el && el.value.trim().split(/\s+/).length >= 12; }); await page.fill("#add-wallet-password", PASSWORD); await page.fill("#add-wallet-password-confirm", PASSWORD); await page.click("#btn-add-wallet-confirm"); await visible(page, "#view-main", 60000); } // Reach the address detail screen from wherever the popup restored to. // Clicking .address-row does not open it; the [info] button does. async function openAddressDetail(page) { const onAddress = await page.isVisible("#view-address"); if (!onAddress) { await visible(page, "#view-main"); await page.click("#wallet-list .btn-addr-info"); } await visible(page, "#view-address"); } module.exports = { createWallet, launch, openAddressDetail, openPopup, visible, };