diff --git a/.dockerignore b/.dockerignore index da592f8..cdbac77 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,7 @@ +# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for +# build-info stamping and the Dockerfile runs `make build`, so excluding it +# would make every built extension report commitHash "unknown". node_modules .DS_Store dist +release diff --git a/.gitea/workflows/e2e.yml b/.gitea/workflows/e2e.yml new file mode 100644 index 0000000..07100bc --- /dev/null +++ b/.gitea/workflows/e2e.yml @@ -0,0 +1,49 @@ +name: e2e +on: [push] + +# The browser end-to-end suites, one job per browser, deliberately kept out +# of the check workflow: REPO_POLICIES.md caps make test at 20 seconds and +# script/cibuild is a plain `docker build .` whose Dockerfile runs +# make check, so folding a browser suite into either would blow that cap +# and slow the local fast path. Before this workflow every browser-level +# guarantee in this repo held only when a human remembered to run it. +# +# One job per browser rather than two steps in one job, so a Chrome failure +# does not hide the Firefox result. +# +# Each job is one script and nothing else. Both scripts need docker and +# nothing else — they deliver the repo to the daemon as a build context and +# build the extension inside the pinned image — which is what makes them +# runnable here at all: the runner executes the job in a container against +# the host's docker socket, so a `-v "$PWD:/work"` source path is resolved +# by the host daemon and mounts an empty directory, and the runner image's +# node is too old to install this repo's dependencies. +# +# These jobs REPORT, they do not gate. Whether a check blocks a merge is +# Gitea branch protection, which this repo does not configure, so a failure +# here is a red mark a reviewer has to account for rather than a hard +# block. Making e2e-chrome a required check is blocked on the measured +# flake in the dApp signing wait -- two of six runs of unmutated code on a +# loaded machine -- tracked as +# https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at +# random teaches people to merge past red. +# +# Nothing here may pass vacuously. There is no continue-on-error and no +# `|| true`. Both scripts exit non-zero when docker is missing, when the +# image build fails, and when the browser fails to start; the Chrome +# harness aborts the suite outright if its network interception is not in +# effect. +jobs: + e2e-chrome: + runs-on: ubuntu-latest + steps: + # actions/checkout v4.2.2, 2026-02-22 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - run: script/test-e2e + + e2e-firefox: + runs-on: ubuntu-latest + steps: + # actions/checkout v4.2.2, 2026-02-22 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - run: script/test-e2e-firefox diff --git a/.gitignore b/.gitignore index fc1b418..0420c87 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ node_modules/ # Build output dist/ +# Release artifacts (make package). Derived from dist/, never committed. +release/ + # Yarn .yarn-integrity package-lock.json diff --git a/.prettierignore b/.prettierignore index 020918d..c8d2800 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ node_modules/ yarn.lock dist/ +release/ .claude/ diff --git a/Dockerfile b/Dockerfile index cdb2550..4260864 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,21 @@ # node:22-slim (22.x LTS), 2026-02-24 -FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 +FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base WORKDIR /app +# Marks "already inside the lint container" for script/lint, which otherwise +# shells out to docker to build the lint stage below. Nothing outside this +# image sets it. +ENV AUTISTMASK_LINT_NATIVE=1 + +# script/test's default 30s bound is the host figure, against a suite that +# runs in about 8s there. In here the same suite starts on a cold jest cache +# and shares the runner with the rest of the build, so 30s is marginal rather +# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s +# still catches a hang in three minutes and cannot be tripped by a suite that +# is merely running on contended hardware. +ENV AUTISTMASK_TEST_TIMEOUT=180 + # script/bootstrap installs all prerequisites (make via apt here; node # is already in the base image, yarn comes via corepack) and runs # yarn install --frozen-lockfile. Dependency manifests are copied first @@ -13,5 +26,17 @@ RUN script/bootstrap COPY . . +# Lint stage — fail fast on static analysis and formatting, before the tests +# and the build. This is also the stage script/lint builds from a host, which +# is how linting stays on the pinned ESLint rather than the host's. +FROM base AS lint +RUN make lint + +# Full check and build. The COPY --from is a no-op file copy whose only job is +# to make BuildKit finish the lint stage before this one starts; without it the +# stages run in parallel and a lint failure would not fail the build early. +FROM base AS check +COPY --from=lint /app/package.json /dev/null + RUN make check RUN make build diff --git a/LICENSE b/LICENSE index f8a0f60..0f8b5be 100644 --- a/LICENSE +++ b/LICENSE @@ -682,7 +682,14 @@ under their own licenses. They are NOT covered by the GPL-3.0 license above. --------------------------------------------------------------------------- File: src/shared/phishingBlocklist.json -Source: https://github.com/AugurProject/eth-phishing-detect (config.json) +Source: the eth-phishing-detect community blocklist (src/config.json). + The file here is derived from it, not a copy of it: only the + blacklist is carried over, and each entry is stored as a truncated + digest rather than a domain name. script/vendor-blocklist records + the exact upstream URL, the commit it is pinned to and the hash of + the bytes that commit serves, and is what regenerates this file. + The URL previously cited here, under a different organisation, + returns 404: that repository is gone. Copyright: Copyright (c) 2018 kumavis License: Don't Be a Dick Public License (DBAD), Version 1.2 --------------------------------------------------------------------------- diff --git a/Makefile b/Makefile index 33ab6d0..881f8e6 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 test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist clean dev # Standard targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -11,11 +11,18 @@ setup: @script/setup install: - @yarn install + @yarn install --frozen-lockfile test: @script/test +# Browser end-to-end suites. Both require docker; neither is part of check. +test-e2e: + @script/test-e2e + +test-e2e-firefox: + @script/test-e2e-firefox + lint: @script/lint @@ -28,25 +35,77 @@ fmt-check: check: @script/check +# Assert that the competitor name appears nowhere but its documented +# exceptions. Part of check, and re-run against dist/ at the end of a build; +# separate target for re-running it alone. +check-censored: + @script/check-censored + +package: + @script/package + docker: @script/docker hooks: @script/install-precommit +# build.js writes a receipt of everything it emitted — every path, its sha256, +# and whether it is a bundle containing constants.js — and script/verify-build +# checks dist/ against that. The receipt is made here, fresh per invocation, +# outside the repo, and deleted again: a standing file inside dist/ would be +# rewritten by whoever rewrote dist/, which is what made the old check +# satisfiable by a hand-written tree. +# +# The expected mode is an explicit argument and AUTISTMASK_DEBUG is scrubbed +# from the verifier's environment. The script no longer reads it at all; env -u +# is here so that stays true of anything it calls. It is deliberately NOT +# scrubbed from the build itself: with AUTISTMASK_DEBUG=1 exported, this target +# compiles a debug bundle and then fails on it, loudly, rather than quietly +# handing back something other than the release build that was asked for. +# +# Every step of this target is wrapped in script/discard-dist-on-failure, so a +# release build that fails removes dist/ instead of leaving a complete, loadable +# debug bundle there for whoever runs the build, sees it fail, and loads +# dist/chrome/ anyway. A step that succeeds removes nothing, and build-debug is +# deliberately not wrapped. build: @echo "Building extension..." - @yarn run build 2>&1 + @set -eu; \ + receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \ + trap 'rm -f "$$receipt"' EXIT INT TERM; \ + script/discard-dist-on-failure \ + env AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \ + script/discard-dist-on-failure \ + env -u AUTISTMASK_DEBUG script/verify-build --expect release \ + --receipt "$$receipt" + @script/discard-dist-on-failure script/check-censored --require-dist # Development-only build: enables the red DEBUG / INSECURE banner and makes # the hardcoded test recovery phrase the output of wallet creation. Never # distribute the artifacts this produces. +# +# No discard-dist-on-failure here, on purpose: a debug build that fails is not +# producing an artifact anyone could mistake for a release one, and its dist/ is +# the evidence of what went wrong. build-debug: @echo "Building extension (DEBUG)..." - @AUTISTMASK_DEBUG=1 yarn run build 2>&1 + @set -eu; \ + receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \ + trap 'rm -f "$$receipt"' EXIT INT TERM; \ + AUTISTMASK_DEBUG=1 AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \ + env -u AUTISTMASK_DEBUG script/verify-build --expect debug \ + --receipt "$$receipt" + @script/check-censored --require-dist + +# Refresh src/shared/phishingBlocklist.json from its hash-pinned upstream. +# Run deliberately, land the diff: the extension does no runtime fetching, so +# the shipped list is as fresh as the last vendoring run that was released. +vendor-blocklist: + @script/vendor-blocklist clean: - @rm -rf dist/ + @rm -rf dist/ release/ dev: @echo "Building in watch mode..." diff --git a/README.md b/README.md index a4023da..b637168 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ don't implement any crypto, and don't send user-specific data anywhere but a extension contacts three user-configurable services: the configured RPC node for blockchain interactions, a public CoinDesk API (no API key) for realtime price information, and a Blockscout block-explorer API for transaction history and -token balances. It also fetches a community-maintained phishing domain blocklist -periodically and performs best-effort Etherscan address label lookups during -transaction confirmation. +token balances. It also performs best-effort Etherscan address label lookups +during transaction confirmation. A community-maintained phishing domain +blocklist is built into the extension at build time and checked locally; nothing +is fetched for it at runtime. In the extension is a hardcoded list of the top ERC20 contract addresses. You can add any ERC20 contract by contract address if you wish, but the hardcoded @@ -31,34 +32,181 @@ list exists to detect symbol spoofing attacks and improve UX. ```bash git clone https://git.eeqj.de/sneak/autistmask.git cd autistmask -make install +make setup make build ``` +`make setup` is the entrypoint for a fresh clone: it installs dependencies from +the lockfile and installs the git pre-commit hook. + Load the extension: - **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click "Load unpacked", and select the `dist/chrome/` directory. - **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load - Temporary Add-on", and select `dist/firefox/manifest.json`. + Temporary Add-on", and select `dist/firefox/manifest.json`. Read + [Installing on Firefox](#installing-on-firefox) before relying on this: a + temporary add-on does not survive closing the browser. + +### Release Artifacts + +`make package` runs `make build` and then writes one self-contained, versioned +archive per browser into `release/`, plus a `SHA256SUMS` for them: + +```bash +make package +``` + +``` +release/autistmask-chrome-.zip +release/autistmask-firefox-.xpi +release/SHA256SUMS +``` + +Nothing is published by this. Tagging, CRX packing and any upload are +outward-facing acts and are the owner's alone. + +The archives are deterministic — entries sorted, timestamps fixed, compression +level fixed — so two builds of one commit produce byte-identical files and the +recorded digest is a property of the input rather than of the clock. + +**Self-containment is checked, not assumed.** `build.js` writes the compiled +Tailwind output to `dist/styles.css` at the `dist/` ROOT, outside both browser +directories, and copies it into each of them as `src/popup/styles.css`; a naive +`zip -r dist/chrome` is therefore correct only by accident. So the packager +resolves every path referenced by the manifest and by every HTML document in the +archive, requires each to be inside the archive, and fails on any reference that +climbs out of the extension root. Files left at the `dist/` root are printed as +deliberately not shipped rather than dropped by a glob. The archive is then read +back off disk and compared member by member against the directory it was built +from: an archive nobody opened is a claim, not an artifact. + +There is one version, and the build enforces it. `package.json`, +`manifest/chrome.json` and `manifest/firefox.json` each declare one and none is +derived from another — the manifests are copied to `dist/` verbatim, which is +what `tests/manifest.test.js` asserts — so `script/lib/version.js` requires all +three to agree and **fails the build when they do not**, naming each file and +what it said. `tests/version.test.js` covers that rule in `make check`. + +### Installing on Chrome + +`manifest/chrome.json` carries a fixed `key`: the public half of an RSA keypair, +base64-encoded DER. It exists for one reason. An unpacked Chrome extension with +no `key` gets an extension id derived from the **absolute path it was loaded +from**, and `chrome.storage.local` — which is where the wallet lives — is +partitioned by that id. Move the checkout, re-clone it, or load a second copy +from anywhere else, and the extension comes up on a fresh, empty storage +partition: the wallet is simply gone, with no error and nothing in the UI to say +so. With the `key` in place the id is derived from the key instead, and follows +the extension wherever it is loaded from. That id is +`gipbhkogfopeahplcjhipkgpcimdpkip`, pinned in `tests/extensionId.test.js` and +observed against a real Chrome in `tests/e2e/storagePartition.js`. + +**Changing `key` changes the extension id, and orphans every wallet stored under +the old one.** It is a migration, not an edit. + +The **private** half is a credential. It is not in this repository, no target +generates one into the working tree, `*.pem` and `*.key` are gitignored, and +`tests/extensionId.test.js` fails if such a file is ever committed. It is not +needed to build, load or test anything here — it signs a CRX, and this repo does +not pack one. A packer would take it from outside the repo, e.g. +`chrome --pack-extension=dist/chrome --pack-extension-key=`. + +### Installing on Firefox + +**The XPI this repo produces is UNSIGNED, and release Firefox and Firefox ESR +will refuse to install it.** Those builds enforce add-on signing with no working +override — `xpinstall.signatures.required` does nothing on them — so a permanent +install needs Firefox Developer Edition, Nightly, or an Unbranded build, with +`xpinstall.signatures.required` set to `false` in `about:config`. + +Signing means submitting to AMO (self-distribution is enough, and does not +require listing), which needs credentials this repository does not have and is +the owner's decision. + +The other route is `about:debugging#/runtime/this-firefox` -> "Load Temporary +Add-on", which works on every Firefox including release. **A temporary add-on is +unloaded when Firefox exits**, so daily use means re-adding it by hand on every +browser start. + +**The wallet survives the restart.** `manifest/firefox.json` declares a fixed +`browser_specific_settings.gecko.id`, and Firefox keys the extension's storage +area on that id rather than on the install, so adding the temporary add-on again +in the same profile finds the vault where it left it. That is asserted, not +assumed: `tests/e2e/firefox/reinstall.js` installs the packaged XPI in a real +Firefox, creates a wallet through the UI, quits the browser, starts it again on +the same profile, adds the add-on again, and decrypts the vault with the +original password back to the original recovery phrase. The `moz-extension://` +origin the popup is served from is _not_ stable across installs and does not +need to be — nothing durable is keyed on it. + +**Removing the add-on does not.** An explicit uninstall — about:addons "Remove" +— destroys the extension's storage, and the vault with it. That is ordinary, +correct browser behaviour and it is observed in the same suite, but for a wallet +it is worth saying out loud: **on Firefox, Remove is irreversible, and the +recovery phrase is the only way back.** ### Debug Builds -`make build` always produces a release build: the build-time `DEBUG` constant is -`false`, so wallet creation uses real entropy and the red banner is off. To -produce a debug build instead, set `AUTISTMASK_DEBUG=1` in the environment: +`make build` never hands back a debug build. `make build-debug` is the only +target that produces one: ```bash -make build-debug # or: AUTISTMASK_DEBUG=1 make build +make build-debug ``` -Only the exact value `1` enables it; any other value (including unset, empty, or -`true`) yields a release build, so a typo cannot accidentally ship the debug -behavior. The build prints which mode it used. See the -[DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never +`AUTISTMASK_DEBUG=1` still selects the debug compile, and only the exact value +`1` does; any other value (including unset, empty, or `true`) yields a release +build, so a typo cannot accidentally ship the debug behavior. But it is the +compiler's input, not the verifier's: if it happens to be exported in the shell +that runs `make build`, that target compiles a debug bundle and then **fails**, +because it tells `script/verify-build` in so many words that it was supposed to +produce a release build. It used to be that the verifier read the same variable +out of its own environment, agreed with itself, and reported a debug artifact as +verified. The build prints which mode it used. A release build that fails also +**removes `dist/`**, and says so: the bundle it had already written is loadable, +and a loud failure is no protection against someone loading `dist/chrome/` +anyway. `make build-debug` keeps its `dist/` on failure — that output is not +mistakable for a release build, and it is the evidence of what went wrong. See +the [DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never distribute a debug build** — every wallet it creates gets the same publicly known test recovery phrase. +Both targets end by running `script/verify-build`, which reads the compiled +`DEBUG` state back out of the emitted bundles and fails the build if it is not +the one that was asked for. The test suite cannot check this: it loads +`src/shared/constants.js` outside a bundle, so it only ever sees the fallback +value. The assertion is on the artifacts because that is where the property +lives. + +### Build Receipts + +`build.js` records every file it emits — path, sha256, and whether the file is +one of the bundles containing `src/shared/constants.js` — into a build receipt, +and `script/verify-build` checks `dist/` against that receipt: every recorded +file present with exactly the recorded bytes, every audited bundle carrying the +requested `DEBUG` marker, and no regular file or symlink under `dist/` that the +build did not write. The `Makefile` creates the receipt path with `mktemp` per +invocation, outside the repo, and deletes it afterwards. + +That is what ties the check to a build rather than to a directory. What it +establishes is narrow and worth stating exactly: `dist/` is byte for byte the +output of the `build.js` run that just finished, with no regular file or symlink +added, removed or altered in between. Regular files and symlinks are the whole +of what the tree walk covers; fifos, sockets, device nodes and empty directories +under `dist/` are not checked, because a build emits none of them, none can +carry a shippable payload, and `grep` on a fifo would hang rather than fail. It +establishes nothing about whether the source tree or `build.js` were honest, and +it offers nothing to someone handed a `dist/` from elsewhere — without the +receipt from its own build there is no input to the check. Verifiable provenance +for a third party is signing, which this is not. + +There is deliberately no target that re-verifies an existing `dist/` on its own. +The list of files to check has to come from the build that produced them; read +back out of `dist/`, it is the artifact vouching for itself, which is how a +26-byte file containing only the marker string, a hostile content script, and an +entire hand-written `dist/` all used to verify green. + ## Entrypoints This repository adheres to the @@ -73,15 +221,439 @@ 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/lint` — run the linter +- `script/test-e2e` — run the Chrome browser end-to-end suite (docker is the + only prerequisite: it builds a pinned image that carries the repo and a fresh + extension build, see [End-to-End Tests](#end-to-end-tests)) +- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same, + against an image with a pinned Firefox and geckodriver, see + [End-to-End Tests](#end-to-end-tests)) +- `script/lint` — run ESLint (`eslint.config.js`) and then `prettier --check`, + failing on either. It never writes: `--fix` is not in this path, so + `make check` stays non-mutating. Linting runs in the container — the script + builds the Dockerfile's `lint` stage — because an ESLint result that depends + on whichever ESLint the host happens to have is not a result. Docker is + therefore required to lint; inside that image `AUTISTMASK_LINT_NATIVE=1` makes + the same script lint in place instead of recursing. - `script/fmt` — format all files (writes) - `script/fmt-check` — check formatting (read-only) -- `script/check` — run test, lint, and fmt-check +- `script/check` — run test, test-verify-build, check-censored, lint, and + fmt-check +- `script/check-censored` — assert the competitor name RULES.md bars appears + nowhere in the working tree or under `dist/` outside its documented + exceptions: the pinned source reference in `script/vendor-blocklist`, the two + provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's + on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and + fails anywhere else. Part of `make check`, which inspects `dist/` when there + is one and says loudly when there is not; `make build` re-runs it with + `--require-dist`, so a build artifact is always covered +- `script/package` — produce the release artifacts: `make build` first, so the + archives can only ever be made from a `dist/` that has been verified against + that build's own receipt as a RELEASE build, then one self-contained, + versioned archive per browser into `release/` (see + [Release Artifacts](#release-artifacts)). It packages and does not publish +- `script/vendor-blocklist` — refresh `src/shared/phishingBlocklist.json` from + its upstream, pinned to a commit and to the sha256 of the bytes that commit + serves. Run deliberately, never as part of a build: the output is committed + and there is no runtime fetch, so the shipped list is as fresh as the last + vendoring run that was released +- `script/verify-build --expect release|debug --receipt PATH` — assert that the + regular files and symlinks under `dist/` are exactly what the build that just + ran emitted (other file types are out of scope), and that the compiled `DEBUG` + state of the bundles in it is the one that was asked for. Both arguments are + required and neither has a default: the expected mode is stated by the caller + rather than read from `AUTISTMASK_DEBUG`, and the file list comes from the + build's receipt rather than from `dist/` (see + [Build Receipts](#build-receipts)). Run automatically at the end of + `make build` and `make build-debug`; fails loudly rather than passing whenever + it cannot determine something. Not part of `make check`, which does not depend + on build artifacts existing. +- `script/discard-dist-on-failure COMMAND [ARG...]` — run one step of the + **release** build and, if it fails, remove `dist/` before returning that + step's exit status, saying on stderr that it did and why. Every step of + `make build` runs through it; `make build-debug` runs none of them through it. + A step that succeeds removes nothing, and a removal that cannot be completed + is reported as loudly as one that was +- `script/test-verify-build` — exercise every failure mode of + `script/verify-build` against a fixture tree in a temp dir, asserting the exit + status and the message of each, assert the state of `dist/` on disk after a + failing and a succeeding release build step, and read the `make build` and + `make build-debug` recipes back out of `make -n` to check that they pass the + mode as an argument on a scrubbed environment and wrap only the release path. + Part of `make check`; it reads no build artifacts and writes nothing under + `dist/`. The cases that depend on file permissions cannot mean anything for a + process that is not subject to them, so the harness proves its runner against + a mode-000 file before counting them, dropping to an unprivileged user when + run as root; if it cannot, it skips those cases and says so in a banner rather + than passing them. - `script/docker` — build the Docker image tagged via `script/projectname` - `script/cibuild` — CI entrypoint: plain `docker build .` - `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/install-precommit` — install the git pre-commit hook +The Makefile shims to those. It also carries a few targets that have no +`script/` counterpart and are Makefile-only conveniences: + +- `make install` — `yarn install --frozen-lockfile` on its own, without the rest + of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being + silently rewritten. Use `make setup` for a fresh clone. +- `make hooks` — shims to `script/install-precommit` +- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`, + then verify the result against the build's receipt as a release build. A + failure at any step removes `dist/` +- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a + debug build, and keeping its `dist/` on failure (see + [Debug Builds](#debug-builds)) +- `make clean` — remove `dist/` and `release/` +- `make dev` — build in watch mode + +## End-to-End Tests + +There are two suites, one per browser, and they share no code. Chrome runs on +Playwright; Firefox has its own WebDriver client, because Playwright cannot +observe errors on a Firefox extension page at all — see +[Firefox](#firefox-make-test-e2e-firefox) below. Both require docker, and both +are outside `make check`. + +### Chrome (`make test-e2e`) + +`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 +`tests/e2e/Dockerfile`, which is also where the extension is built; 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, WebAssembly compilation under the shipped CSP (see +[Content Security Policy](#content-security-policy)), wallet creation through +the UI, the Add Token screen, the transaction detail screen for an ERC-20 +transfer, and the recovery phrase screen — which wallet types are offered it, +that it holds nothing before the password is accepted, that a wrong password +reveals nothing, that leaving it by either route wipes it — including a leave +taken while the decrypt is still running — and that reopening the popup does not +land on it. It also covers address removal: which wallets offer the control at +all, that the confirmation states the route back rather than showing an empty +paragraph, that leaving the confirmation removes nothing, and that confirming it +does. 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. + +It also covers the **Settings screen**, which holds the densest run of element +id lookups in the codebase and where one wrong id leaves the whole popup blank +rather than only degrading Settings: that the screen renders populated — the +About well and the wallet list are read back, so a `show()` that stopped early +is caught rather than merely a view that failed to appear — that the four Token +Spam Protection controls are real checkboxes defaulted on, and that the theme +and network selectors offer exactly the choices `src/shared/networks.js` and +`index.html` define. What the selectors persist is then driven through the UI to +`dark` and `sepolia` and reasserted after a popup reopen before being restored +the same way; neither value is its selector's first `