Compare commits
4 Commits
0434163ce8
...
52fb765232
| Author | SHA1 | Date | |
|---|---|---|---|
| 52fb765232 | |||
| 18b47cd579 | |||
| 5af89a1b63 | |||
| c6a1f97247 |
7
Makefile
7
Makefile
@@ -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
127
README.md
@@ -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
54
TODO.md
@@ -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
63
script/test-e2e-firefox
Executable 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 "$@"
|
||||
42
src/popup/dustThreshold.js
Normal file
42
src/popup/dustThreshold.js
Normal 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 };
|
||||
@@ -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
243
tests/dustThreshold.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
51
tests/e2e/firefox/Dockerfile
Normal file
51
tests/e2e/firefox/Dockerfile
Normal 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
464
tests/e2e/firefox/driver.js
Normal 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
288
tests/e2e/firefox/run.js
Normal 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;
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
}
|
||||
return { jsonrpc: "2.0", id: req.id, result };
|
||||
});
|
||||
if (batch.some((req) => req.method === "eth_estimateGas")) {
|
||||
await awaitRelease(opts, report);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
733
tests/e2e/run.js
733
tests/e2e/run.js
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user