test: containerized Chrome end-to-end harness that drives the real popup (closes #181) #185
6
Makefile
6
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/
|
# Standard targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
@@ -16,6 +16,10 @@ install:
|
|||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
|
# Browser end-to-end suite. Requires docker; not part of check.
|
||||||
|
test-e2e:
|
||||||
|
@script/test-e2e
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
|
|||||||
62
README.md
62
README.md
@@ -73,6 +73,8 @@ provide:
|
|||||||
git pre-commit hook
|
git pre-commit hook
|
||||||
- `script/projectname` — print the project name (used for the Docker image tag)
|
- `script/projectname` — print the project name (used for the Docker image tag)
|
||||||
- `script/test` — run the test suite (jest)
|
- `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/lint` — run the linter
|
||||||
- `script/fmt` — format all files (writes)
|
- `script/fmt` — format all files (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `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/precommit` — run by the git pre-commit hook; runs `script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook
|
- `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
|
## Rationale
|
||||||
|
|
||||||
Common popular EVM wallets have become bloated with swap UIs, portfolio
|
Common popular EVM wallets have become bloated with swap UIs, portfolio
|
||||||
|
|||||||
21
TODO.md
21
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
|
and scripts-to-rule-them-all landed as #148, so the `scripts/` directory
|
||||||
question is resolved. Full policy file set present. `make check` verified
|
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
|
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
|
# Next Step
|
||||||
|
|
||||||
@@ -27,6 +28,13 @@ review.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 2026-08-09: Reviewed the repo end to end and filed the 1.0.0 backlog
|
||||||
(#149-#168).
|
(#149-#168).
|
||||||
- 2026-07-26: About well in settings with build info, repo link and the version
|
- 2026-07-26: About well in settings with build info, repo link and the version
|
||||||
@@ -52,10 +60,17 @@ review.
|
|||||||
|
|
||||||
# Future Steps
|
# 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
|
- Add ESLint to `script/lint` (#152). `make check` is `prettier --check` only
|
||||||
and cannot catch undefined identifiers, which is how #150 and #151 shipped.
|
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
|
- Make the Firefox target functional: Chrome callback APIs are used against the
|
||||||
promise-only `browser` namespace (#153).
|
promise-only `browser` namespace (#153).
|
||||||
- Send and transaction-flow correctness: gas fee excluded from the
|
- Send and transaction-flow correctness: gas fee excluded from the
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@tailwindcss/cli": "^4.2.1",
|
"@tailwindcss/cli": "^4.2.1",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
|
"playwright-core": "1.56.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^4.2.1"
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
|
|||||||
64
script/test-e2e
Executable file
64
script/test-e2e
Executable file
@@ -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 "$@"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { $, showFlash, goBack } = require("./helpers");
|
const { $, showView, showFlash, goBack } = require("./helpers");
|
||||||
const { getTopTokens } = require("../../shared/tokenList");
|
const { getTopTokens } = require("../../shared/tokenList");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { lookupTokenInfo } = require("../../shared/balances");
|
const { lookupTokenInfo } = require("../../shared/balances");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
showFlash,
|
showFlash,
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
|
addressDotHtml,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
isoDate,
|
isoDate,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
|
|||||||
289
tests/e2e/harness.js
Normal file
289
tests/e2e/harness.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
334
tests/e2e/network.js
Normal file
334
tests/e2e/network.js
Normal file
@@ -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/<addr>/... 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<string|null>}>}
|
||||||
|
*/
|
||||||
|
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: "<html><body></body></html>",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
264
tests/e2e/run.js
Normal file
264
tests/e2e/run.js
Normal file
@@ -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;
|
||||||
|
});
|
||||||
@@ -2547,6 +2547,11 @@ pkg-dir@^4.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
find-up "^4.0.0"
|
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:
|
pngjs@^5.0.0:
|
||||||
version "5.0.0"
|
version "5.0.0"
|
||||||
resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz"
|
resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user