// 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. // // Empty, and worth keeping that way. Its only entry was the WASM // CompileError libsodium provoked on every popup load, deleted with #182 // when both manifests started allowing WASM; the run that used to need it // is now the run that proves the fix. const ALLOWED_ERRORS = []; function isAllowed(text) { return ALLOWED_ERRORS.some((a) => a.pattern.test(text)); } // Collects every uncaught page error, console.error and unstubbed // request, and hands each one to exactly one reporter. // // This deliberately has NO window API. It used to expose mark()/since() // so a test could ask for "the errors since I started", and that shape // produced a green run that proved nothing twice over: first the mark // started after test 1, so everything recorded during launch was // discarded, then the tail after the final test was never read at all. In // both cases a record fell outside somebody's window and vanished, which // is the precise failure this harness exists to prevent. // // So there is no window left to fall outside of. take() is the only // reader and it always takes everything outstanding, so successive takes // partition the entire record stream with no gaps, and the runner turns // every record it reads into a failure. // // Observation ends when the browser context is closed. Nothing records // after that — the route handler and the console listeners are gone with // the context — so there is no post-teardown phase to collect, and this // class deliberately offers no mechanism pretending to cover one. // // One narrow exception exists, and it is not a mute: expect(). A test that // drives a failure path on purpose — a refused gas estimate, say — provokes // the console.error the code is supposed to emit, and that error is the // behaviour under test rather than an escape. Declaring it consumes exactly // one matching record and no more, and an expectation nothing matched fails // its test just as an unexpected error does. So it cannot be used to // silence anything: it can only assert that a specific error happened. class ErrorCollector { constructor() { this.entries = []; this.taken = 0; this.expectations = []; } // Declare a console.error this test is about to cause deliberately. // `label` names it in the failure message if it never arrives. expect(label, pattern) { this.expectations.push({ label, pattern, matched: false }); } // Declared expectations that nothing matched, clearing the list so each // test starts with none outstanding. unmatchedExpectations() { const out = this.expectations .filter((e) => !e.matched) .map((e) => e.label); this.expectations = []; return out; } record(kind, text) { const line = kind + ": " + String(text).split("\n")[0]; if (isAllowed(line)) return; const expected = this.expectations.find( (e) => !e.matched && e.pattern.test(line), ); if (expected) { expected.matched = true; return; } this.entries.push(line); } // Everything recorded since the previous take(). Never yields a // record twice and never skips one. take() { const out = this.entries.slice(this.taken); this.taken = this.entries.length; return out; } } 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. // // The margin that actually decides whether this check is sound is not // this timeout — it is whether the route handler is installed before the // worker fetches. Measured over several runs: route installation // completes 11-23ms after the context comes up, and the worker's // blocklist fetch arrives 525-883ms after that, so the route wins by // roughly 25-50x. This 30s figure is only slack for a loaded machine on // top of that; losing the race fails the run rather than passing it // quietly, which was verified by forcing a 3s delay before route // installation. 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; // State the observation, not a conclusion. This fires for at least // two quite different causes and the harness cannot tell them apart // from here, so guessing one of them in the message sends the reader // the wrong way. throw new Error( "observed no service-worker request in the route handler within " + WORKER_TRAFFIC_TIMEOUT_MS + "ms. Under working interception the background worker's " + "startup blocklist fetch (src/background/index.js) reaches the " + "handler about half a second after the route is installed. " + "Two causes are plausible and this check cannot distinguish " + "them: (1) service-worker interception is not in effect, so " + "that traffic went to the real internet unobserved — the suite " + "must be run through script/test-e2e, which sets " + "PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " + "Playwright upgrade may have dropped or renamed that flag; " + "(2) no worker request was made in the first place — the route " + "lost the startup race, or the worker no longer fetches at " + "startup, in which case this check needs a new anchor because " + "there is no longer any worker traffic to observe. Either way " + "the fix is a replacement mechanism or an honest downgrade of " + "the isolation claims in tests/e2e/network.js and README.md — " + "not deleting this check", ); } 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 }); } // An empty WebAssembly module: magic number and version header, no // sections. Compiling it in the popup asks the one question that decides // libsodium's backend — may this realm compile WebAssembly — of the real // page under the real shipped manifest, which is the only place the // answer can be observed. Kept independent of src/shared/vault.js on // purpose: a bundle asked to grade itself proves less than an outside // observation of the same realm. const EMPTY_WASM_MODULE = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; async function pageCompilesWasm(page) { return page.evaluate(async (bytes) => { try { await WebAssembly.compile(new Uint8Array(bytes)); return true; } catch { return false; } }, EMPTY_WASM_MODULE); } 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. // // Returns the recovery phrase it generated. Tests that assert on a secret // need the real value — checking for "some 12 words" would pass against the // wrong wallet's phrase, and checking for nothing at all would pass against // a screen that shows the phrase it was supposed to hide. 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; }); const phrase = (await page.inputValue("#wallet-mnemonic")).trim(); 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); return phrase; } // Reach the address detail screen of the FIRST address of the first wallet, // from wherever the popup restored to. Clicking .address-row does not open // it; the [info] button does. // // .first() rather than a bare selector because the suite adds a second // wallet partway through, and every later test would otherwise die in // Playwright's strict mode rather than on an assertion. async function openAddressDetail(page) { const onAddress = await page.isVisible("#view-address"); if (!onAddress) { await visible(page, "#view-main"); await page.locator("#wallet-list .btn-addr-info").first().click(); } await visible(page, "#view-address"); } module.exports = { PASSWORD, createWallet, launch, openAddressDetail, openPopup, pageCompilesWasm, visible, };