// 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 error in // the background worker is not visible here — everything this suite // drives lives in the popup page. } // 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. async function extensionId(ctx) { let [sw] = ctx.serviceWorkers(); if (!sw) { sw = await ctx.waitForEvent("serviceworker", { timeout: 30000 }); } return new URL(sw.url()).host; } 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", ], }); const errors = new ErrorCollector(); attachErrorListeners(ctx, errors); routeOpts.report = (text) => errors.record("network", text); await installNetworkStubs(ctx, routeOpts); const id = await extensionId(ctx); const popupUrl = "chrome-extension://" + id + "/src/popup/index.html"; return { ctx, errors, extensionId: id, popupUrl, async close() { await ctx.close(); fs.rmSync(userDir, { recursive: true, force: true }); }, }; } // ---------------------------------------------------------------- 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 = { ALLOWED_ERRORS, EXT_PATH, REPO_ROOT, createWallet, launch, openAddressDetail, openPopup, visible, };