From d89629d09099cc53cdf906ac0bbe2fa684b735bd Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 9 Aug 2026 14:24:36 +0000 Subject: [PATCH 1/4] test: containerized Chrome end-to-end harness that drives the real popup (closes #181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make check` was green while the AddToken screen crashed on every open. `script/lint` is only `prettier --check`, so a used-but-not-imported identifier is invisible until a browser evaluates it. This adds a suite that runs the real popup in a real Chrome and treats any uncaught page error or console.error as a failure. - `script/test-e2e` (with `make test-e2e` as a thin shim) builds `dist/chrome/` and runs `tests/e2e/run.js` inside the Playwright image, pinned by digest. `playwright-core` is pinned to the matching 1.56.0 through `yarn.lock`; the two must be bumped together because the browsers ship inside the image. - Deliberately outside `script/test` and `script/check`: REPO_POLICIES caps `make test` at 20 seconds. Nothing under `tests/e2e/` is named `*.test.js`, so jest cannot pick it up either. - Launches with `channel: "chromium"`; the default headless shell silently refuses to load extensions with no error at all. The extension id is read from the service worker URL, never hardcoded. - All http(s) traffic is intercepted at the browser level and served from fixtures, so the run is deterministic and offline. Unrecognised outbound requests are reported as failures rather than allowed. - A missing build or an unavailable container fails loudly; a skip that looks like a pass is the failure mode this is meant to prevent. - One allowlisted page error, for the libsodium WASM CSP fallback tracked as #182, which is otherwise untouched here. The suite was demonstrated failing against the unfixed tree with `pageerror: showView is not defined` and `pageerror: addressDotHtml is not defined`, so it carries the two one-line import fixes it caught: closes #150 — `showView` restored to the destructure in `src/popup/views/addToken.js`, dropped by a22f33d, which made the AddToken screen unreachable and corrupted the navigation stack. closes #151 — `addressDotHtml` restored in `src/popup/views/transactionDetail.js`, dropped by df031fd, which threw before `showView("transaction")` for every ERC-20 transfer. The shared `renderAddressHtml` helper is not used here on purpose: it hardcodes the `/address/` explorer URL, and this row needs the token-specific `/token/` link. --- Makefile | 6 +- README.md | 31 ++++ TODO.md | 16 +- package.json | 1 + script/test-e2e | 48 ++++++ src/popup/views/addToken.js | 2 +- src/popup/views/transactionDetail.js | 1 + tests/e2e/harness.js | 188 ++++++++++++++++++++++ tests/e2e/network.js | 223 +++++++++++++++++++++++++++ tests/e2e/run.js | 188 ++++++++++++++++++++++ yarn.lock | 5 + 11 files changed, 704 insertions(+), 5 deletions(-) create mode 100755 script/test-e2e create mode 100644 tests/e2e/harness.js create mode 100644 tests/e2e/network.js create mode 100644 tests/e2e/run.js diff --git a/Makefile b/Makefile index 33ab6d0..6a3f3be 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap setup install test lint fmt fmt-check check docker hooks build build-debug clean dev +.PHONY: bootstrap setup install test test-e2e lint fmt fmt-check check docker hooks build build-debug clean dev # Standard targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -16,6 +16,10 @@ install: test: @script/test +# Browser end-to-end suite. Requires docker; not part of check. +test-e2e: + @script/test-e2e + lint: @script/lint diff --git a/README.md b/README.md index a4023da..3092e1e 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ provide: git pre-commit hook - `script/projectname` — print the project name (used for the Docker image tag) - `script/test` — run the test suite (jest) +- `script/test-e2e` — run the browser end-to-end suite (docker required; see + [End-to-End Tests](#end-to-end-tests)) - `script/lint` — run the linter - `script/fmt` — format all files (writes) - `script/fmt-check` — check formatting (read-only) @@ -82,6 +84,35 @@ provide: - `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/install-precommit` — install the git pre-commit hook +## End-to-End Tests + +`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real +Chrome**, loaded as an unpacked MV3 extension inside a pinned +`mcr.microsoft.com/playwright` container (pinned by digest in `script/test-e2e`; +docker is required and the suite fails loudly rather than skipping if it is +unavailable). The suite lives in `tests/e2e/` and is driven by +`playwright-core`, whose version must stay matched to the container's Playwright +version — the browsers ship inside the image. + +It covers popup load, wallet creation through the UI, the Add Token screen and +the transaction detail screen for an ERC-20 transfer. All outbound network is +intercepted at the browser level and served from fixtures in +`tests/e2e/network.js`, so the run is deterministic and fully offline; +unrecognised outbound requests are reported as failures rather than silently +allowed. + +**Any uncaught page error or `console.error` fails the run.** That is the point: +a `ReferenceError` from a used-but-not-imported identifier is invisible to +`make check` (`script/lint` is only `prettier --check`) but fatal in a browser, +and this suite exists because exactly that class of bug shipped twice. + +`make test-e2e` is deliberately **not** part of `make check` or `make test`. +`REPO_POLICIES.md` caps `make test` at 20 seconds and a browser suite does not +fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot pick it up +either. It is also not wired into the Gitea workflow yet — docker-in-docker in +CI is a separate question. Run it locally before changing anything under +`src/popup/views/`. + ## Rationale Common popular EVM wallets have become bloated with swap UIs, portfolio diff --git a/TODO.md b/TODO.md index b94902f..8a40ed3 100644 --- a/TODO.md +++ b/TODO.md @@ -15,7 +15,8 @@ other branch is in flight: the settings About well landed as #145 on 2026-07-26 and scripts-to-rule-them-all landed as #148, so the `scripts/` directory question is resolved. Full policy file set present. `make check` verified passing on `main` at `23aeae4` on 2026-08-09. The 1.0.0 backlog is filed as -#149-#168. +#149-#168. A real-browser end-to-end suite (`make test-e2e`) now sits alongside +`make check`, which cannot see a runtime `ReferenceError` in a popup view. # Next Step @@ -27,6 +28,11 @@ review. # Completed Steps +- 2026-08-09: Containerized Chrome end-to-end harness (`make test-e2e` / + `script/test-e2e`) driving the real popup with all network intercepted, plus + the two used-but-not-imported crashes it caught: AddToken unreachable (#150) + and TransactionDetail broken for every ERC-20 transfer (#151). Harness + demonstrated failing before the fixes and passing after (#181). - 2026-08-09: Reviewed the repo end to end and filed the 1.0.0 backlog (#149-#168). - 2026-07-26: About well in settings with build info, repo link and the version @@ -52,10 +58,14 @@ review. # Future Steps -- Fix the two `ReferenceError` crashes that make whole screens unreachable: - AddToken (#150) and TransactionDetail for every ERC-20 transfer (#151). - Add ESLint to `script/lint` (#152). `make check` is `prettier --check` only and cannot catch undefined identifiers, which is how #150 and #151 shipped. +- Decide the libsodium backend that actually ships (#182) and delete the single + allowlist entry it owns in `tests/e2e/harness.js`. +- Extend the end-to-end suite to the dApp approval signing path (EIP-1193 + through the real content script, background worker and approval popup), and + decide separately whether docker-in-docker makes `make test-e2e` runnable in + the Gitea workflow. - Make the Firefox target functional: Chrome callback APIs are used against the promise-only `browser` namespace (#153). - Send and transaction-flow correctness: gas fee excluded from the diff --git a/package.json b/package.json index f6aea86..c7ecbdd 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@tailwindcss/cli": "^4.2.1", "esbuild": "^0.27.3", "jest": "^30.2.0", + "playwright-core": "1.56.0", "prettier": "^3.8.1", "tailwindcss": "^4.2.1" }, diff --git a/script/test-e2e b/script/test-e2e new file mode 100755 index 0000000..2a604d0 --- /dev/null +++ b/script/test-e2e @@ -0,0 +1,48 @@ +#!/bin/sh +# script/test-e2e: build the extension and drive the real popup in a real +# Chromium inside a pinned container. Our own extension to +# scripts-to-rule-them-all. +# +# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md +# caps make test at 20 seconds and a browser suite does not fit. Run it +# yourself before touching popup views; it is the only check that can see +# a used-but-not-imported identifier blow up at runtime. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# mcr.microsoft.com/playwright:v1.56.0-noble, 2026-08-09 +# +# The playwright-core devDependency is pinned to the matching Playwright +# version (1.56.0) and the two must be bumped together: the browsers ship +# inside this image, and playwright-core looks for the exact browser +# revision its own version expects. A mismatch fails at launch. +IMAGE="mcr.microsoft.com/playwright@sha256:35246d87a7c88ea9b771c65d33171b2611b02a8253b4b12ce6f94376c55f99f2" + +main() { + cd "$ROOT" + + if ! command -v docker >/dev/null 2>&1; then + echo "test-e2e: docker is required to run the e2e suite" >&2 + exit 1 + fi + + echo "Building extension for e2e..." + yarn run build 2>&1 + + echo "Running e2e suite in the pinned Playwright container..." + # --ipc=host: Chromium's shared-memory needs more than the default + # 64MB /dev/shm or renderers crash. + # --user: keep files the suite touches owned by the caller, not root. + # HOME=/tmp: the mapped uid has no home directory in the image. + docker run --rm \ + --ipc=host \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp \ + -v "$ROOT:/work" \ + -w /work \ + "$IMAGE" \ + node tests/e2e/run.js +} + +main "$@" diff --git a/src/popup/views/addToken.js b/src/popup/views/addToken.js index 88c7b36..63b75ed 100644 --- a/src/popup/views/addToken.js +++ b/src/popup/views/addToken.js @@ -1,4 +1,4 @@ -const { $, showFlash, goBack } = require("./helpers"); +const { $, showView, showFlash, goBack } = require("./helpers"); const { getTopTokens } = require("../../shared/tokenList"); const { state, saveState } = require("../../shared/state"); const { lookupTokenInfo } = require("../../shared/balances"); diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 2cb4437..5922ddb 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -7,6 +7,7 @@ const { showFlash, flashCopyFeedback, addressTitle, + addressDotHtml, escapeHtml, isoDate, timeAgo, diff --git a/tests/e2e/harness.js b/tests/e2e/harness.js new file mode 100644 index 0000000..14491cc --- /dev/null +++ b/tests/e2e/harness.js @@ -0,0 +1,188 @@ +// 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, +}; diff --git a/tests/e2e/network.js b/tests/e2e/network.js new file mode 100644 index 0000000..8387c19 --- /dev/null +++ b/tests/e2e/network.js @@ -0,0 +1,223 @@ +// Browser-level network interception for the end-to-end suite. +// +// Every http(s) request the extension makes is fulfilled from these +// fixtures, so the suite is deterministic and runs entirely offline. The +// probe that motivated this harness (see issue #181) observed live calls +// to Blockscout returning 401 inside the container, which would make any +// assertion about rendered transaction data worthless. +// +// Anything not explicitly stubbed here is aborted AND reported to the +// error collector, so a newly added outbound call shows up as a test +// failure rather than as intermittent flakiness. + +"use strict"; + +// Fictional ERC-20 used to seed the transaction-detail test. The symbol +// must not collide with any entry in src/shared/tokenList.js, or +// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a +// symbol-spoofing attempt; holders_count must be >= 1000 or the default +// hideLowHolderTokens filter drops it. Either would make the test pass +// vacuously by never rendering a row at all. +const STUB_TOKEN = { + address: "0xe2e0000000000000000000000000000000000e2e", + symbol: "E2E", + name: "End To End Test Token", + decimals: "6", + holders: "12345", +}; + +const STUB_COUNTERPARTY = "0xc0ffee0000000000000000000000000000c0ffee"; + +const STUB_TX_HASH = + "0xe2e0000000000000000000000000000000000000000000000000000000000e2e"; + +const STUB_BLOCK_NUMBER = 21000000; + +// Fixed instant so timeAgo() output is stable across runs. +const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z"; + +// A 32-byte zero word. Returned for every eth_call, which is what makes +// ethers' ENS reverse lookup resolve to "no resolver set" and return null +// instead of throwing. A throw would be logged by src/shared/ens.js via +// log.errorf(), i.e. console.error, which fails the run on its own. +const ZERO_WORD = "0x" + "0".repeat(64); + +const RPC_RESULTS = { + eth_chainId: "0x1", + net_version: "1", + eth_blockNumber: "0x1406f40", + eth_getBalance: "0x0", + eth_call: ZERO_WORD, + eth_gasPrice: "0x3b9aca00", + eth_estimateGas: "0x5208", + eth_getTransactionCount: "0x0", + eth_maxPriorityFeePerGas: "0x3b9aca00", +}; + +function tokenObject() { + return { + address_hash: STUB_TOKEN.address, + address: STUB_TOKEN.address, + symbol: STUB_TOKEN.symbol, + name: STUB_TOKEN.name, + decimals: STUB_TOKEN.decimals, + holders_count: STUB_TOKEN.holders, + type: "ERC-20", + }; +} + +// One received ERC-20 transfer of 1.5 E2E to the address under test. +function tokenTransferItems(address) { + return [ + { + transaction_hash: STUB_TX_HASH, + block_number: STUB_BLOCK_NUMBER, + timestamp: STUB_TX_TIMESTAMP, + from: { hash: STUB_COUNTERPARTY }, + to: { hash: address }, + total: { decimals: STUB_TOKEN.decimals, value: "1500000" }, + token: tokenObject(), + }, + ]; +} + +// Full details for STUB_TX_HASH. raw_input is "0x" so the calldata +// decoder short-circuits; the on-chain detail fields still populate. +function transactionDetails() { + return { + hash: STUB_TX_HASH, + block_number: STUB_BLOCK_NUMBER, + nonce: 7, + gas_used: "51000", + gas_price: "1000000000", + fee: { value: "51000000000000" }, + raw_input: "0x", + status: "ok", + }; +} + +function jsonResponse(route, body) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +// Extract the address from a Blockscout /addresses//... path. +function blockscoutAddress(pathname) { + const m = pathname.match(/\/addresses\/(0x[0-9a-fA-F]{40})\//); + return m ? m[1] : null; +} + +function handleRpc(route, postData, report) { + let payload; + try { + payload = JSON.parse(postData || "null"); + } catch { + report("unstubbed RPC: unparseable body " + String(postData)); + return route.abort(); + } + // ethers batches by default, so the body may be an array. + const batch = Array.isArray(payload) ? payload : [payload]; + const replies = batch.map((req) => { + const result = RPC_RESULTS[req.method]; + if (result === undefined) { + report("unstubbed RPC method: " + req.method); + return { + jsonrpc: "2.0", + id: req.id, + error: { code: -32601, message: "unstubbed in e2e harness" }, + }; + } + return { jsonrpc: "2.0", id: req.id, result }; + }); + return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]); +} + +/** + * Route every http(s) request through local fixtures. + * + * @param {import("playwright-core").BrowserContext} ctx + * @param {object} opts + * @param {(text: string) => void} opts.report called for unstubbed traffic + * @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20 + * transfer. Read at request time, so a test can flip it on the same + * options object without re-registering the route. + */ +async function installNetworkStubs(ctx, opts) { + const report = opts.report; + + // Regex rather than a glob so chrome-extension:// resource loads are + // never touched — routing those would break the popup itself. + await ctx.route(/^https?:\/\//, async (route) => { + const req = route.request(); + const url = new URL(req.url()); + const p = url.pathname; + + // JSON-RPC endpoint (any host): a POST with a JSON-RPC body. + if (req.method() === "POST") { + return handleRpc(route, req.postData(), report); + } + + // Blockscout v2 + if (p.includes("/api/v2/")) { + if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) { + return jsonResponse(route, { items: [] }); + } + if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) { + const addr = blockscoutAddress(p); + return jsonResponse(route, { + items: + opts.seedTokenTransfer && addr + ? tokenTransferItems(addr) + : [], + }); + } + if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) { + return jsonResponse(route, []); + } + if (p.endsWith("/transactions/" + STUB_TX_HASH)) { + return jsonResponse(route, transactionDetails()); + } + } + + // CoinDesk price tick + if (url.hostname.endsWith("coindesk.com")) { + return jsonResponse(route, { Data: {} }); + } + + // MetaMask phishing blocklist + if ( + url.hostname === "raw.githubusercontent.com" || + p.endsWith("/eth-phishing-detect/main/src/config.json") + ) { + return jsonResponse(route, { + version: 2, + tolerance: 2, + fuzzylist: [], + whitelist: [], + blacklist: [], + }); + } + + // Best-effort Etherscan address labels: served as an empty page. + if (url.hostname.endsWith("etherscan.io")) { + return route.fulfill({ + status: 200, + contentType: "text/html", + body: "", + }); + } + + report("unstubbed request: " + req.method() + " " + req.url()); + return route.abort(); + }); +} + +module.exports = { + installNetworkStubs, + STUB_TOKEN, + STUB_COUNTERPARTY, + STUB_TX_HASH, +}; diff --git a/tests/e2e/run.js b/tests/e2e/run.js new file mode 100644 index 0000000..8cf6e56 --- /dev/null +++ b/tests/e2e/run.js @@ -0,0 +1,188 @@ +// End-to-end suite entrypoint. Run via script/test-e2e (which builds +// dist/chrome/ and starts the pinned container); running it directly +// requires a Chromium that playwright-core can find. +// +// A plain runner rather than jest on purpose: jest's default testMatch +// would pull these files into script/test, and browser tests do not fit +// inside the 20-second cap REPO_POLICIES.md puts on make test. Nothing +// here is named *.test.js for the same reason. + +"use strict"; + +const { + createWallet, + launch, + openAddressDetail, + openPopup, + visible, +} = require("./harness"); +const { STUB_TOKEN, STUB_TX_HASH } = require("./network"); + +const TEST_TIMEOUT_MS = 120000; + +const tests = []; + +function test(name, fn) { + tests.push({ name, fn }); +} + +function assert(cond, message) { + if (!cond) throw new Error(message); +} + +function withTimeout(promise, name) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => + reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")), + TEST_TIMEOUT_MS, + ); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +// ----------------------------------------------------------------- tests + +test("popup loads and reaches the welcome view", async (env) => { + env.page = await openPopup(env.ctx, env.popupUrl); + await visible(env.page, "#view-welcome"); + const title = await env.page.title(); + assert(title === "AutistMask", "unexpected popup title: " + title); +}); + +test("wallet creation through the UI reaches the main view", async (env) => { + await createWallet(env.page); + const addrCount = await env.page + .locator("#wallet-list .btn-addr-info") + .count(); + assert(addrCount > 0, "no addresses rendered in the wallet list"); +}); + +test("add token screen opens from address detail (#150)", async (env) => { + await openAddressDetail(env.page); + await env.page.click("#btn-add-token"); + await visible(env.page, "#view-add-token"); + const quickPicks = await env.page + .locator("#common-token-list .common-token") + .count(); + assert(quickPicks > 0, "no common-token quick-pick buttons rendered"); +}); + +test("transaction detail renders an ERC-20 transfer (#151)", async (env) => { + // Serve the stubbed token transfer from here on, then reload so the + // address detail screen refetches its transaction list. + env.routeOpts.seedTokenTransfer = true; + await env.page.reload(); + await openAddressDetail(env.page); + + await visible(env.page, "#tx-list .tx-row"); + const rowText = await env.page + .locator("#tx-list .tx-row") + .first() + .innerText(); + assert( + rowText.includes(STUB_TOKEN.symbol), + "token transfer row missing symbol " + + STUB_TOKEN.symbol + + ", got: " + + JSON.stringify(rowText), + ); + + await env.page.locator("#tx-list .tx-row").first().click(); + await visible(env.page, "#view-transaction"); + + const hash = await env.page.locator("#tx-detail-hash").innerText(); + assert( + hash.includes(STUB_TX_HASH), + "transaction detail shows the wrong hash: " + hash, + ); + + // The token contract row is the field that crashes when + // addressDotHtml is not imported: it renders only for transfers with + // a contractAddress, which is every ERC-20 transfer. + await visible(env.page, "#tx-detail-token-contract-section"); + const contract = env.page.locator("#tx-detail-token-contract"); + const contractText = await contract.innerText(); + assert( + contractText.toLowerCase().includes(STUB_TOKEN.address), + "token contract row missing the contract address, got: " + + JSON.stringify(contractText), + ); + const dots = await contract.locator('span[style*="border-radius"]').count(); + assert(dots > 0, "token contract row rendered without its colour dot"); +}); + +// ---------------------------------------------------------------- runner + +async function main() { + const routeOpts = { seedTokenTransfer: false }; + + let session; + try { + session = await launch(routeOpts); + } catch (e) { + // Never skip and report success: a browser we cannot start is a + // failure of the suite, not an absent one. + console.error("e2e: could not start the browser: " + e.message); + process.exitCode = 1; + return; + } + + console.log("# extension id: " + session.extensionId); + console.log("1.." + tests.length); + + const env = { + ctx: session.ctx, + popupUrl: session.popupUrl, + routeOpts, + page: null, + }; + + let failed = 0; + let n = 0; + for (const t of tests) { + n += 1; + const mark = session.errors.mark(); + let failure = null; + try { + await withTimeout(t.fn(env), t.name); + } catch (e) { + failure = e.message; + } + + // Any uncaught page error or console.error fails the test that + // provoked it, whether or not its assertions passed. This is the + // mechanism that caught #150. + const newErrors = session.errors.since(mark); + if (!failure && newErrors.length > 0) { + failure = "uncaught browser errors during this test"; + } + + if (failure) { + failed += 1; + console.log("not ok " + n + " - " + t.name); + console.log(" " + failure); + for (const line of newErrors) { + console.log(" " + line); + } + } else { + console.log("ok " + n + " - " + t.name); + } + } + + await session.close(); + + console.log( + "# " + (tests.length - failed) + "/" + tests.length + " passed", + ); + if (failed > 0) { + console.log("# FAILED"); + process.exitCode = 1; + } +} + +main().catch((e) => { + console.error("e2e: " + (e && e.stack ? e.stack : e)); + process.exitCode = 1; +}); diff --git a/yarn.lock b/yarn.lock index ca84232..3602e67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2547,6 +2547,11 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +playwright-core@1.56.0: + version "1.56.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.56.0.tgz#14b40ea436551b0bcefe19c5bfb8d1804c83739c" + integrity sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ== + pngjs@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz" -- 2.49.1 From a3075f2f471c2cf868fb32e66aad494030eda871 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 9 Aug 2026 15:11:19 +0000 Subject: [PATCH 2/4] test: intercept the MV3 service worker's network in the e2e harness ctx.route() does not see requests made by the background service worker unless Playwright is run with PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, so the phishing blocklist fetch that src/background/index.js issues at worker startup was reaching raw.githubusercontent.com on the real internet on every run. phishingDomains.js swallows fetch failures, so nothing surfaced it, and the raw.githubusercontent.com stub in tests/e2e/network.js was unreachable code that made the gap look covered. script/test-e2e now sets the flag, with a comment recording what to do if a future Playwright drops it. The flag being experimental is not taken on trust: launch() waits for the worker's own startup request to arrive in the route handler and refuses to run the suite if it never does, so escaping traffic fails the run instead of passing unnoticed. Chrome is additionally started with --host-resolver-rules=MAP * ~NOTFOUND, so anything that does slip past interception cannot reach a real host. Also: errors and unstubbed requests recorded during launch are attributed to the first test rather than discarded, a suite that registers no tests now fails instead of exiting 0, a failure after the browser is up tears the context down instead of hanging the process, E2E_TRACE_NETWORK=1 prints every routed request tagged [sw] or [page], and the dead exports in harness.js and network.js are gone. --- README.md | 10 ++++ TODO.md | 13 +++-- script/test-e2e | 16 ++++++ tests/e2e/harness.js | 124 ++++++++++++++++++++++++++++++++----------- tests/e2e/network.js | 71 ++++++++++++++++++++++++- tests/e2e/run.js | 26 +++++++-- 6 files changed, 219 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 3092e1e..dcf5ca6 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,16 @@ intercepted at the browser level and served from fixtures in unrecognised outbound requests are reported as failures rather than silently allowed. +That interception covers the MV3 background service worker as well as the popup +page, which it does not by default — `script/test-e2e` sets +`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is +experimental, the harness does not take it on trust: at launch it fetches a +`.invalid` URL from inside the service worker that only the route handler can +answer, and aborts the entire suite if the answer does not come back. Escaping +traffic fails the run instead of disappearing. To see what is actually being +intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is printed, +tagged `[sw]` or `[page]`. + **Any uncaught page error or `console.error` fails the run.** That is the point: a `ReferenceError` from a used-but-not-imported identifier is invisible to `make check` (`script/lint` is only `prettier --check`) but fatal in a browser, diff --git a/TODO.md b/TODO.md index 8a40ed3..0f0f652 100644 --- a/TODO.md +++ b/TODO.md @@ -32,7 +32,9 @@ review. `script/test-e2e`) driving the real popup with all network intercepted, plus the two used-but-not-imported crashes it caught: AddToken unreachable (#150) and TransactionDetail broken for every ERC-20 transfer (#151). Harness - demonstrated failing before the fixes and passing after (#181). + demonstrated failing before the fixes and passing after (#181). Interception + covers the MV3 background service worker, not just the popup page, and a + launch-time canary aborts the suite if worker traffic starts escaping. - 2026-08-09: Reviewed the repo end to end and filed the 1.0.0 backlog (#149-#168). - 2026-07-26: About well in settings with build info, repo link and the version @@ -63,9 +65,12 @@ review. - Decide the libsodium backend that actually ships (#182) and delete the single allowlist entry it owns in `tests/e2e/harness.js`. - Extend the end-to-end suite to the dApp approval signing path (EIP-1193 - through the real content script, background worker and approval popup), and - decide separately whether docker-in-docker makes `make test-e2e` runnable in - the Gitea workflow. + through the real content script, background worker and approval popup). That + path needs a CDP-based route to background-worker console output first: + Playwright exposes no error event for service workers, so an uncaught + exception in the worker cannot fail the run today (worker network traffic is + already covered). Decide separately whether docker-in-docker makes + `make test-e2e` runnable in the Gitea workflow. - Make the Firefox target functional: Chrome callback APIs are used against the promise-only `browser` namespace (#153). - Send and transaction-flow correctness: gas fee excluded from the diff --git a/script/test-e2e b/script/test-e2e index 2a604d0..ece9a5d 100755 --- a/script/test-e2e +++ b/script/test-e2e @@ -35,10 +35,26 @@ main() { # 64MB /dev/shm or renderers crash. # --user: keep files the suite touches owned by the caller, not root. # HOME=/tmp: the mapped uid has no home directory in the image. + # PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it, + # ctx.route() intercepts page requests only, and every fetch made by + # the MV3 background service worker — including the phishing + # blocklist fetch that src/background/index.js issues at worker + # startup — goes to the real internet. The flag is experimental and + # Playwright may drop or rename it. It cannot break silently: the + # harness probes service-worker interception at launch and aborts + # the whole suite if it is not in effect (see the interception + # canary in tests/e2e/harness.js). If a future Playwright removes + # the flag, that probe is what will fail, and the fix is either a + # replacement mechanism or an honest downgrade of the isolation + # claim in tests/e2e/network.js and README.md — not deleting the + # probe. The image is pinned by digest, so this can only ever bite + # on a deliberate bump. docker run --rm \ --ipc=host \ --user "$(id -u):$(id -g)" \ -e HOME=/tmp \ + -e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \ + -e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \ -v "$ROOT:/work" \ -w /work \ "$IMAGE" \ diff --git a/tests/e2e/harness.js b/tests/e2e/harness.js index 14491cc..24e3da6 100644 --- a/tests/e2e/harness.js +++ b/tests/e2e/harness.js @@ -74,19 +74,61 @@ function attachErrorListeners(ctx, errors) { 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. + // 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. } -// 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 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) { @@ -112,27 +154,50 @@ async function launch(routeOpts) { // 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 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 }); - }, + 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 @@ -177,9 +242,6 @@ async function openAddressDetail(page) { } module.exports = { - ALLOWED_ERRORS, - EXT_PATH, - REPO_ROOT, createWallet, launch, openAddressDetail, diff --git a/tests/e2e/network.js b/tests/e2e/network.js index 8387c19..f5abf0e 100644 --- a/tests/e2e/network.js +++ b/tests/e2e/network.js @@ -1,11 +1,22 @@ // Browser-level network interception for the end-to-end suite. // -// Every http(s) request the extension makes is fulfilled from these +// Every http(s) request the extension makes — from the popup page AND +// from the MV3 background service worker — is fulfilled from these // fixtures, so the suite is deterministic and runs entirely offline. The // probe that motivated this harness (see issue #181) observed live calls // to Blockscout returning 401 inside the container, which would make any // assertion about rendered transaction data worthless. // +// Service-worker coverage is not free: ctx.route() only sees worker +// traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in +// the environment, which script/test-e2e does. Without it the phishing +// blocklist fetch that src/background/index.js issues at worker startup +// silently reaches raw.githubusercontent.com on the open internet, and +// src/shared/phishingDomains.js swallows the failure so nothing surfaces +// it. That is not left to trust: waitForServiceWorkerTraffic() below +// backs the launch-time canary in harness.js, which fails the entire +// suite if worker requests stop being visible here. +// // Anything not explicitly stubbed here is aborted AND reported to the // error collector, so a newly added outbound call shows up as a test // failure rather than as intermittent flakiness. @@ -144,16 +155,45 @@ function handleRpc(route, postData, report) { * @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20 * transfer. Read at request time, so a test can flip it on the same * options object without re-registering the route. + * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => + * Promise}>} */ async function installNetworkStubs(ctx, opts) { const report = opts.report; + // First request seen that originated in a service worker, and the + // resolver waiting for it. This is what proves worker interception is + // actually in force; see waitForServiceWorkerTraffic below. + let firstWorkerRequest = null; + let announceWorkerRequest = null; + + // E2E_TRACE_NETWORK=1 prints every request that reaches this handler, + // tagged [sw] when it originated in the background service worker. + // It exists so the isolation claim above can be re-checked by anyone + // in one command, without editing files: the phishing blocklist fetch + // showing up with an [sw] tag is the proof that the worker really is + // intercepted and that the raw.githubusercontent.com stub below is + // live code rather than decoration. + const trace = process.env.E2E_TRACE_NETWORK === "1"; + // Regex rather than a glob so chrome-extension:// resource loads are // never touched — routing those would break the popup itself. await ctx.route(/^https?:\/\//, async (route) => { const req = route.request(); const url = new URL(req.url()); const p = url.pathname; + const fromWorker = !!req.serviceWorker(); + + if (fromWorker && !firstWorkerRequest) { + firstWorkerRequest = req.method() + " " + req.url(); + if (announceWorkerRequest) + announceWorkerRequest(firstWorkerRequest); + } + + if (trace) { + const origin = fromWorker ? "[sw] " : "[page] "; + console.log("# routed " + origin + req.method() + " " + req.url()); + } // JSON-RPC endpoint (any host): a POST with a JSON-RPC body. if (req.method() === "POST") { @@ -213,11 +253,38 @@ async function installNetworkStubs(ctx, opts) { report("unstubbed request: " + req.method() + " " + req.url()); return route.abort(); }); + + return { + /** + * Resolve with the first service-worker-originated request this + * handler saw, or null if none arrives within `ms`. + * + * The background worker fetches the phishing blocklist at + * startup, unconditionally, within about a second of the context + * coming up — so under working interception this resolves almost + * immediately. Nothing arriving means worker traffic is bypassing + * the handler entirely and going to the real internet, which the + * caller turns into a hard failure of the whole suite. + */ + waitForServiceWorkerTraffic(ms) { + if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest); + return new Promise((resolve) => { + const timer = setTimeout(() => { + announceWorkerRequest = null; + resolve(null); + }, ms); + announceWorkerRequest = (req) => { + clearTimeout(timer); + announceWorkerRequest = null; + resolve(req); + }; + }); + }, + }; } module.exports = { installNetworkStubs, STUB_TOKEN, - STUB_COUNTERPARTY, STUB_TX_HASH, }; diff --git a/tests/e2e/run.js b/tests/e2e/run.js index 8cf6e56..8fc6a49 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -116,15 +116,28 @@ test("transaction detail renders an ERC-20 transfer (#151)", async (env) => { // ---------------------------------------------------------------- runner async function main() { + // A suite that runs nothing must never report success. If a refactor + // drops the registrations above, or a require() of this file stops + // reaching them, the only honest outcome is a red run — reporting + // "0/0 passed" and exiting 0 is the same vacuous-check failure this + // whole harness exists to prevent. + if (tests.length === 0) { + console.log("1..0"); + console.log("# FAILED: the e2e suite registered no tests"); + process.exitCode = 1; + return; + } + const routeOpts = { seedTokenTransfer: false }; let session; try { session = await launch(routeOpts); } catch (e) { - // Never skip and report success: a browser we cannot start is a - // failure of the suite, not an absent one. - console.error("e2e: could not start the browser: " + e.message); + // Never skip and report success: a browser we cannot start, or + // one whose network interception is not in force, is a failure of + // the suite, not an absent one. + console.error("e2e: cannot run the suite: " + e.message); process.exitCode = 1; return; } @@ -141,9 +154,13 @@ async function main() { let failed = 0; let n = 0; + // Starts at zero rather than at the current mark on purpose: errors + // and escaping requests recorded during launch — before any test ran, + // which is when the background worker does its startup fetches — are + // attributed to the first test instead of being discarded. + let mark = 0; for (const t of tests) { n += 1; - const mark = session.errors.mark(); let failure = null; try { await withTimeout(t.fn(env), t.name); @@ -155,6 +172,7 @@ async function main() { // provoked it, whether or not its assertions passed. This is the // mechanism that caught #150. const newErrors = session.errors.since(mark); + mark = session.errors.mark(); if (!failure && newErrors.length > 0) { failure = "uncaught browser errors during this test"; } -- 2.49.1 From a13862d991ee65b7654b8a4fc316e3ba70a455e9 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 9 Aug 2026 15:50:45 +0000 Subject: [PATCH 3/4] test: make e2e error attribution total, and fix the README canary text The error collector had a window API (mark/since) and twice a record fell outside somebody's window and was silently dropped, producing a green run that proved nothing: first the mark started after test 1, discarding everything recorded during launch; then the tail after the final test was never read at all, so a request escaping the fixtures at the end of the last test reported 5/5 passed and exit 0. Rather than patch a second boundary and invite a third, the window concept is gone. ErrorCollector exposes only take(), which always drains everything outstanding, so successive takes partition the whole record stream with no gaps, and seal(), which closes the stream at the end of the run and routes stragglers straight to a failure. Attribution is total by construction: launch through test 1 goes to test 1, each subsequent interval to the test that ends it, the tail to the suite. The tail also needs to exist before it can be drained. A request a test fires without awaiting reaches the route handler about 10ms after that test's function resolves, and closing the context does not wait for it, so with no window at all it died unobserved. The run now keeps collecting for a bounded 1.5s after the last test before teardown. Also: - README described a canary that was built, found to kill the service worker, and deleted. Replaced with what actually runs: the harness waits for the background worker's own startup blocklist fetch to reach the route handler and aborts if it does not. Documents --host-resolver-rules=MAP * ~NOTFOUND as defence in depth. - The canary's failure message asserted traffic was escaping to the real internet and blamed the -e flag. It cannot distinguish that from a lost startup race, so it now states what was observed and lists both causes. - E2E_TRACE_NETWORK was compared strictly to "1", so E2E_TRACE_NETWORK=true silently did nothing. Recognised on/off values are accepted and anything else is a hard error rather than a quiet default. - The measured margin that makes the canary sound is route install at 11-23ms against the worker fetch at 525-883ms, not the 30s timeout slack the comment cited. --- README.md | 23 +++++++++--- tests/e2e/harness.js | 84 +++++++++++++++++++++++++++++++++--------- tests/e2e/network.js | 29 ++++++++++++++- tests/e2e/run.js | 87 ++++++++++++++++++++++++++++++++++++++------ 4 files changed, 187 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index dcf5ca6..4d75b97 100644 --- a/README.md +++ b/README.md @@ -104,12 +104,23 @@ allowed. That interception covers the MV3 background service worker as well as the popup page, which it does not by default — `script/test-e2e` sets `PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is -experimental, the harness does not take it on trust: at launch it fetches a -`.invalid` URL from inside the service worker that only the route handler can -answer, and aborts the entire suite if the answer does not come back. Escaping -traffic fails the run instead of disappearing. To see what is actually being -intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is printed, -tagged `[sw]` or `[page]`. +experimental, the harness does not take it on trust. At launch it waits for the +background worker's **own** startup request — the phishing blocklist fetch that +`src/background/index.js` issues unconditionally — to arrive in the route +handler, and aborts the entire suite if none does within 30 seconds +(`tests/e2e/harness.js`). The check is passive on purpose: a synthetic probe +fetched from inside the worker via `worker.evaluate()` was tried first and +rejected, because evaluating in an extension service worker that early kills the +worker outright, destroying the thing being measured. Observing traffic the +extension already generates perturbs nothing. Losing the race fails closed — the +suite refuses to run rather than passing quietly. + +As defence in depth, Chrome is also started with +`--host-resolver-rules=MAP * ~NOTFOUND`, so a request that ever did slip past +the route handler could not resolve a host at all. That only bounds the damage; +detecting escaping traffic remains the canary's job. To see what is actually +being intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is +printed, tagged `[sw]` or `[page]`. **Any uncaught page error or `console.error` fails the run.** That is the point: a `ReferenceError` from a used-but-not-imported identifier is invisible to diff --git a/tests/e2e/harness.js b/tests/e2e/harness.js index 24e3da6..0a55a97 100644 --- a/tests/e2e/harness.js +++ b/tests/e2e/harness.js @@ -39,23 +39,55 @@ 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. seal() closes the +// stream once the run is over and routes anything later straight to a +// callback rather than into a list nobody reads again. Attribution is +// therefore total by construction, and the runner turns every attributed +// record into a failure. class ErrorCollector { constructor() { this.entries = []; + this.taken = 0; + this.onLate = null; } record(kind, text) { const line = kind + ": " + String(text).split("\n")[0]; if (isAllowed(line)) return; + if (this.onLate) { + // Sealed: no test and no suite phase is left to attribute + // this to, so hand it over now instead of accumulating it + // where nothing will look. + this.onLate(line); + return; + } this.entries.push(line); } - mark() { - return this.entries.length; + // 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; } - since(mark) { - return this.entries.slice(mark); + // Close the stream: later records go to onLate instead of the list. + seal(onLate) { + this.onLate = onLate; } } @@ -89,8 +121,16 @@ async function serviceWorker(ctx) { } // 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. +// +// 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 @@ -115,19 +155,29 @@ async function assertWorkerTrafficIntercepted(stubs) { ); 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( - "no service-worker request reached the route handler within " + + "observed no service-worker request in 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", + "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", ); } diff --git a/tests/e2e/network.js b/tests/e2e/network.js index f5abf0e..413a45b 100644 --- a/tests/e2e/network.js +++ b/tests/e2e/network.js @@ -146,6 +146,33 @@ function handleRpc(route, postData, report) { return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]); } +const TRACE_TRUE = ["1", "true", "yes", "on"]; +const TRACE_FALSE = ["", "0", "false", "no", "off"]; + +// Whether E2E_TRACE_NETWORK asks for the request trace. +// +// A set-but-unrecognised value is a hard error rather than a quiet +// "off": E2E_TRACE_NETWORK=true asking for a trace and getting silence +// is the operator being lied to about what the harness is doing, which +// is the whole failure mode this suite exists to eliminate. Refusing to +// guess costs one line and one obvious error message. +function traceEnabled(raw) { + if (raw === undefined || raw === null) return false; + const v = String(raw).trim().toLowerCase(); + if (TRACE_TRUE.includes(v)) return true; + if (TRACE_FALSE.includes(v)) return false; + throw new Error( + "E2E_TRACE_NETWORK is set to " + + JSON.stringify(String(raw)) + + ", which is not a recognised on/off value. Use one of " + + TRACE_TRUE.join(", ") + + " to enable the request trace, or one of " + + TRACE_FALSE.slice(1).join(", ") + + " to disable it. Refusing to guess: a diagnostic that silently " + + "does nothing is worse than one that is not there", + ); +} + /** * Route every http(s) request through local fixtures. * @@ -174,7 +201,7 @@ async function installNetworkStubs(ctx, opts) { // showing up with an [sw] tag is the proof that the worker really is // intercepted and that the raw.githubusercontent.com stub below is // live code rather than decoration. - const trace = process.env.E2E_TRACE_NETWORK === "1"; + const trace = traceEnabled(process.env.E2E_TRACE_NETWORK); // Regex rather than a glob so chrome-extension:// resource loads are // never touched — routing those would break the popup itself. diff --git a/tests/e2e/run.js b/tests/e2e/run.js index 8fc6a49..f20f0a9 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -20,6 +20,10 @@ const { STUB_TOKEN, STUB_TX_HASH } = require("./network"); const TEST_TIMEOUT_MS = 120000; +// How long to keep collecting after the final test returns; see the +// trailing drain in main(). +const TRAILING_WATCH_MS = 1500; + const tests = []; function test(name, fn) { @@ -152,13 +156,27 @@ async function main() { page: null, }; + // Attribution of collected errors is total. session.errors has no + // window API at all: take() always drains everything outstanding, so + // successive takes partition the whole stream, and the phases below + // cover the entire life of the run. Nothing the collector holds can + // go unread. + // + // launch .. end of test 1 -> test 1 (so the worker's startup + // fetches land on a test, not + // nowhere) + // end of test k .. end of k+1 -> test k+1 + // last test .. teardown -> the suite, via the trailing drain + // after the trailing drain -> seal(), which fails on the spot + // + // Two green-but-vacuous runs on this harness were the same shape: a + // record falling outside somebody's window and being dropped. First + // the mark started after test 1, discarding launch-time records; + // then the tail after the last test was never read. Patching a + // second boundary would have invited a third, so the window concept + // is gone rather than fixed. let failed = 0; let n = 0; - // Starts at zero rather than at the current mark on purpose: errors - // and escaping requests recorded during launch — before any test ran, - // which is when the background worker does its startup fetches — are - // attributed to the first test instead of being discarded. - let mark = 0; for (const t of tests) { n += 1; let failure = null; @@ -168,11 +186,10 @@ async function main() { failure = e.message; } - // Any uncaught page error or console.error fails the test that - // provoked it, whether or not its assertions passed. This is the - // mechanism that caught #150. - const newErrors = session.errors.since(mark); - mark = session.errors.mark(); + // Any uncaught page error, console.error or unstubbed request + // fails the test that provoked it, whether or not its assertions + // passed. This is the mechanism that caught #150. + const newErrors = session.errors.take(); if (!failure && newErrors.length > 0) { failure = "uncaught browser errors during this test"; } @@ -189,12 +206,58 @@ async function main() { } } + // Keep watching after the last test returns, before tearing the + // browser down. A request a test fires without awaiting is still in + // flight when its function resolves; measured here it reaches the + // route handler about 10ms later, but closing the context does not + // wait for it — with no window at all the request dies unobserved + // and the run goes green, which is exactly how escaping traffic + // stays invisible. + // + // A fixed bounded window rather than a quiescence poll on purpose: + // the collector being quiet is not evidence, because a request that + // has not been dispatched yet has recorded nothing to be quiet + // about. Playwright offers no "is anything in flight" question to + // ask either — the route handler is the only observation point — so + // a grace period is the mechanism available, and this one is ~150x + // the measured latency for 1.5s on a ~25s suite. + await new Promise((resolve) => setTimeout(resolve, TRAILING_WATCH_MS)); + await session.close(); + // The tail. These cannot be blamed on any single test, so they are + // reported against the suite rather than guessed at — but they are + // reported, and they fail the run. + const trailing = session.errors.take(); + + // From here the run is over and there is nothing left to attribute a + // record to, so stragglers fail immediately instead of piling up + // where nothing will read them. + let late = 0; + session.errors.seal((line) => { + late += 1; + console.log("# FAILED: browser error recorded after the run ended"); + console.log("# " + line); + process.exitCode = 1; + }); + console.log( - "# " + (tests.length - failed) + "/" + tests.length + " passed", + "# " + (tests.length - failed) + "/" + tests.length + " tests passed", ); - if (failed > 0) { + + if (trailing.length > 0) { + console.log( + "# " + + trailing.length + + " browser error(s) recorded after the last test finished, " + + "not attributable to any single test:", + ); + for (const line of trailing) { + console.log("# " + line); + } + } + + if (failed > 0 || trailing.length > 0 || late > 0) { console.log("# FAILED"); process.exitCode = 1; } -- 2.49.1 From 82c40e009d1b785f715d98dfde4fe7efb15509da Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 9 Aug 2026 16:26:26 +0000 Subject: [PATCH 4/4] test: delete the unreachable seal() hook and guard non-object RPC bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, no redesign. seal() was installed after session.close(), so onLate could never fire: once the context is destroyed no route handler and no console listener exists to record anything. It was dead code that the attribution table, run.js and harness.js all described as the final safety net. Deleted rather than moved — take() already drains everything the collector holds before teardown, so it covered nothing, and this harness must not ship a mechanism it cannot demonstrate. The now-unreachable `late > 0` term in the failure condition goes with it. handleRpc() parsed `postData || "null"` and then dereferenced the result, so a POST whose body Playwright reports as null — a bodyless request, or any payload it cannot decode as UTF-8, e.g. sendBeacon with a Blob — threw a TypeError inside the route handler and killed node mid-suite: truncated TAP, no summary, no failure line. Non-object payloads now take the same path as unparseable ones and are reported as unstubbed traffic, which is the entire point of that branch. README claimed unrecognised outbound requests are reported as failures, unqualified, while observation in fact ends TRAILING_WATCH_MS after the last test returns. The limit is now stated where it lands. --- README.md | 10 ++++++++++ tests/e2e/harness.js | 25 +++++++------------------ tests/e2e/network.js | 17 +++++++++++++++++ tests/e2e/run.js | 21 ++++++++------------- 4 files changed, 42 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 4d75b97..0e8756d 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,16 @@ intercepted at the browser level and served from fixtures in unrecognised outbound requests are reported as failures rather than silently allowed. +That reporting has one bound worth knowing. Observation ends when the browser +context is torn down, and nothing can watch traffic after that, so the run keeps +collecting for a fixed grace period after the last test returns +(`TRAILING_WATCH_MS` in `tests/e2e/run.js`, currently 1500ms) and then closes +the context. A request whose _first_ dispatch falls after that window is never +seen at all and cannot fail the run. In practice a request a test fires without +awaiting reaches the route handler about 10ms later, and anything on a repeating +timer gets observed on an earlier tick during the ~20s suite — but a one-shot +call deliberately deferred past the window will escape. + That interception covers the MV3 background service worker as well as the popup page, which it does not by default — `script/test-e2e` sets `PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is diff --git a/tests/e2e/harness.js b/tests/e2e/harness.js index 0a55a97..f51fe78 100644 --- a/tests/e2e/harness.js +++ b/tests/e2e/harness.js @@ -52,28 +52,22 @@ function isAllowed(text) { // // 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. seal() closes the -// stream once the run is over and routes anything later straight to a -// callback rather than into a list nobody reads again. Attribution is -// therefore total by construction, and the runner turns every attributed -// record into a failure. +// 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. class ErrorCollector { constructor() { this.entries = []; this.taken = 0; - this.onLate = null; } record(kind, text) { const line = kind + ": " + String(text).split("\n")[0]; if (isAllowed(line)) return; - if (this.onLate) { - // Sealed: no test and no suite phase is left to attribute - // this to, so hand it over now instead of accumulating it - // where nothing will look. - this.onLate(line); - return; - } this.entries.push(line); } @@ -84,11 +78,6 @@ class ErrorCollector { this.taken = this.entries.length; return out; } - - // Close the stream: later records go to onLate instead of the list. - seal(onLate) { - this.onLate = onLate; - } } function attachErrorListeners(ctx, errors) { diff --git a/tests/e2e/network.js b/tests/e2e/network.js index 413a45b..034e940 100644 --- a/tests/e2e/network.js +++ b/tests/e2e/network.js @@ -131,6 +131,23 @@ function handleRpc(route, postData, report) { } // ethers batches by default, so the body may be an array. const batch = Array.isArray(payload) ? payload : [payload]; + + // Anything that is not a JSON-RPC object, or a batch of them, is not + // RPC at all and must be reported like any other unrecognised + // outbound traffic rather than dereferenced. request.postData() + // returns null both for a bodyless POST and for a body Playwright + // cannot decode as UTF-8 (sendBeacon with a Blob, or any binary + // payload), so this is not an empty-string special case: it rejects + // every non-object payload, exactly as the catch above rejects every + // unparseable one. + if ( + payload === null || + typeof payload !== "object" || + !batch.every((req) => req !== null && typeof req === "object") + ) { + report("unstubbed request: POST " + route.request().url()); + return route.abort(); + } const replies = batch.map((req) => { const result = RPC_RESULTS[req.method]; if (result === undefined) { diff --git a/tests/e2e/run.js b/tests/e2e/run.js index f20f0a9..a771e3c 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -167,7 +167,13 @@ async function main() { // nowhere) // end of test k .. end of k+1 -> test k+1 // last test .. teardown -> the suite, via the trailing drain - // after the trailing drain -> seal(), which fails on the spot + // + // Those three phases cover the entire life of the browser context. + // There is no fourth: once the context is closed nothing can record, + // because the route handler and the console listeners died with it. + // Traffic that a test defers past the trailing drain is therefore + // never observed at all — a real limit of this design, stated in the + // README, and not one any post-teardown hook could close. // // Two green-but-vacuous runs on this harness were the same shape: a // record falling outside somebody's window and being dropped. First @@ -230,17 +236,6 @@ async function main() { // reported, and they fail the run. const trailing = session.errors.take(); - // From here the run is over and there is nothing left to attribute a - // record to, so stragglers fail immediately instead of piling up - // where nothing will read them. - let late = 0; - session.errors.seal((line) => { - late += 1; - console.log("# FAILED: browser error recorded after the run ended"); - console.log("# " + line); - process.exitCode = 1; - }); - console.log( "# " + (tests.length - failed) + "/" + tests.length + " tests passed", ); @@ -257,7 +252,7 @@ async function main() { } } - if (failed > 0 || trailing.length > 0 || late > 0) { + if (failed > 0 || trailing.length > 0) { console.log("# FAILED"); process.exitCode = 1; } -- 2.49.1