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..0e8756d 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,66 @@ 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. + +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 +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 +`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..0f0f652 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,13 @@ 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). 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 @@ -52,10 +60,17 @@ 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). 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/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..ece9a5d --- /dev/null +++ b/script/test-e2e @@ -0,0 +1,64 @@ +#!/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. + # 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" \ + 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..f51fe78 --- /dev/null +++ b/tests/e2e/harness.js @@ -0,0 +1,289 @@ +// 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)); +} + +// 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. +class ErrorCollector { + constructor() { + this.entries = []; + this.taken = 0; + } + + record(kind, text) { + const line = kind + ": " + String(text).split("\n")[0]; + if (isAllowed(line)) 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 }); +} + +async function openPopup(ctx, popupUrl) { + const page = await ctx.newPage(); + await page.goto(popupUrl); + return page; +} + +// Full wallet creation through the real UI: BIP-39 generation, libsodium +// vault encryption and extension storage persistence, for real. +async function createWallet(page) { + await page.click("#btn-welcome-add"); + await visible(page, "#view-add-wallet"); + await page.click("#btn-generate-phrase"); + await page.waitForFunction(() => { + const el = document.getElementById("wallet-mnemonic"); + return el && el.value.trim().split(/\s+/).length >= 12; + }); + await page.fill("#add-wallet-password", PASSWORD); + await page.fill("#add-wallet-password-confirm", PASSWORD); + await page.click("#btn-add-wallet-confirm"); + await visible(page, "#view-main", 60000); +} + +// Reach the address detail screen from wherever the popup restored to. +// Clicking .address-row does not open it; the [info] button does. +async function openAddressDetail(page) { + const onAddress = await page.isVisible("#view-address"); + if (!onAddress) { + await visible(page, "#view-main"); + await page.click("#wallet-list .btn-addr-info"); + } + await visible(page, "#view-address"); +} + +module.exports = { + createWallet, + launch, + openAddressDetail, + openPopup, + visible, +}; diff --git a/tests/e2e/network.js b/tests/e2e/network.js new file mode 100644 index 0000000..034e940 --- /dev/null +++ b/tests/e2e/network.js @@ -0,0 +1,334 @@ +// Browser-level network interception for the end-to-end suite. +// +// 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. + +"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]; + + // 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) { + 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]); +} + +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. + * + * @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. + * @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 = 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. + 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") { + 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(); + }); + + 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_TX_HASH, +}; diff --git a/tests/e2e/run.js b/tests/e2e/run.js new file mode 100644 index 0000000..a771e3c --- /dev/null +++ b/tests/e2e/run.js @@ -0,0 +1,264 @@ +// 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; + +// 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) { + 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() { + // 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, 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; + } + + console.log("# extension id: " + session.extensionId); + console.log("1.." + tests.length); + + const env = { + ctx: session.ctx, + popupUrl: session.popupUrl, + routeOpts, + 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 + // + // 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 + // 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; + for (const t of tests) { + n += 1; + let failure = null; + try { + await withTimeout(t.fn(env), t.name); + } catch (e) { + failure = e.message; + } + + // 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"; + } + + 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); + } + } + + // 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(); + + console.log( + "# " + (tests.length - failed) + "/" + tests.length + " tests passed", + ); + + 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) { + 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"