Compare commits

...

4 Commits

Author SHA1 Message Date
52fb765232 test: containerized Firefox end-to-end harness (closes #184)
All checks were successful
check / check (push) Successful in 35s
Drives the real popup in a real Firefox with dist/firefox/ installed as an
unpacked MV2 temporary add-on, via geckodriver. Covers popup load, wallet
creation through the UI, and the Add Token screen. Outside make check, like
the Chrome suite.

Zero npm dependencies: tests/e2e/firefox/driver.js is a WebDriver client
over global fetch and child_process against geckodriver's HTTP API. The
Dockerfile pins the node base image, the Firefox 153.0.3 tarball and
geckodriver 0.36.0 by digest.

Errors are read from the privileged nsIConsoleService in Marionette's chrome
context, filtered to non-warning entries whose sourceName is the extension
origin. BiDi log.entryAdded delivers nothing at all for extension pages, so
a Playwright-BiDi or Puppeteer-BiDi harness would see nothing and report
success; the code says so where someone would be tempted to simplify it.
Errors logged during add-on install and background startup are drained and
folded into step 1, never discarded: a throw at the top of
src/background/index.js kills the background page and fails the run.
Content-script capture is left as unverified, because --network none leaves
no http:// page for a content script to be injected into.

Each drain reads the console and clears it in ONE chrome script. Splitting
the read from Services.console.reset() left a window between the two round
trips in which an error was logged into a buffer about to be discarded, and
destroyed unread rather than deferred to the next drain; a probe of 100
sequenced throws at 20ms spacing lost one. With the drain atomic the same
probe accounts for every throw that falls inside the observed window, on two
consecutive runs.

No driver layer is shared with the Chrome suite and the three UI steps are
written twice deliberately: the two backends have no common substrate, and
three steps do not pay for a shim.

Two limits are documented rather than papered over, with measurements rather
than absolutes. Error capture is poll-based, so an error is attributed to a
step and not to a moment within it; the drained window ends ~1.5s after the
last step returns (a 500ms settle, a 1000ms sleep and two drain round trips),
and that cut-off jitters run to run — three runs of throws at fixed offsets
reported everything up to +1.5s and one of the three also reported +1.6s.
Inside the window the atomic drain leaves no race, but nsIConsoleService
keeps a ring buffer of only 250 messages, so more than 250 console messages
between two drains evicts unread errors: 400 throws inside one step report
as exactly the newest 250, on three runs, while occupancy in a clean run
peaks at 4 of 250 at the install drain and 0 at every later drain. Nothing is
stubbed; the container runs with --network none instead, which proves no
request escaped, cannot report which were attempted, and runs only the
failure branches of network-dependent code.
2026-08-12 09:56:55 +00:00
18b47cd579 test: close the empty-batch hole in the e2e unstubbed-request guard (closes #187)
Some checks failed
check / check (push) Has been cancelled
The guard that reports unrecognised POST bodies used batch.every(), which is
vacuously true on an empty array, so a POST with body [] was answered 200 []
and escaped the one mechanism whose job is to make unrecognised outbound
traffic fail the suite rather than pass silently. Unreachable in practice
today, which is exactly the qualifier that stops being true later.

The comment explaining the guard also described a mechanism that does not
exist: playwright-core decodes a binary body lossily rather than returning
null, so such a body reaches the JSON parse as mojibake and is reported by the
catch, while only an absent or empty body decodes to null and is reported by
the type guard. Both are reported; the comment now describes the two real
routes.
2026-08-12 11:50:38 +02:00
5af89a1b63 test: drive ConfirmTx in the e2e suite, gate assertion included (closes #238)
All checks were successful
check / check (push) Successful in 38s
ConfirmTx -- the screen that decides what gets signed -- had no automated
coverage of its own behaviour. The arithmetic underneath was well tested; the
wiring was not, so a mutant making the spend gate read the displayed fee
estimate instead of the reserve would have reintroduced the #154 overspend with
the suite still green.

Nine end-to-end tests now drive it for both the native and ERC-20 paths,
covering the pending, funded, over-balance and estimate-failed states, and
asserting that the gate reads the reserve rather than the estimate. Swapping the
two makes the suite fail. The view height is asserted constant across every
state transition rather than merely printed.

Reaching the screen needs a funded balance and a gas estimate, so the route
interception gains fixtures for both. Testing the estimate-failed state means
provoking the console error the code is supposed to emit, which the harness
otherwise fails a run on; an expectation mechanism consumes exactly one matching
record, is scoped to the declaring test, and fails that test if nothing matched,
so it cannot mask an unrelated error.
2026-08-12 11:35:20 +02:00
c6a1f97247 fix: explain a rejected dust threshold instead of silently snapping back (closes #233)
All checks were successful
check / check (push) Successful in 33s
The dust-threshold field was the only validated input in Settings that rejected
without saying anything: the value silently changed back to the stored one with
no explanation. It now flashes "Please enter a whole number of gwei, zero or
greater." alongside the existing resync, matching the idiom the RPC URL field
already uses.

The parse moves to its own module and accepts plain decimal digits only, zero
or greater. Hex and exponent notation are refused rather than accepted: Number()
reads "0x10" as 16 and "1e3" as 1000, neither of which the previous parseInt
produced, and storing a number the user did not type is the same silent
substitution this change exists to remove.

The message must fit one line of the reserved flash area -- a wrapped message
pushes the settings view down, which the No Layout Shift policy forbids. That is
pinned by an end-to-end test measuring the rendered line height and the position
of the elements below it, in a single round trip because the flash clears after
two seconds.
2026-08-12 11:29:09 +02:00
13 changed files with 2313 additions and 55 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: bootstrap setup install test test-e2e lint fmt fmt-check check docker hooks build build-debug verify-build clean dev
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check docker hooks build build-debug verify-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,10 +16,13 @@ install:
test:
@script/test
# Browser end-to-end suite. Requires docker; not part of check.
# 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

127
README.md
View File

@@ -83,7 +83,10 @@ 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
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker required;
see [End-to-End Tests](#end-to-end-tests))
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (docker
required; builds its own pinned image, see
[End-to-End Tests](#end-to-end-tests))
- `script/lint` — run the linter
- `script/fmt` — format all files (writes)
@@ -123,6 +126,14 @@ The Makefile shims to those. It also carries a few targets that have no
## 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 `script/test-e2e`;
@@ -146,6 +157,23 @@ 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 confirmation screen, for both a native ETH send and an ERC-20
send: Send disabled while the fee estimate is in flight, enabled once it lands,
the fee block quoting the expected cost and the reserve separately, the distinct
message for an estimate that failed, and the view height staying constant across
every one of those transitions. The load-bearing one is that the spend gate uses
the **reserve** and not the displayed **estimate** — the two are stubbed far
apart on purpose, and the funded and refused sends sit on opposite sides of the
reserve while sitting on the same side of the estimate, so swapping the two in
`src/popup/views/confirmTx.js` fails the suite instead of passing it. That is
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only.
Any test that drives a failure path on purpose declares the `console.error` it
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
consumes exactly one matching record, and a declaration nothing matched fails
its test just as an undeclared error does.
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
@@ -183,12 +211,90 @@ 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/`.
### Firefox (`make test-e2e-firefox`)
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
It covers popup load, wallet creation through the UI, and the Add Token screen.
The suite lives in `tests/e2e/firefox/` and has **no npm dependencies at all**:
it is a small WebDriver client built on global `fetch` and `child_process`
against geckodriver's HTTP API.
Unlike the Chrome suite it builds its own container image rather than pulling a
published one, because no published image carries both a pinned Firefox and a
matching geckodriver. `tests/e2e/firefox/Dockerfile` pins all three external
artifacts by digest — the `node` base image, the Firefox 153.0.3 tarball, and
geckodriver 0.36.0 — and the Firefox version in particular must not float:
`-remote-allow-system-access` is **mandatory** on 153 and was not on 142.
Without that flag, both navigating to `moz-extension://` and running
chrome-context script fail with `unsupported operation`. The flag grants the
driver full chrome privileges over that browser, which is acceptable only
because it is a throwaway container.
The popup's `moz-extension://` uuid is **pinned, not discovered**: the profile
pref `extensions.webextensions.uuids` maps the extension id that
`manifest/firefox.json` already declares to a fixed uuid, so the popup URL is
deterministic. Navigation uses **classic** WebDriver `POST /session/{id}/url`,
because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright.
**Any uncaught error from a `moz-extension://` source fails the run**, including
errors from the background page, which the suite never navigates to: a `throw`
at the top of `src/background/index.js` kills the background page and fails
step 1. Content-script errors should arrive by the same route, but this suite
does not exercise it and does not claim it — with `--network none` there is no
`http://` page for a content script to be injected into. Errors from add-on
install and background startup are folded into step 1 rather than discarded.
Errors are read from the privileged `nsIConsoleService` in Marionette's chrome
context and filtered to non-warning entries whose `sourceName` is the extension
origin. That mechanism is not a stylistic choice. WebDriver BiDi's
`log.entryAdded` delivers **nothing** for extension pages: on a plain `http://`
page it reports uncaught errors with stack traces, and on the `moz-extension://`
popup it reports zero events, because Firefox's remote agent excludes extension
browsing contexts from BiDi observation. Any harness built on Playwright-BiDi or
Puppeteer-BiDi would therefore see nothing and report success, which is exactly
the vacuous check this repo has already shipped twice. Do not migrate this suite
to BiDi.
Two limits are worth knowing, both real differences from the Chrome suite:
- **Error capture is poll-based, not event-streamed.** The console is drained at
each step boundary, so an error is attributed to the step it was drained
after, not to a moment within it. The window that is drained runs from add-on
install to **≈1.5s** after the last step returns — a 500ms settle, a 1000ms
tail sleep and two drain round trips — and then the browser is torn down. That
cut-off is not a hard boundary: with throws scheduled at fixed offsets, three
runs reported everything up to +1.5s and one of the three also reported +1.6s,
so an error landing near it may or may not be seen, and anything well past it
is not. Inside the window there is no race — each drain reads and clears the
console in a single chrome round trip, so an error logged mid-drain lands in
that batch or the next rather than being destroyed unread — but there is a
**capacity limit**: `nsIConsoleService` keeps a ring buffer of 250 messages
and silently evicts the oldest, so more than 250 console messages between two
drains destroys the excess unread. 400 throws inside one step are reported as
exactly the newest 250, three runs running. That buffer is shared with
Firefox's own console noise; a clean run peaks at 4 of 250 at the install
drain and 0 at every later drain, so the three steps here have wide headroom,
but a step that logs heavily could evict unread errors. What poll-based costs
is location, not coverage: an error cannot be placed within a step the way the
Chrome suite's `pageerror` events place it.
- **Nothing is stubbed, which inverts the coverage of network-dependent code.**
There is no fixture layer; the container runs with `--network none` instead,
so the run is offline and deterministic and no request can escape. The
extension swallows its own fetch failures, so the flows are unaffected — but
every network call fails, so only the _failure_ branches of code that depends
on one are ever executed. A `ReferenceError` in the success path of
`renderTransactions`, or of price or balance rendering, passes this suite
green. The offline run is also weaker than the Chrome suite's interception: it
proves nothing got out, but it cannot report which requests were attempted.
Closing that gap needs a fixture layer, deliberately out of scope for this
harness.
Neither `make test-e2e` nor `make test-e2e-firefox` is 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. Neither is wired into the Gitea workflow yet —
docker-in-docker in CI is a separate question. Run them locally before changing
anything under `src/popup/views/`.
## Rationale
@@ -857,7 +963,12 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- "Hide fake tokens impersonating a known symbol" checkbox
- "Hide tokens with fewer than 1,000 holders" checkbox
- "Hide transactions from detected fraud contracts" checkbox
- "Hide dust transactions below N gwei" checkbox + threshold input
- "Hide dust transactions below N gwei" checkbox + threshold input. The
threshold is plain decimal digits, a whole number of gwei, zero or
greater (zero hides nothing). Anything else — a fraction, a negative,
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
is refused with a flash message and the field snaps back to the stored
threshold, so a number the user did not type is never stored.
- Allowed Sites: list with remove buttons
- Denied Sites: list with remove buttons
- About: project link, license, author, version, release date, and the

54
TODO.md
View File

@@ -30,9 +30,10 @@ compiled off.
The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
authoritative; this file does not duplicate it. Full policy file set present. 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.
authoritative; this file does not duplicate it. Full policy file set present.
Real-browser end-to-end suites (`make test-e2e` for Chrome,
`make test-e2e-firefox` for Firefox) now sit alongside `make check`, which
cannot see a runtime `ReferenceError` in a popup view.
# Next Step
@@ -44,6 +45,45 @@ undefined identifiers, which is how
# Completed Steps
- 2026-08-12: A containerized Firefox end-to-end harness
(`make test-e2e-firefox`) drives the real popup in a real Firefox with the MV2
build installed as a temporary add-on. Zero npm dependencies — a WebDriver
client over `fetch` against geckodriver — with `node`, Firefox 153.0.3 and
geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the
privileged console service in Marionette's chrome context, because BiDi
`log.entryAdded` reports nothing at all for extension pages; each drain reads
and clears the console in one chrome round trip, so no error is destroyed
unread by the drain itself, and errors logged during add-on install and
background startup are folded into step 1 instead of being cleared. The two
measured limits are documented rather than claimed away: the console ring
buffer holds 250 messages (a clean run peaks at 4), and the drained window
ends ≈1.5s after the last step returns. Demonstrated discriminating by exiting
1 on a `throw` at the top of `src/background/index.js`, on a build with one
import removed, on a `setTimeout` throw whose UI assertions all pass, on an
unhandled `Promise.reject` and on an undefined identifier in `home.js`, and 0
on the branch as it stands
([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)).
- 2026-08-12: Closed the empty-array hole in the end-to-end unstubbed-request
guard. `batch.every()` is vacuously true on `[]`, so a POST with body `[]` was
answered `200 []` instead of failing the suite; the guard now rejects an empty
batch, demonstrated green-before/red-after with a throwaway probe. The comment
claiming `postData()` returns `null` for undecodable bodies was corrected to
the two real paths — an absent or empty body decodes to `null`, a binary body
decodes lossily into invalid JSON
([#187](https://git.eeqj.de/sneak/AutistMask/issues/187)).
- 2026-08-12: The transaction confirmation screen has browser coverage. The
end-to-end suite reaches ConfirmTx for both the native ETH and the ERC-20 path
off a funded-balance fixture, and asserts the pending, funded, over-balance
and estimate-failed states, the fee block quoting the estimate and the reserve
separately, and a constant view height across every one of those transitions.
The load-bearing assertion is that the spend gate reads the reserve and not
the displayed estimate: swapping the two fails the suite
([#238](https://git.eeqj.de/sneak/AutistMask/issues/238)).
- 2026-08-12: The dust threshold field now explains a rejection instead of
snapping back in silence, with the parse in a pure, unit-tested module that
accepts plain decimal digits only — hex and exponent notation are refused
rather than read as 16 and 1000
([#233](https://git.eeqj.de/sneak/AutistMask/issues/233)).
- 2026-08-12: Approval verification became an allowlist — transaction type
restricted to 0/1/2 so an EIP-7702 delegation can no longer ride along on an
approved transfer, every consequential field compared, the artifact
@@ -231,9 +271,9 @@ tracker.
- Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
input validation) before any 1.0rc tag. Individual filed issues are parts of
it, but the review is broader than any of them.
- Decide whether docker-in-docker makes `make test-e2e` runnable in the Gitea
workflow. Extending the suite itself is tracked as
[#183](https://git.eeqj.de/sneak/AutistMask/issues/183) and
[#184](https://git.eeqj.de/sneak/AutistMask/issues/184).
- Decide whether docker-in-docker makes `make test-e2e` and
`make test-e2e-firefox` runnable in the Gitea workflow. Extending the Chrome
suite itself is tracked as
[#183](https://git.eeqj.de/sneak/AutistMask/issues/183).
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
land.

63
script/test-e2e-firefox Executable file
View File

@@ -0,0 +1,63 @@
#!/bin/sh
# script/test-e2e-firefox: build the extension and drive the real popup in
# a real Firefox inside a pinned container. The Firefox counterpart to
# script/test-e2e. Our own extension to scripts-to-rule-them-all.
#
# Deliberately NOT called by script/check or script/test, for the same
# reason as the Chrome suite: REPO_POLICIES.md caps make test at 20 seconds
# and a browser suite does not fit.
#
# Unlike script/test-e2e this builds its image locally, because no
# published image carries both a pinned Firefox and a matching geckodriver.
# All three external artifacts are pinned by digest inside the Dockerfile;
# see tests/e2e/firefox/Dockerfile.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
main() {
cd "$ROOT"
if ! command -v docker >/dev/null 2>&1; then
echo "test-e2e-firefox: docker is required to run the e2e suite" >&2
exit 1
fi
echo "Building extension for e2e..."
yarn run build 2>&1
# The build context is tests/e2e/firefox/ and holds nothing but the
# Dockerfile: the harness itself arrives over the bind mount below, so
# editing it never invalidates an image layer.
echo "Building the pinned Firefox e2e image..."
docker build -t "$IMAGE" "$ROOT/tests/e2e/firefox"
echo "Running the Firefox e2e suite..."
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
# --network none: the suite stubs nothing, so this is what keeps the
# run offline and deterministic. The extension swallows its own
# fetch failures, so the popup flows work unchanged; see the
# network note in README.md. Weaker than the Chrome suite's
# fixture interception, and honestly so — it proves no request
# escaped, but it cannot report which ones were attempted.
# --user: keep files the suite touches owned by the caller, not root.
# HOME=/tmp: the mapped uid has no home directory in the image.
#
# No --privileged. Firefox's sandbox logs
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
# it is cosmetic and headless Firefox runs fine without it.
docker run --rm \
--shm-size=1g \
--network none \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \
-v "$ROOT:/work" \
-w /work \
"$IMAGE" \
node tests/e2e/firefox/run.js dist/firefox
}
main "$@"

View File

@@ -0,0 +1,42 @@
// Parsing for the dust threshold field in Settings.
//
// Pure: no DOM, no state, so the accepted set can be unit tested directly
// instead of through the settings view.
//
// Accepted input is plain decimal digits only, meaning a whole number of
// gwei, zero or greater. Zero is a real setting: it hides nothing.
//
// Deliberately rejected, not coerced:
// "" nothing to save
// "-1" a negative threshold has no meaning
// "1.5" fractional gwei is not a threshold the filter can use
// "100 gwei" the unit is already printed beside the field
// "0x10" hex, which Number() would silently read as 16
// "1e3" exponent notation, which Number() would silently read as 1000
//
// The last two are the reason this is a digit test and not a Number() test.
// Number() accepts both, and accepting them would put a number in the field
// that the user did not type — the same silent substitution the visible
// rejection message exists to end.
// Must render on ONE line of #flash-msg, whose reserved height
// (min-h-[1.25rem]) is exactly one line at text-xs. A string long enough to
// wrap to two lines pushes the settings view down, which the No Layout Shift
// policy forbids. Do not lengthen this without re-running the layout test in
// tests/e2e/run.js, which measures the flash line and goes red on a shift.
const DUST_THRESHOLD_MESSAGE =
"Please enter a whole number of gwei, zero or greater.";
// Returns the threshold in gwei, or null if the input is not one.
function parseDustThresholdGwei(raw) {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!/^[0-9]+$/.test(trimmed)) return null;
const val = Number(trimmed);
// A run of digits long enough to exceed Number's exact integer range
// would round on the way in, so it is not a threshold we can store.
if (!Number.isSafeInteger(val)) return null;
return val;
}
module.exports = { DUST_THRESHOLD_MESSAGE, parseDustThresholdGwei };

View File

@@ -9,6 +9,10 @@ const {
pushCurrentView,
} = require("./helpers");
const { applyTheme } = require("../theme");
const {
DUST_THRESHOLD_MESSAGE,
parseDustThresholdGwei,
} = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch");
@@ -329,13 +333,14 @@ function init(ctx) {
$("settings-dust-threshold").value = state.dustThresholdGwei;
$("settings-dust-threshold").addEventListener("change", async () => {
const raw = $("settings-dust-threshold").value.trim();
const val = Number(raw);
// 0 is accepted and means "hide nothing". Empty, negative,
// fractional and non-numeric input is rejected outright rather than
// coerced, and the field is put back to the stored threshold so it
// never shows a value the wallet is not using.
if (raw !== "" && Number.isInteger(val) && val >= 0) {
const val = parseDustThresholdGwei($("settings-dust-threshold").value);
// Rejected input is never coerced. The field is put back to the
// stored threshold so it never shows a value the wallet is not
// using, and the message says what the field wants so the snap-back
// is explained rather than silent.
if (val === null) {
showFlash(DUST_THRESHOLD_MESSAGE);
} else {
state.dustThresholdGwei = val;
await saveState();
}

243
tests/dustThreshold.test.js Normal file
View File

@@ -0,0 +1,243 @@
// Tests for the dust threshold field in Settings (issue #233).
//
// Two halves: what the parse accepts, and what the settings view does with a
// rejection. The view half runs against the real change handler with the DOM
// helpers stubbed out, because the bug was not in the parse — it was that a
// rejection said nothing.
const {
DUST_THRESHOLD_MESSAGE,
parseDustThresholdGwei,
} = require("../src/popup/dustThreshold");
describe("parsing the dust threshold", () => {
test("accepts a whole number of gwei", () => {
expect(parseDustThresholdGwei("100000")).toBe(100000);
expect(parseDustThresholdGwei("1")).toBe(1);
});
// Zero is a real setting, not an empty field: it hides nothing.
test("accepts zero", () => {
expect(parseDustThresholdGwei("0")).toBe(0);
});
test("accepts surrounding whitespace", () => {
expect(parseDustThresholdGwei(" 250 ")).toBe(250);
});
test("rejects an empty field", () => {
expect(parseDustThresholdGwei("")).toBe(null);
expect(parseDustThresholdGwei(" ")).toBe(null);
});
test("rejects a negative threshold", () => {
expect(parseDustThresholdGwei("-1")).toBe(null);
});
// parseInt used to read this as 1, which is not what was typed.
test("rejects a fractional value", () => {
expect(parseDustThresholdGwei("1.5")).toBe(null);
expect(parseDustThresholdGwei("1.0")).toBe(null);
});
// parseInt used to read this as 100. The unit is printed beside the
// field already.
test("rejects a value carrying its unit", () => {
expect(parseDustThresholdGwei("100 gwei")).toBe(null);
});
// Number() reads this as 16. Storing 16 for a field that was told to
// want a whole number of gwei would be the same silent substitution the
// message exists to end.
test("rejects hex notation", () => {
expect(parseDustThresholdGwei("0x10")).toBe(null);
});
// Number() reads this as 1000.
test("rejects exponent notation", () => {
expect(parseDustThresholdGwei("1e3")).toBe(null);
});
test("rejects other non-numeric input", () => {
expect(parseDustThresholdGwei("lots")).toBe(null);
expect(parseDustThresholdGwei("+5")).toBe(null);
expect(parseDustThresholdGwei("Infinity")).toBe(null);
expect(parseDustThresholdGwei(undefined)).toBe(null);
expect(parseDustThresholdGwei(5)).toBe(null);
});
// Beyond 2^53 the digits would round on the way in, so the stored
// threshold would not be the one typed.
test("rejects a value too large to hold exactly", () => {
expect(parseDustThresholdGwei("9007199254740993")).toBe(null);
});
});
describe("the rejection message", () => {
// README, Language & Labeling: error messages are full sentences.
test("is a full sentence naming the constraint", () => {
expect(DUST_THRESHOLD_MESSAGE).toMatch(/^[A-Z].*\.$/);
expect(DUST_THRESHOLD_MESSAGE).toContain("whole number of gwei");
expect(DUST_THRESHOLD_MESSAGE).toContain("zero or greater");
});
});
describe("the flash line the message is shown in", () => {
const fs = require("fs");
const path = require("path");
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
// This asserts only that the reservation exists in the markup. It does
// NOT and CANNOT assert that the message fits inside it: jest runs on
// the node environment here, with no layout engine, so every rendered
// height is zero. An earlier version of this block claimed to pin the
// No Layout Shift policy with this regex, and it passed at any message
// length, including one that wrapped to two lines and pushed the
// settings view down 12px.
//
// The assertion that actually measures — empty line vs. the message,
// real Chromium, documented 360x600 popup — is
// "a rejected dust threshold shifts no layout (#233)" in
// tests/e2e/run.js, run by make test-e2e. It is not in make check
// because REPO_POLICIES.md caps make test at 20 seconds and a browser
// suite does not fit; run it before changing the wording.
test("reserves its height in the markup", () => {
const flashLine = POPUP_HTML.match(
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
);
expect(flashLine).not.toBeNull();
expect(flashLine[1]).toMatch(/min-h-\[/);
});
});
describe("the settings view on a change to the field", () => {
let elements;
let flashes;
let saves;
let state;
// A stand-in for one DOM node: enough of an element for init() to set
// properties on it and hang listeners off it.
function fakeElement() {
return {
value: "",
checked: false,
textContent: "",
href: "",
style: {},
dataset: {},
classList: { add() {}, remove() {} },
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
querySelectorAll: () => [],
};
}
function loadSettingsView() {
elements = {};
flashes = [];
saves = 0;
jest.resetModules();
jest.doMock("../src/popup/views/helpers", () => ({
$: (id) => (elements[id] ||= fakeElement()),
showView: () => {},
updateDebugBanner: () => {},
showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s,
flashCopyFeedback: () => {},
goBack: () => {},
pushCurrentView: () => {},
onViewLeave: () => {},
VIEWS: [],
}));
state = require("../src/shared/state").state;
state.dustThresholdGwei = 100000;
const settings = require("../src/popup/views/settings");
settings.init({});
return elements["settings-dust-threshold"];
}
beforeEach(() => {
globalThis.chrome = {
runtime: { sendMessage: () => {} },
storage: {
local: {
get: async () => ({}),
set: async () => {
saves++;
},
},
},
};
});
afterEach(() => {
jest.dontMock("../src/popup/views/helpers");
delete globalThis.chrome;
});
async function change(field, typed) {
field.value = typed;
await field.listeners.change();
}
test("a valid value is stored and says nothing", async () => {
const field = loadSettingsView();
await change(field, "250");
expect(state.dustThresholdGwei).toBe(250);
expect(field.value).toBe(250);
expect(flashes).toEqual([]);
expect(saves).toBe(1);
});
test("a rejected value shows the message and is not stored", async () => {
const field = loadSettingsView();
await change(field, "1.5");
expect(state.dustThresholdGwei).toBe(100000);
expect(flashes).toEqual([DUST_THRESHOLD_MESSAGE]);
expect(saves).toBe(0);
});
// The snap-back is the behaviour the message explains, so it stays.
test("a rejected value still resyncs the field to what is stored", async () => {
const field = loadSettingsView();
await change(field, "100 gwei");
expect(field.value).toBe(100000);
});
test("every rejected notation gets the same one message", async () => {
for (const typed of ["", "-1", "1.5", "100 gwei", "0x10", "1e3"]) {
const field = loadSettingsView();
await change(field, typed);
expect(flashes).toEqual([DUST_THRESHOLD_MESSAGE]);
expect(state.dustThresholdGwei).toBe(100000);
}
});
test("zero is accepted, not treated as an empty field", async () => {
const field = loadSettingsView();
await change(field, "0");
expect(state.dustThresholdGwei).toBe(0);
expect(flashes).toEqual([]);
});
});

View File

@@ -0,0 +1,51 @@
# Firefox end-to-end image: stock Firefox plus geckodriver on a node base,
# built by script/test-e2e-firefox. The repo is bind-mounted at /work; the
# harness itself has no dependencies, so nothing is installed for it.
#
# All three external artifacts are pinned by digest. The Firefox version in
# particular must not float: -remote-allow-system-access is mandatory on 153
# and was not on 142, so the flag the harness passes is version-coupled.
# node:22-bookworm-slim, 2026-08-12
FROM node@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436
ENV DEBIAN_FRONTEND=noninteractive
# Firefox's shared-library dependencies on a slim base, plus the two tools
# needed to fetch and unpack the pinned tarballs.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
libasound2 \
libdbus-glib-1-2 \
libgtk-3-0 \
libx11-xcb1 \
libxt6 \
libxtst6 \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# Firefox 153.0.3, linux-x86_64, en-US
ARG FIREFOX_URL=https://ftp.mozilla.org/pub/firefox/releases/153.0.3/linux-x86_64/en-US/firefox-153.0.3.tar.xz
ARG FIREFOX_SHA256=22b312280900bfb174b685ece32c7b3c6d72e7f8e53d6d30f21ac41a8dc500a2
RUN curl -fsSL -o /tmp/firefox.tar.xz "$FIREFOX_URL" \
&& echo "$FIREFOX_SHA256 /tmp/firefox.tar.xz" | sha256sum -c - \
&& tar -xJf /tmp/firefox.tar.xz -C /opt \
&& rm /tmp/firefox.tar.xz \
&& /opt/firefox/firefox --version
# geckodriver v0.36.0, linux64
ARG GECKODRIVER_URL=https://github.com/mozilla/geckodriver/releases/download/v0.36.0/geckodriver-v0.36.0-linux64.tar.gz
ARG GECKODRIVER_SHA256=0bde38707eb0a686a20c6bd50f4adcc7d60d4f73c60eb83ee9e0db8f65823e04
RUN curl -fsSL -o /tmp/geckodriver.tar.gz "$GECKODRIVER_URL" \
&& echo "$GECKODRIVER_SHA256 /tmp/geckodriver.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/geckodriver.tar.gz -C /usr/local/bin \
&& rm /tmp/geckodriver.tar.gz \
&& geckodriver --version
ENV FIREFOX_BIN=/opt/firefox/firefox
ENV GECKODRIVER=/usr/local/bin/geckodriver
WORKDIR /work
CMD ["node", "tests/e2e/firefox/run.js", "dist/firefox"]

464
tests/e2e/firefox/driver.js Normal file
View File

@@ -0,0 +1,464 @@
// A minimal WebDriver client for geckodriver, plus the privileged console
// reader the error assertions are built on. No npm dependencies: global
// fetch and child_process against geckodriver's HTTP API is less code than
// a driver library and keeps the harness at zero packages.
//
// Run through script/test-e2e-firefox, which builds dist/firefox/ and the
// pinned container around this. FIREFOX_BIN and GECKODRIVER locate the two
// binaries; the image sets both.
"use strict";
const { spawn } = require("child_process");
const net = require("net");
const FIREFOX_BIN = process.env.FIREFOX_BIN || "firefox";
const GECKODRIVER = process.env.GECKODRIVER || "geckodriver";
// The extension id declared in manifest/firefox.json, and the uuid the
// popup is served from. Firefox normally assigns that uuid randomly per
// profile, which would make the popup URL undiscoverable without querying
// privileged state; setting extensions.webextensions.uuids before launch
// pins it instead. This only works because the manifest declares a fixed
// browser_specific_settings.gecko.id — without one the mapping has no key.
const EXTENSION_ID = "autistmask@sneak.berlin";
const EXTENSION_UUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const EXTENSION_ORIGIN = "moz-extension://" + EXTENSION_UUID;
// The W3C web element identifier. Getting the last character wrong yields
// an element reference of "undefined" and a bewildering "element with the
// reference undefined is not known" from geckodriver, so findElement()
// below checks for the key rather than indexing blindly.
const WEB_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
const SCRIPT_TIMEOUT_MS = 120000;
const DEFAULT_WAIT_MS = 20000;
const POLL_INTERVAL_MS = 100;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// An ephemeral port picked by the kernel, then handed to geckodriver.
// There is a race between closing this listener and geckodriver binding,
// but this host runs many sessions at once and a fixed 4444 is a
// guaranteed collision rather than a possible one.
function freePort() {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.on("error", reject);
srv.listen(0, "127.0.0.1", () => {
const { port } = srv.address();
srv.close(() => resolve(port));
});
});
}
class WebDriverError extends Error {
constructor(command, body) {
const v = (body && body.value) || {};
super(
command +
" failed: " +
(v.error || "unknown error") +
": " +
(v.message || JSON.stringify(body)),
);
this.name = "WebDriverError";
this.error = v.error;
}
}
class Driver {
constructor(proc, base) {
this.proc = proc;
this.base = base;
this.sessionId = null;
this.context = "content";
}
async send(method, path, body) {
const url = this.base + path;
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch (_) {
throw new Error(
method + " " + path + ": non-JSON response: " + text,
);
}
if (!res.ok) throw new WebDriverError(method + " " + path, parsed);
return parsed.value;
}
session(method, path, body) {
return this.send(method, "/session/" + this.sessionId + path, body);
}
// ------------------------------------------------------------ setup
async newSession() {
const prefs = {
// See EXTENSION_UUID above. The pref is a string pref whose
// value is itself JSON.
"extensions.webextensions.uuids": JSON.stringify({
[EXTENSION_ID]: EXTENSION_UUID,
}),
};
const value = await this.send("POST", "/session", {
capabilities: {
alwaysMatch: {
browserName: "firefox",
"moz:firefoxOptions": {
binary: FIREFOX_BIN,
args: [
"-headless",
// Mandatory on Firefox 153: without it,
// navigating to moz-extension:// and running
// chrome-context script both fail with
// "unsupported operation".
//
// It grants the driver FULL CHROME PRIVILEGES
// over this browser. Acceptable only because
// the browser is a throwaway in a CI
// container; never point a session with this
// flag at anything you care about.
"-remote-allow-system-access",
],
prefs,
},
},
},
});
this.sessionId = value.sessionId;
await this.session("POST", "/timeouts", { script: SCRIPT_TIMEOUT_MS });
return value;
}
// Installs the unpacked MV2 build straight from a directory.
// temporary:true bypasses signature checks, so no XPI and no signing
// are involved, and the add-on dies with the profile.
async installAddon(dir) {
return this.session("POST", "/moz/addon/install", {
path: dir,
temporary: true,
});
}
// Classic navigation on purpose. BiDi's browsingContext.navigate
// refuses moz-extension:// URLs outright.
async navigate(url) {
await this.session("POST", "/url", { url });
}
async quit() {
if (this.sessionId) {
await this.session("DELETE", "").catch(() => {});
this.sessionId = null;
}
this.proc.kill("SIGTERM");
}
// ---------------------------------------------------------- scripts
async setContext(context) {
if (this.context === context) return;
await this.session("POST", "/moz/context", { context });
this.context = context;
}
async execute(script, args = []) {
await this.setContext("content");
return this.session("POST", "/execute/sync", { script, args });
}
// Runs in the privileged chrome scope, where Services and Ci exist.
async executeChrome(script, args = []) {
await this.setContext("chrome");
try {
return await this.session("POST", "/execute/sync", {
script,
args,
});
} finally {
await this.setContext("content");
}
}
// ------------------------------------------------------- page waits
// Polls a content-context expression until it returns truthy. Every
// wait in the suite goes through here so a timeout always says which
// condition it was waiting on rather than "timed out".
async waitFor(what, script, args = [], timeout = DEFAULT_WAIT_MS) {
const deadline = Date.now() + timeout;
let last = null;
for (;;) {
try {
const v = await this.execute(script, args);
if (v) return v;
last = null;
} catch (e) {
// A navigation or view swap in flight makes execute
// throw; that is a not-yet, not a failure, until the
// deadline says otherwise.
last = e.message;
}
if (Date.now() >= deadline) {
throw new Error(
"timed out after " +
timeout +
"ms waiting for " +
what +
(last ? " (last error: " + last + ")" : ""),
);
}
await sleep(POLL_INTERVAL_MS);
}
}
// Shown means shown: in the popup a view is switched by toggling a
// "hidden" class, and an element that is present but collapsed is not
// the thing a test means by visible.
async waitVisible(selector, timeout = DEFAULT_WAIT_MS) {
return this.waitFor(
"selector " + selector + " to be visible",
`const el = document.querySelector(arguments[0]);
if (!el) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;`,
[selector],
timeout,
);
}
async isVisible(selector) {
return this.execute(
`const el = document.querySelector(arguments[0]);
if (!el) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;`,
[selector],
);
}
async count(selector) {
return this.execute(
"return document.querySelectorAll(arguments[0]).length;",
[selector],
);
}
async text(selector) {
return this.execute(
`const el = document.querySelector(arguments[0]);
return el ? el.textContent : null;`,
[selector],
);
}
async title() {
return this.session("GET", "/title");
}
// The id of the view element currently on top, which is what a
// failing step needs to report: "the screen did not change" is only
// useful if it says which screen it stayed on.
async currentView() {
return this.execute(
`const views = document.querySelectorAll('[id^="view-"]');
for (const v of views) {
const r = v.getBoundingClientRect();
if (r.width > 0 && r.height > 0) return v.id;
}
return null;`,
);
}
// ----------------------------------------------------- interactions
async findElement(selector) {
const value = await this.session("POST", "/element", {
using: "css selector",
value: selector,
});
const ref = value && value[WEB_ELEMENT_KEY];
if (typeof ref !== "string") {
throw new Error(
"no " +
WEB_ELEMENT_KEY +
" in the element response for " +
selector +
": " +
JSON.stringify(value),
);
}
return ref;
}
// Real WebDriver clicks and real key events rather than in-page
// .click() and value assignment: the popup's handlers are wired to
// events, and synthesising them from inside the page would test the
// harness's idea of the UI instead of the UI.
async click(selector) {
await this.waitVisible(selector);
const id = await this.findElement(selector);
await this.session("POST", "/element/" + id + "/click", {});
}
async fill(selector, value) {
await this.waitVisible(selector);
const id = await this.findElement(selector);
await this.session("POST", "/element/" + id + "/clear", {});
await this.session("POST", "/element/" + id + "/value", {
text: String(value),
});
}
async value(selector) {
return this.execute(
`const el = document.querySelector(arguments[0]);
return el ? el.value : null;`,
[selector],
);
}
}
// ------------------------------------------------------- error capture
// Uncaught errors from extension code, read out of the privileged console
// service.
//
// This is not the obvious mechanism, and the obvious one does not work:
// WebDriver BiDi's log.entryAdded delivers NOTHING for extension pages.
// Verified on Firefox 142 and 153 against a same-session control — a plain
// http:// page yields uncaught errors with stack traces, the
// moz-extension:// popup yields zero events, because the remote agent
// excludes extension browsing contexts from BiDi observation. A harness
// built on Playwright-BiDi or Puppeteer-BiDi therefore sees nothing and
// reports success. Do not "simplify" this back to BiDi.
//
// nsIConsoleService is not per-page: it also carries errors from the
// background page, which BiDi would not have covered even if it worked.
// Background-page capture is verified by probe — a throw at the top of
// src/background/index.js, which kills the background page outright, fails
// the run. Content-script errors should arrive by the same route, but that
// is UNVERIFIED here and must not be claimed: the container runs with
// --network none, so there is no http:// page for a content script to be
// injected into and this suite never exercises one.
//
// Warnings are excluded so the semantics match Playwright's pageerror:
// uncaught errors only.
//
// The read and the clear are ONE chrome script on purpose. Splitting them
// into two round trips leaves a blind window between them in which an
// error is logged into a buffer that is about to be discarded, and is
// destroyed unread rather than deferred to the next drain. That was not
// theoretical: with a separate reset() call, a probe of 100 sequenced
// throws at 20ms spacing lost one of them outright.
const DRAIN_ERRORS_SCRIPT = `
const origin = arguments[0];
const out = [];
for (const raw of Services.console.getMessageArray() || []) {
let e;
try {
e = raw.QueryInterface(Ci.nsIScriptError);
} catch (_) {
continue;
}
if (e.flags & Ci.nsIScriptError.warningFlag) continue;
const src = e.sourceName || "";
if (!src.startsWith(origin)) continue;
out.push({
msg: e.errorMessage,
src: src,
line: e.lineNumber,
cat: e.category,
});
}
Services.console.reset();
return out;
`;
class ConsoleErrors {
constructor(driver, originPrefix) {
this.driver = driver;
this.originPrefix = originPrefix;
}
// Everything logged since the last take, read and cleared atomically
// in a single chrome round trip. Poll-based, so an error is attributed
// to the step that was running when it was drained, not to the moment
// inside that step at which it happened — see the limitation note in
// run.js. An error that arrives mid-drain is not lost — it makes this
// batch or the next one — but the console service ring buffer holds
// only 250 messages, so more than that between two takes evicts the
// oldest unread. A clean run peaks at 4.
async take() {
const found = await this.driver.executeChrome(DRAIN_ERRORS_SCRIPT, [
this.originPrefix,
]);
return found || [];
}
}
// ------------------------------------------------------------- startup
async function waitForDriverReady(base, timeoutMs) {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
const res = await fetch(base + "/status");
if (res.ok) {
const body = await res.json();
if (body && body.value && body.value.ready !== false) return;
}
} catch (_) {
// not listening yet
}
if (Date.now() >= deadline) {
throw new Error(
"geckodriver did not become ready within " + timeoutMs + "ms",
);
}
await sleep(POLL_INTERVAL_MS);
}
}
async function start() {
const port = await freePort();
const proc = spawn(
GECKODRIVER,
["--port", String(port), "--host", "127.0.0.1"],
{ stdio: ["ignore", "inherit", "inherit"] },
);
proc.on("error", (e) => {
console.error("geckodriver failed to spawn: " + e.message);
});
const base = "http://127.0.0.1:" + port;
try {
await waitForDriverReady(base, 30000);
} catch (e) {
proc.kill("SIGKILL");
throw e;
}
return new Driver(proc, base);
}
module.exports = {
ConsoleErrors,
Driver,
EXTENSION_ID,
EXTENSION_ORIGIN,
EXTENSION_UUID,
start,
sleep,
};

288
tests/e2e/firefox/run.js Normal file
View File

@@ -0,0 +1,288 @@
// Firefox end-to-end suite: drives the real popup in a real Firefox with
// the unpacked MV2 build installed as a temporary add-on, and fails the run
// on any uncaught error coming from an extension source.
//
// Run via script/test-e2e-firefox, which builds dist/firefox/ and the pinned
// container. The extension directory is the one argument.
//
// node tests/e2e/firefox/run.js [dist/firefox]
//
// Deliberately not part of script/check, and deliberately not named
// *.test.js: REPO_POLICIES.md caps make test at 20 seconds and a browser
// suite does not fit.
//
// This shares no driver layer with the Chrome suite in tests/e2e/, and the
// UI steps below are written twice on purpose. Chrome runs on Playwright,
// which cannot see extension-page errors in Firefox at all (see the BiDi
// note in driver.js), so the two backends have no common substrate to
// abstract over. Three duplicated steps do not pay for a shim; revisit if
// this suite grows to where they do.
//
// LIMITATION, and the difference from the Chrome suite worth knowing: error
// capture here is POLL-BASED, not event-streamed. The console service is
// drained at each step boundary, so an error is attributed to the step it
// was drained after, never to a moment within that step. What is drained
// covers the whole run from add-on install to the last drain below, which
// lands ~1.5s after the last step returns (500ms settle + 1000ms sleep +
// two drain round trips). That cut-off jitters run to run: three runs of
// throws at fixed offsets reported everything to +1.5s and one of them
// also +1.6s, and past it the browser is torn down first. Inside the
// window there is no race — the drain reads and clears in one chrome
// round trip — but there is a capacity limit: nsIConsoleService keeps
// only the newest 250 messages, so 400 throws in one step report as
// exactly 250. A clean run peaks at 4 of 250, so that is headroom today
// and not a guarantee for a step that logs heavily. The Chrome harness
// receives pageerror events as they happen and can say more. Do not read
// a green Firefox run as the same claim.
"use strict";
const fs = require("fs");
const path = require("path");
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
const PASSWORD = "e2e-harness-password";
// Firefox installs the add-on and starts its background page asynchronously
// after the install call returns. Nothing observable marks the end of that,
// so the popup's own first render is the signal we wait on instead.
const STEP_TIMEOUT_MS = 120000;
const steps = [];
function step(name, fn) {
steps.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(
name + " timed out after " + STEP_TIMEOUT_MS + "ms",
),
),
STEP_TIMEOUT_MS,
);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
// ------------------------------------------------------------- steps
step("popup loads and reaches the welcome view", async (env) => {
const d = env.driver;
await d.navigate(POPUP_URL);
await d.waitVisible("#view-welcome", STEP_TIMEOUT_MS);
const title = await d.title();
assert(title === "AutistMask", "unexpected popup title: " + title);
});
step("wallet creation through the UI reaches the main view", async (env) => {
const d = env.driver;
await d.click("#btn-welcome-add");
await d.waitVisible("#view-add-wallet");
await d.click("#btn-generate-phrase");
await d.waitFor(
"a generated recovery phrase of at least 12 words",
`const el = document.getElementById("wallet-mnemonic");
return !!el && el.value.trim().split(/\\s+/).length >= 12;`,
);
env.phrase = (await d.value("#wallet-mnemonic")).trim();
await d.fill("#add-wallet-password", PASSWORD);
await d.fill("#add-wallet-password-confirm", PASSWORD);
await d.click("#btn-add-wallet-confirm");
// Argon2id under libsodium, for real, so this is the slow one.
await d.waitVisible("#view-main", STEP_TIMEOUT_MS);
assert(
env.phrase.split(/\s+/).length >= 12,
"wallet creation did not yield a recovery phrase",
);
const addrs = await d.count("#wallet-list .btn-addr-info");
assert(addrs > 0, "no addresses rendered in the wallet list");
});
step("add token screen opens from address detail", async (env) => {
const d = env.driver;
if (!(await d.isVisible("#view-address"))) {
await d.waitVisible("#view-main");
await d.click("#wallet-list .btn-addr-info");
}
await d.waitVisible("#view-address");
await d.click("#btn-add-token");
// Reported with the view it actually stayed on: a screen that does
// not change is the symptom a missing import produces, and naming
// the screen is what makes that diagnosable.
try {
await d.waitVisible("#view-add-token");
} catch (e) {
throw new Error(
e.message + "; current view is " + (await d.currentView()),
);
}
const picks = await d.count("#common-token-list .common-token");
assert(picks > 0, "no common-token quick-pick buttons rendered");
});
// ------------------------------------------------------------- runner
function formatError(e) {
return (
e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")"
);
}
async function main() {
// A suite that runs nothing must never report success.
if (steps.length === 0) {
console.log("1..0");
console.log("# FAILED: the Firefox e2e suite registered no steps");
process.exitCode = 1;
return;
}
const extDir = path.resolve(REPO_ROOT, process.argv[2] || "dist/firefox");
if (!fs.existsSync(path.join(extDir, "manifest.json"))) {
console.error(
"e2e-firefox: no unpacked build at " +
extDir +
" — run make build first",
);
process.exitCode = 1;
return;
}
let driver;
try {
driver = await start();
await driver.newSession();
await driver.installAddon(extDir);
} catch (e) {
// A browser we cannot start is a failure of the suite, not an
// absent suite. Never skip and report success.
console.error("e2e-firefox: cannot run the suite: " + e.message);
if (driver) await driver.quit().catch(() => {});
process.exitCode = 1;
return;
}
const errors = new ConsoleErrors(driver, EXTENSION_ORIGIN);
const env = { driver, phrase: null };
console.log("# extension origin: " + EXTENSION_ORIGIN);
console.log("1.." + steps.length);
let failed = 0;
let n = 0;
try {
// Drain, never reset: anything the add-on logged while installing
// and starting its background page has no earlier step to belong
// to, so it is folded into step 1 below. Services.console.reset()
// here would DELETE it instead, and a background page that throws
// at the top of the file — a dead background page — would then
// produce a fully green run.
let installErrors = [];
let installFailure = null;
try {
installErrors = await errors.take();
} catch (e) {
installFailure =
"could not read the console after install: " + e.message;
}
for (const s of steps) {
n += 1;
let failure = null;
try {
await withTimeout(s.fn(env), s.name);
} catch (e) {
failure = e.message;
}
// Let anything the step provoked reach the console service
// before draining it. Without this a failure logged on the
// way out of the step lands in the next step's drain, which
// still fails the run but blames the wrong step.
await sleep(500);
let found = [];
try {
found = await errors.take();
} catch (e) {
failure = failure || "could not read the console: " + e.message;
}
if (n === 1) {
found = installErrors.concat(found);
installErrors = [];
failure = failure || installFailure;
installFailure = null;
}
// Any uncaught error from an extension source fails the step
// that provoked it, whether or not its assertions passed.
if (!failure && found.length > 0) {
failure =
n === 1
? "uncaught extension errors during add-on install, " +
"background startup or this step"
: "uncaught extension errors during this step";
}
if (failure) {
failed += 1;
console.log("not ok " + n + " - " + s.name);
console.log(" " + failure);
for (const e of found) console.log(" " + formatError(e));
} else {
console.log("ok " + n + " - " + s.name);
}
}
// The tail: errors logged after the last step returned cannot be
// blamed on any one step, but they are still reported and they
// still fail the run.
await sleep(1000);
const trailing = await errors.take();
console.log(
"# " +
(steps.length - failed) +
"/" +
steps.length +
" steps passed",
);
if (trailing.length > 0) {
console.log(
"# " +
trailing.length +
" extension error(s) recorded after the last step, not " +
"attributable to any single step:",
);
for (const e of trailing) console.log("# " + formatError(e));
}
if (failed > 0 || trailing.length > 0) {
console.log("# FAILED");
process.exitCode = 1;
}
} finally {
await driver.quit().catch(() => {});
}
}
main().catch((e) => {
console.error("e2e-firefox: " + (e && e.stack ? e.stack : e));
process.exitCode = 1;
});

View File

@@ -53,15 +53,47 @@ function isAllowed(text) {
// 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.
//
// One narrow exception exists, and it is not a mute: expect(). A test that
// drives a failure path on purpose — a refused gas estimate, say — provokes
// the console.error the code is supposed to emit, and that error is the
// behaviour under test rather than an escape. Declaring it consumes exactly
// one matching record and no more, and an expectation nothing matched fails
// its test just as an unexpected error does. So it cannot be used to
// silence anything: it can only assert that a specific error happened.
class ErrorCollector {
constructor() {
this.entries = [];
this.taken = 0;
this.expectations = [];
}
// Declare a console.error this test is about to cause deliberately.
// `label` names it in the failure message if it never arrives.
expect(label, pattern) {
this.expectations.push({ label, pattern, matched: false });
}
// Declared expectations that nothing matched, clearing the list so each
// test starts with none outstanding.
unmatchedExpectations() {
const out = this.expectations
.filter((e) => !e.matched)
.map((e) => e.label);
this.expectations = [];
return out;
}
record(kind, text) {
const line = kind + ": " + String(text).split("\n")[0];
if (isAllowed(line)) return;
const expected = this.expectations.find(
(e) => !e.matched && e.pattern.test(line),
);
if (expected) {
expected.matched = true;
return;
}
this.entries.push(line);
}
@@ -290,13 +322,18 @@ async function createWallet(page) {
return phrase;
}
// Reach the address detail screen from wherever the popup restored to.
// Clicking .address-row does not open it; the [info] button does.
// Reach the address detail screen of the FIRST address of the first wallet,
// from wherever the popup restored to. Clicking .address-row does not open
// it; the [info] button does.
//
// .first() rather than a bare selector because the suite adds a second
// wallet partway through, and every later test would otherwise die in
// Playwright's strict mode rather than on an assertion.
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 page.locator("#wallet-list .btn-addr-info").first().click();
}
await visible(page, "#view-address");
}

View File

@@ -53,18 +53,96 @@ const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z";
// log.errorf(), i.e. console.error, which fails the run on its own.
const ZERO_WORD = "0x" + "0".repeat(64);
function hex(value) {
return "0x" + BigInt(value).toString(16);
}
// A bigint as a 32-byte ABI word.
function word(value) {
return "0x" + BigInt(value).toString(16).padStart(64, "0");
}
// ------------------------------------------------------------ fee fixture
//
// The confirmation screen carries two different numbers for the same
// transaction and may gate on only one of them:
//
// reserve = gasLimit * maxFeePerGas — what a node requires to be
// available for a type-2 transaction, and what the spend gate
// must use.
// estimate = gasLimit * gasPrice — what the transfer is expected to
// actually cost. Display only.
//
// Issue #154 was the gate reading the smaller of the two. ethers derives
// maxFeePerGas as baseFeePerGas * 2 + maxPriorityFeePerGas, so the numbers
// below put the reserve at very nearly twice the estimate. That gap is the
// entire point of these values: it leaves room for a send that an
// estimate-based gate accepts and a reserve-based gate refuses, which is
// what lets the ConfirmTx tests tell the two apart at all. Collapse the gap
// — by dropping baseFeePerGas from the block below, say — and those tests
// go on passing while asserting nothing.
const GAS_LIMIT = 21000n;
const BASE_FEE_WEI = 100000000000n; // 100 gwei
const PRIORITY_FEE_WEI = 1000000000n; // 1 gwei
const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI; // 101 gwei
const MAX_FEE_WEI = BASE_FEE_WEI * 2n + PRIORITY_FEE_WEI; // 201 gwei
const FEE_ESTIMATE_WEI = GAS_LIMIT * GAS_PRICE_WEI; // 0.002121 ETH
const FEE_RESERVE_WEI = GAS_LIMIT * MAX_FEE_WEI; // 0.004221 ETH
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_getCode: "0x",
eth_gasPrice: hex(GAS_PRICE_WEI),
eth_estimateGas: hex(GAS_LIMIT),
eth_getTransactionCount: "0x0",
eth_maxPriorityFeePerGas: "0x3b9aca00",
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
};
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
// to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the
// reserve and the estimate collapse to the same number, and the gate tests
// stop being able to distinguish them.
function latestBlock() {
return {
hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32),
number: hex(STUB_BLOCK_NUMBER),
timestamp: hex(1767326645),
nonce: "0x0000000000000000",
difficulty: "0x0",
gasLimit: "0x1c9c380",
gasUsed: "0xf4240",
miner: STUB_COUNTERPARTY,
extraData: "0x",
baseFeePerGas: hex(BASE_FEE_WEI),
transactions: [],
};
}
// keccak("decimals()")[0:4].
const SELECTOR_DECIMALS = "0x313ce567";
// Every eth_call still answers with a zero word except decimals() on the
// stub token. ethers reads that before it can encode an ERC-20 transfer,
// and a zero there makes parseUnits() reject any fractional amount — so the
// ERC-20 confirmation path would fail its gas estimate for a reason that
// has nothing to do with what is being tested.
function ethCallResult(req) {
const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
return word(STUB_TOKEN.decimals);
}
return ZERO_WORD;
}
function tokenObject() {
return {
address_hash: STUB_TOKEN.address,
@@ -92,6 +170,18 @@ function tokenTransferItems(address) {
];
}
// A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving
// this is what puts an ERC-20 in the send screen's token dropdown, which is
// the only way the confirmation screen's ERC-20 path can be reached.
function tokenBalanceItems() {
return [
{
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() {
@@ -121,7 +211,82 @@ function blockscoutAddress(pathname) {
return m ? m[1] : null;
}
function handleRpc(route, postData, report) {
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// How long a deliberately held reply is allowed to stay held, and how often
// the release flag is re-read while it is.
const HOLD_POLL_MS = 25;
const HOLD_MAX_MS = 30000;
// Hold a gas estimate open for as long as the test asks.
//
// opts.holdGasEstimate is read here rather than captured, so a test flips it
// on the same options object the route was registered with — the same
// pattern as seedTokenTransfer. This is the only way to observe the
// confirmation screen while its estimate is genuinely in flight; sampling
// the screen and hoping to win a race against the network would assert
// nothing on a slow machine.
//
// It never gives up quietly. A hold that outlives the bound is reported like
// any other harness fault, because a "pending" state that stopped being
// pending on its own is a green assertion about the wrong screen.
async function awaitRelease(opts, report) {
const started = Date.now();
while (opts.holdGasEstimate) {
if (Date.now() - started > HOLD_MAX_MS) {
report(
"held gas estimate was never released after " +
HOLD_MAX_MS +
"ms",
);
return;
}
await sleep(HOLD_POLL_MS);
}
}
// One JSON-RPC reply. Methods whose answer depends on a fixture a test has
// set, or on the call itself, are resolved here; every other method is a
// constant in RPC_RESULTS.
function rpcReply(req, opts, report) {
const envelope = { jsonrpc: "2.0", id: req.id };
if (req.method === "eth_getBalance") {
return Object.assign(envelope, {
result: opts.ethBalanceWei || RPC_RESULTS.eth_getBalance,
});
}
if (req.method === "eth_call") {
return Object.assign(envelope, { result: ethCallResult(req) });
}
if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock() });
}
if (req.method === "eth_estimateGas" && opts.failGasEstimate) {
// A refusal the node itself would produce, not a transport error:
// this is the shape the confirmation screen has to turn into
// "Unable to estimate" rather than into a fee of zero.
return Object.assign(envelope, {
error: {
code: -32000,
message: "e2e fixture: gas required exceeds allowance",
},
});
}
const result = RPC_RESULTS[req.method];
if (result === undefined) {
report("unstubbed RPC method: " + req.method);
return Object.assign(envelope, {
error: { code: -32601, message: "unstubbed in e2e harness" },
});
}
return Object.assign(envelope, { result });
}
async function handleRpc(route, postData, opts, report) {
let payload;
try {
payload = JSON.parse(postData || "null");
@@ -132,34 +297,36 @@ function handleRpc(route, postData, report) {
// ethers batches by default, so the body may be an array.
const batch = Array.isArray(payload) ? payload : [payload];
// Anything that is not a JSON-RPC object, or a batch of them, is not
// RPC at all and must be reported like any other unrecognised
// outbound traffic rather than dereferenced. request.postData()
// returns null both for a bodyless POST and for a body Playwright
// cannot decode as UTF-8 (sendBeacon with a Blob, or any binary
// payload), so this is not an empty-string special case: it rejects
// every non-object payload, exactly as the catch above rejects every
// unparseable one.
// Anything that is not a JSON-RPC object, or a NON-EMPTY batch of
// them, is not RPC at all and must be reported like any other
// unrecognised outbound traffic rather than dereferenced.
//
// The length check is not decoration: every() is vacuously true on an
// empty array, so without it a POST with body [] was answered 200 []
// and escaped the guard entirely (issue #187). No real batch is empty,
// so nothing legitimate is caught by it.
//
// Two distinct paths land a non-RPC body here, and neither is an
// empty-string special case. playwright-core's postData() is
// `buffer.toString("utf-8") || null`, so an absent or empty body
// decodes to null, JSON.parse("null") yields null, and the type guard
// below reports it. A binary body is instead decoded LOSSILY into
// mojibake — not null — which is not valid JSON, so the catch above
// reports that one. Both end up reported; only the route differs.
if (
payload === null ||
typeof payload !== "object" ||
batch.length === 0 ||
!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" },
};
if (batch.some((req) => req.method === "eth_estimateGas")) {
await awaitRelease(opts, report);
}
return { jsonrpc: "2.0", id: req.id, result };
});
const replies = batch.map((req) => rpcReply(req, opts, report));
return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]);
}
@@ -199,6 +366,15 @@ function traceEnabled(raw) {
* @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.
* @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20
* holding, which is what makes the token reachable from the send screen.
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
* defaults to zero, which is what every test that predates the funded
* fixture expects.
* @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a
* node-side refusal.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>}
*/
@@ -241,7 +417,7 @@ async function installNetworkStubs(ctx, opts) {
// JSON-RPC endpoint (any host): a POST with a JSON-RPC body.
if (req.method() === "POST") {
return handleRpc(route, req.postData(), report);
return handleRpc(route, req.postData(), opts, report);
}
// Blockscout v2
@@ -259,7 +435,10 @@ async function installNetworkStubs(ctx, opts) {
});
}
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) {
return jsonResponse(route, []);
return jsonResponse(
route,
opts.seedTokenBalance ? tokenBalanceItems() : [],
);
}
if (p.endsWith("/transactions/" + STUB_TX_HASH)) {
return jsonResponse(route, transactionDetails());
@@ -329,6 +508,9 @@ async function installNetworkStubs(ctx, opts) {
module.exports = {
installNetworkStubs,
FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI,
STUB_COUNTERPARTY,
STUB_TOKEN,
STUB_TX_HASH,
};

View File

@@ -9,6 +9,7 @@
"use strict";
const { formatEther } = require("ethers");
const {
PASSWORD,
createWallet,
@@ -18,7 +19,14 @@ const {
pageCompilesWasm,
visible,
} = require("./harness");
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
const {
FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI,
STUB_COUNTERPARTY,
STUB_TOKEN,
STUB_TX_HASH,
} = require("./network");
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
const TEST_TIMEOUT_MS = 120000;
@@ -493,6 +501,702 @@ test("confirming removes the address and returns Home (#162)", async (env) => {
);
});
// ------------------------------------------------ dust threshold (#233)
// The popup size README documents the UI as designed for. Pages in this
// context otherwise get Playwright's 1280x720 default, at which the flash
// line has room for any plausible message and never wraps — measuring
// there would pass for every string and prove nothing.
const POPUP_VIEWPORT = { width: 360, height: 600 };
// Everything below the flash line that must not move when it fills, plus
// the height of the line itself. Runs in the page.
//
// Positions are in document coordinates, not viewport coordinates:
// tabbing out of the field to fire "change" scrolls the popup, and a
// getBoundingClientRect().top read across that scroll reports a thousand
// pixels of movement that is the scroll, not a layout shift.
function measureFlashLine() {
const top = (id) =>
document.getElementById(id).getBoundingClientRect().top +
window.scrollY;
return {
text: document.getElementById("flash-msg").textContent,
flashHeight: document
.getElementById("flash-msg")
.getBoundingClientRect().height,
settingsTop: top("view-settings"),
fieldTop: top("settings-dust-threshold"),
};
}
// Polling one evaluate() rather than waitForFunction() plus a second
// round trip to measure: showFlash() clears the line again after 2s, and
// measuring in a separate call can land after that and read an empty
// line — which would pass however long the message is. Here the text
// check and the geometry come from the same page task, so what is
// measured is always the filled line. Missing the 2s window entirely
// throws; it cannot go green.
async function waitForFilledFlashLine(page) {
const deadline = Date.now() + 15000;
for (;;) {
const m = await page.evaluate(measureFlashLine);
if (m.text.length > 0) return m;
if (Date.now() > deadline) {
throw new Error("the flash line never filled");
}
await sleep(25);
}
}
// README, No Layout Shift: the rejection message goes into #flash-msg,
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs. Reserving
// the space is not enough on its own — a message too long for one line
// wraps and pushes everything below it down anyway, which is what the
// first version of this change shipped: 75 characters, 32px, the settings
// view and the threshold field 12px lower than with an empty line.
//
// So this measures rather than inspects markup. It is the only assertion
// in the repo that can see the wording grow: the unit suite runs on the
// node environment with no layout engine, where every height is zero (see
// the note in tests/dustThreshold.test.js). Lengthen
// DUST_THRESHOLD_MESSAGE past one line and this test goes red.
test("a rejected dust threshold shifts no layout (#233)", async (env) => {
const page = await openPopup(env.ctx, env.popupUrl);
try {
await page.setViewportSize(POPUP_VIEWPORT);
await openSettings(page);
const before = await page.evaluate(measureFlashLine);
assert(
before.text === "",
"the flash line was not empty at the baseline measurement: " +
JSON.stringify(before.text),
);
// "change" fires on blur, not on typing, so fill() alone is not
// enough — it only dispatches "input".
await page.fill("#settings-dust-threshold", "1.5");
await page.locator("#settings-dust-threshold").press("Tab");
const after = await waitForFilledFlashLine(page);
// Printed pass or fail: the numbers are the evidence, and a
// silent assertion would leave the reader taking this on trust.
console.log(
"# dust threshold flash: " +
after.text.length +
" chars, line height " +
before.flashHeight +
" -> " +
after.flashHeight +
", view-settings top " +
before.settingsTop +
" -> " +
after.settingsTop +
", field top " +
before.fieldTop +
" -> " +
after.fieldTop,
);
assert(
after.text === DUST_THRESHOLD_MESSAGE,
"the field flashed something other than DUST_THRESHOLD_MESSAGE: " +
JSON.stringify(after.text),
);
assert(
after.flashHeight === before.flashHeight,
"the message does not fit the reserved line: " +
before.flashHeight +
"px empty vs " +
after.flashHeight +
"px with the message. Shorten DUST_THRESHOLD_MESSAGE",
);
assert(
after.settingsTop === before.settingsTop,
"the settings view moved " +
(after.settingsTop - before.settingsTop) +
"px when the message appeared",
);
assert(
after.fieldTop === before.fieldTop,
"the dust threshold field moved " +
(after.fieldTop - before.fieldTop) +
"px when the message appeared",
);
} finally {
await page.close();
}
});
// --------------------------------------------- confirmation screen (#238)
//
// The screen that decides what gets signed. The arithmetic underneath it
// lives in src/shared/txValidation.js and is unit tested there; what these
// tests cover is the wiring — which number reaches the gate, when the gate
// re-runs, what the fee block renders, and whether Send is enabled.
//
// The load-bearing one is "gates on the fee RESERVE": the confirmation
// screen quotes the ESTIMATE and gates on the RESERVE, and issue #154 was
// the gate reading the quoted number. Every other assertion here would
// survive that mutation, so the funded and gap sends are deliberately sized
// on opposite sides of the reserve while sitting on the same side of the
// estimate.
// The balance the funded fixture serves, and the amounts sent against it.
const FUNDED_ETH_WEI = 10n ** 18n;
const FUNDED_ETH_TEXT = "1.0";
const COMFORTABLE_AMOUNT = "0.1";
const OVER_BALANCE_AMOUNT = "2.0";
// A send the balance covers to the wei once the ESTIMATE is added, and does
// not cover once the RESERVE is. Sending this is allowed by a gate reading
// the estimate and refused by a gate reading the reserve, which is the whole
// discrimination these tests exist to make.
const GAP_AMOUNT = formatEther(FUNDED_ETH_WEI - FEE_ESTIMATE_WEI);
// The ERC-20 side. The ETH balance is set to exactly the estimate for the
// fee test: it covers the expected cost to the wei and falls short of the
// reserve, so the same swap flips this assertion too — through a different
// balance and a different message than the ETH path uses.
const TOKEN_BALANCE_TEXT = "1.5";
const TOKEN_AMOUNT = "0.25";
const OVER_TOKEN_AMOUNT = "9.0";
const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI;
function toHexWei(wei) {
return "0x" + wei.toString(16);
}
// A fee in wei as the confirmation screen writes it. Deliberately a second
// implementation of formatFeeEth() from src/popup/views/confirmTx.js rather
// than an import of it: that module pulls in the whole popup and cannot be
// required outside a browser, and asserting against an independent rendering
// is stronger than asserting a function equals itself.
function feeEth(wei) {
const parts = formatEther(wei).split(".");
const dec =
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
return parts[0] + "." + dec + " ETH";
}
// What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, and how tall the whole view is.
async function confirmState(page) {
return page.evaluate(() => {
const el = (id) => document.getElementById(id);
// Both mechanisms matter. The two fee messages are dropped with
// display:none for the transaction type they cannot apply to, and
// shown or hidden with visibility for the one they can.
const shown = (id) => {
const cs = getComputedStyle(el(id));
return cs.display !== "none" && cs.visibility === "visible";
};
return {
height: el("view-confirm-tx").getBoundingClientRect().height,
type: el("confirm-type").textContent.trim(),
balance: el("confirm-balance").textContent.trim(),
fee: el("confirm-fee-amount").textContent.trim(),
reserve: el("confirm-fee-reserve").textContent.trim(),
reserveShown: shown("confirm-fee-reserve"),
errors: shown("confirm-errors")
? el("confirm-errors").textContent.trim()
: "",
amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-error"),
sendDisabled: el("btn-confirm-send").disabled,
};
});
}
async function waitForEstimate(page) {
await page.waitForFunction(
() =>
document.getElementById("confirm-fee-amount").textContent.trim() !==
"Estimating...",
null,
{ timeout: 60000 },
);
}
async function backToAddress(page) {
if (await page.isVisible("#view-confirm-tx")) {
await page.click("#btn-confirm-back");
await visible(page, "#view-send");
}
if (await page.isVisible("#view-send")) {
await page.click("#btn-send-back");
}
await openAddressDetail(page);
}
// Drive the popup to the confirmation screen for one send.
//
// It waits for the send screen to be showing `balance` before filling
// anything in. That figure is the exact number the spend gate compares
// against, so waiting for it — rather than for a refresh to have probably
// landed — is what keeps every assertion below deterministic after a
// fixture change.
async function goToConfirm(page, { token, balance, amount }) {
await backToAddress(page);
await page.click("#btn-send");
await visible(page, "#view-send");
await page.selectOption("#send-token", token);
await page.waitForFunction(
(want) =>
document.getElementById("send-balance").textContent.trim() === want,
"Current balance: " + balance,
{ timeout: 60000 },
);
await page.fill("#send-to", STUB_COUNTERPARTY);
await page.fill("#send-amount", amount);
await page.click("#btn-send-review");
await visible(page, "#view-confirm-tx");
}
// A balance as the main view renders it: balanceLinesForAddress() writes
// every quantity with four decimal places.
function quantity(wei) {
return parseFloat(formatEther(wei)).toFixed(4);
}
// Wait on the main view until a changed balance fixture has been picked up.
//
// Deliberately not a reload: the popup re-refreshes on a 10-second timer by
// itself, and reloading aborts whatever fetch the home screen has open at
// that instant, which the extension reports through log.errorf and the
// harness — correctly — fails the run on.
//
// It also deliberately settles on MAIN rather than on the address screen.
// The address screen builds the send screen's token dropdown once, from the
// balances it holds at that moment, and nothing rebuilds it when a later
// refresh arrives, so entering it early leaves a dropdown with no token in
// it and the ERC-20 path unreachable.
async function settleOnMain(env, { ethWei, expectToken }) {
await backToAddress(env.page);
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await env.page.waitForFunction(
(want) =>
document.getElementById("wallet-list").textContent.includes(want),
quantity(ethWei),
{ timeout: 60000 },
);
if (expectToken) {
await visible(
env.page,
'#wallet-list [data-token="' + STUB_TOKEN.address + '"]',
60000,
);
}
}
test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true;
await settleOnMain(env, {
ethWei: FUNDED_ETH_WEI,
expectToken: true,
});
env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
const st = await confirmState(env.page);
env.ethPendingHeight = st.height;
console.log("# confirm-tx ETH view height: " + st.height + "px");
assert(
st.type === "Native ETH transfer",
"unexpected transaction type: " + JSON.stringify(st.type),
);
assert(
st.fee === "Estimating...",
"the fee line is not showing the pending placeholder: " +
JSON.stringify(st.fee),
);
assert(!st.reserveShown, "the reserve line is shown before any estimate");
assert(
st.sendDisabled,
"Send is enabled while the fee estimate is still in flight",
);
assert(
!st.feeUnknownError,
"the estimate-failed message is shown for an estimate that is merely pending",
);
assert(
!st.amountFeeError && !st.gasError && st.errors === "",
"a balance message is shown before the fee is known",
);
});
test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238)", async (env) => {
env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page);
const st = await confirmState(env.page);
assert(
st.balance === FUNDED_ETH_TEXT + " ETH",
"the confirmation screen shows the wrong balance: " +
JSON.stringify(st.balance),
);
assert(
st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
"the fee line does not quote the estimate: " + JSON.stringify(st.fee),
);
assert(st.reserveShown, "the reserve line is not shown once the fee lands");
assert(
st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved",
"the reserve line does not quote the reserve: " +
JSON.stringify(st.reserve),
);
assert(
!st.sendDisabled,
"Send is disabled for a comfortably funded transfer",
);
assert(
st.errors === "" &&
!st.amountFeeError &&
!st.gasError &&
!st.feeUnknownError,
"a balance message is shown for a comfortably funded transfer",
);
assert(
st.height === env.ethPendingHeight,
"the view changed height when the estimate landed: " +
env.ethPendingHeight +
"px -> " +
st.height +
"px",
);
});
// The one that closes the hole. Everything else here survives a gate that
// reads the displayed estimate instead of the reserve; this does not.
test("ConfirmTx gates on the fee RESERVE, not the displayed estimate (#238)", async (env) => {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: GAP_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
// Printed on every run, pass or fail: the two fee numbers and the gate's
// decision side by side is the measurement this test is really making.
console.log(
"# gate probe: balance=" +
FUNDED_ETH_TEXT +
" ETH amount=" +
GAP_AMOUNT +
" estimate=" +
feeEth(FEE_ESTIMATE_WEI) +
" reserve=" +
feeEth(FEE_RESERVE_WEI) +
" sendDisabled=" +
st.sendDisabled +
" amountFeeError=" +
st.amountFeeError,
);
assert(
st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
"the screen is not quoting the estimate, so this send is not in the gap: " +
JSON.stringify(st.fee),
);
assert(
st.sendDisabled,
"Send is ENABLED for a transfer the fee RESERVE does not cover — the " +
"spend gate is reading the displayed estimate, which is issue #154",
);
assert(
st.amountFeeError,
"the amount-plus-fee message is not shown for a send the reserve does not cover",
);
assert(
st.height === env.ethPendingHeight,
"the over-budget state is a different height than the pending state: " +
env.ethPendingHeight +
"px -> " +
st.height +
"px",
);
});
test("ConfirmTx refuses a send that exceeds the balance outright (#238)", async (env) => {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: OVER_BALANCE_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
const want =
"Insufficient balance. You have " +
FUNDED_ETH_TEXT +
" ETH but are trying to send " +
OVER_BALANCE_AMOUNT +
" ETH.";
assert(
st.errors === want,
"wrong over-balance message: " + JSON.stringify(st.errors),
);
assert(st.sendDisabled, "Send is enabled for a send that exceeds balance");
assert(
!st.amountFeeError,
"the amount-plus-fee message is shown for an amount that alone exceeds the balance",
);
});
test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => {
// The refusal is logged by confirmTx via log.errorf, i.e. console.error,
// which fails a test on its own. Declaring it here consumes exactly that
// one record — and fails this test if it never arrives.
env.errors.expect(
"confirmTx logging the failed gas estimate",
/gas estimation failed/,
);
env.routeOpts.failGasEstimate = true;
env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
const pending = await confirmState(env.page);
assert(
pending.fee === "Estimating..." && pending.sendDisabled,
"the screen is not in the pending state before the estimate fails",
);
env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page);
const st = await confirmState(env.page);
env.routeOpts.failGasEstimate = false;
assert(
st.fee === "Unable to estimate",
"the fee line does not report the failure: " + JSON.stringify(st.fee),
);
assert(
!st.reserveShown,
"the reserve line is shown after a failed estimate",
);
assert(
st.feeUnknownError,
"the estimate-failed message is not shown after a failed estimate",
);
assert(
!st.amountFeeError && !st.gasError && st.errors === "",
"a balance message is shown for an estimate that simply failed",
);
assert(
st.sendDisabled,
"Send is enabled with no usable fee estimate — an unknown fee is being treated as zero",
);
assert(
st.height === pending.height,
"the view changed height when the estimate failed: " +
pending.height +
"px -> " +
st.height +
"px",
);
});
test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (env) => {
env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
const pending = await confirmState(env.page);
env.erc20PendingHeight = pending.height;
console.log("# confirm-tx ERC-20 view height: " + pending.height + "px");
assert(
pending.type === "ERC-20 token transfer (" + STUB_TOKEN.symbol + ")",
"unexpected transaction type: " + JSON.stringify(pending.type),
);
assert(
pending.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
"the ERC-20 screen shows the wrong balance: " +
JSON.stringify(pending.balance),
);
assert(
pending.fee === "Estimating..." && pending.sendDisabled,
"the ERC-20 screen does not block sending while its estimate is pending",
);
env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page);
const st = await confirmState(env.page);
assert(
st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
"the ERC-20 fee line does not quote the estimate: " +
JSON.stringify(st.fee),
);
assert(
st.reserveShown &&
st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved",
"the ERC-20 fee block does not quote the reserve: " +
JSON.stringify(st.reserve),
);
assert(!st.sendDisabled, "Send is disabled for a funded ERC-20 transfer");
assert(
st.height === pending.height,
"the ERC-20 view changed height when the estimate landed: " +
pending.height +
"px -> " +
st.height +
"px",
);
});
test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", async (env) => {
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: OVER_TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
const want =
"Insufficient " +
STUB_TOKEN.symbol +
" balance. You have " +
TOKEN_BALANCE_TEXT +
" " +
STUB_TOKEN.symbol +
" but are trying to send " +
OVER_TOKEN_AMOUNT +
" " +
STUB_TOKEN.symbol +
".";
assert(
st.errors === want,
"wrong over-token-balance message: " + JSON.stringify(st.errors),
);
assert(
st.sendDisabled,
"Send is enabled for an ERC-20 transfer that exceeds the token balance",
);
assert(
!st.gasError,
"the ERC-20 gas message is shown for an ETH balance that covers the fee",
);
});
// The same swap, through the other balance and the other message: here the
// token balance is ample and it is the ETH balance that must cover the fee.
// It is set to exactly the estimate, so an estimate-reading gate lets this
// through and the reserve-reading gate refuses it.
test("ConfirmTx gates the ERC-20 fee on the RESERVE, with the ERC-20 message (#238)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FEE_ONLY_ETH_WEI);
await settleOnMain(env, {
ethWei: FEE_ONLY_ETH_WEI,
expectToken: true,
});
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
console.log(
"# erc-20 gate probe: ethBalance=" +
formatEther(FEE_ONLY_ETH_WEI) +
" estimate=" +
feeEth(FEE_ESTIMATE_WEI) +
" reserve=" +
feeEth(FEE_RESERVE_WEI) +
" sendDisabled=" +
st.sendDisabled +
" gasError=" +
st.gasError,
);
assert(
st.sendDisabled,
"Send is ENABLED for an ERC-20 transfer whose fee RESERVE exceeds the " +
"ETH balance — the spend gate is reading the displayed estimate (#154)",
);
assert(
st.gasError,
"the ERC-20 network-fee message is not shown when the ETH balance cannot cover the reserve",
);
assert(
!st.amountFeeError,
"the native-ETH over-budget message is shown on an ERC-20 transfer",
);
assert(
st.errors === "",
"a token-balance message is shown for a transfer the token balance covers: " +
JSON.stringify(st.errors),
);
assert(
st.height === env.erc20PendingHeight,
"the ERC-20 fee-error state is a different height than its pending state: " +
env.erc20PendingHeight +
"px -> " +
st.height +
"px",
);
});
test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee problem (#238)", async (env) => {
env.errors.expect(
"confirmTx logging the failed ERC-20 gas estimate",
/gas estimation failed/,
);
env.routeOpts.failGasEstimate = true;
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
env.routeOpts.failGasEstimate = false;
assert(
st.fee === "Unable to estimate",
"the ERC-20 fee line does not report the failure: " +
JSON.stringify(st.fee),
);
assert(
st.feeUnknownError,
"the estimate-failed message is not shown on the ERC-20 path",
);
assert(
!st.gasError,
"the ERC-20 network-fee message is shown for a fee that is unknown rather than unaffordable",
);
assert(
st.sendDisabled,
"Send is enabled on the ERC-20 path with no usable fee estimate",
);
assert(
st.height === env.erc20PendingHeight,
"the ERC-20 estimate-failed state is a different height than its pending state: " +
env.erc20PendingHeight +
"px -> " +
st.height +
"px",
);
});
// ---------------------------------------------------------------- runner
async function main() {
@@ -508,7 +1212,15 @@ async function main() {
return;
}
const routeOpts = { seedTokenTransfer: false };
// Every fixture switch the suite can flip, declared in one place so the
// starting state of a run is readable without hunting through tests.
const routeOpts = {
seedTokenTransfer: false,
seedTokenBalance: false,
ethBalanceWei: null,
failGasEstimate: false,
holdGasEstimate: false,
};
let session;
try {
@@ -530,9 +1242,16 @@ async function main() {
popupUrl: session.popupUrl,
routeOpts,
page: null,
// The error collector, so a test that drives a failure path on
// purpose can declare the console.error it is about to provoke.
errors: session.errors,
// The recovery phrase of the wallet created in test 2, so later
// tests can assert on the real secret rather than its shape.
phrase: null,
// Confirmation-screen heights, measured in the pending state and
// compared against every later state of the same screen.
ethPendingHeight: null,
erc20PendingHeight: null,
};
// Attribution of collected errors is total. session.errors has no
@@ -579,6 +1298,16 @@ async function main() {
failure = "uncaught browser errors during this test";
}
// A test that declared an error it meant to provoke and did not
// provoke it asserted nothing. Failing here is what keeps expect()
// from being usable as a mute.
const unmatched = session.errors.unmatchedExpectations();
if (!failure && unmatched.length > 0) {
failure =
"expected browser error(s) that never arrived: " +
unmatched.join("; ");
}
if (failure) {
failed += 1;
console.log("not ok " + n + " - " + t.name);