From a6885a0d628682c5aff0255bb518e1aeee3a6a83 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 9 Aug 2026 14:24:36 +0000 Subject: [PATCH] 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 | 29 ++-- 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, 710 insertions(+), 12 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 7d29268..eda8a05 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap setup install test lint fmt fmt-check check docker hooks build clean dev +.PHONY: bootstrap setup install test test-e2e lint fmt fmt-check check docker hooks build 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 618e757..e9a5ee7 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,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) @@ -65,6 +67,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 5677a1b..7926744 100644 --- a/TODO.md +++ b/TODO.md @@ -10,21 +10,27 @@ # Status -pre-1.0. Tagged v0.1.0 on 2026-02-27. Active development on branch -feat/issue-144-settings-about (another agent working as of 2026-07-06). Full -policy file set present; make check on main not verified. +pre-1.0. Tagged v0.1.0 on 2026-02-27. Working toward the 1.0.0 milestone. Full +policy file set present. `make check` is green on this branch; a real browser +end-to-end suite (`make test-e2e`) now exists alongside it, because `make check` +cannot see a runtime `ReferenceError` in a popup view. # Next Step -Land feat/issue-144-settings-about: finish the settings About well (build info, -app name and repo link, release date, version click easter egg, git info derived -inside Docker), resolve the untracked scripts/ directory (commit or gitignore), -get review, merge to main. +Verify `main` passes `make check` after this branch merges and fix anything red; +`main` must always be green. # 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-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile shims, README Entrypoints section +- 2026-08-09 Landed the settings About well (build info, app name and repo link, + release date, version click easter egg) as `23aeae4` (#145). - 2026-03-01: About well in settings with build info and easter egg (in flight on feature branch); USD display suppressed on testnets (#142); estimated USD for ETH in approve-tx view (#141). @@ -45,9 +51,12 @@ get review, merge to main. # Future Steps -- Verify main passes make check after the feature branch merges (not verified - 2026-07-06 because an agent was active in the tree); fix anything red. main - must always be green. +- 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. - Prune stale branches: dozens of merged local and remote feature branches remain (fix/_, feature/_, tx-\*); delete merged ones locally and on origin. - Continue the issue backlog toward a feature-complete wallet, then cut further 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"