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/
|
# Standard targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
@@ -16,10 +16,13 @@ install:
|
|||||||
test:
|
test:
|
||||||
@script/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:
|
test-e2e:
|
||||||
@script/test-e2e
|
@script/test-e2e
|
||||||
|
|
||||||
|
test-e2e-firefox:
|
||||||
|
@script/test-e2e-firefox
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
|
|||||||
127
README.md
127
README.md
@@ -83,7 +83,10 @@ provide:
|
|||||||
git pre-commit hook
|
git pre-commit hook
|
||||||
- `script/projectname` — print the project name (used for the Docker image tag)
|
- `script/projectname` — print the project name (used for the Docker image tag)
|
||||||
- `script/test` — run the test suite (jest)
|
- `script/test` — run the test suite (jest)
|
||||||
- `script/test-e2e` — run the browser end-to-end suite (docker required; see
|
- `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))
|
[End-to-End Tests](#end-to-end-tests))
|
||||||
- `script/lint` — run the linter
|
- `script/lint` — run the linter
|
||||||
- `script/fmt` — format all files (writes)
|
- `script/fmt` — format all files (writes)
|
||||||
@@ -123,6 +126,14 @@ The Makefile shims to those. It also carries a few targets that have no
|
|||||||
|
|
||||||
## End-to-End Tests
|
## 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
|
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
|
||||||
Chrome**, loaded as an unpacked MV3 extension inside a pinned
|
Chrome**, loaded as an unpacked MV3 extension inside a pinned
|
||||||
`mcr.microsoft.com/playwright` container (pinned by digest in `script/test-e2e`;
|
`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
|
offline; unrecognised outbound requests are reported as failures rather than
|
||||||
silently allowed.
|
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
|
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
|
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
|
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,
|
`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.
|
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`.
|
### Firefox (`make test-e2e-firefox`)
|
||||||
`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
|
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
|
||||||
either. It is also not wired into the Gitea workflow yet — docker-in-docker in
|
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
|
||||||
CI is a separate question. Run it locally before changing anything under
|
It covers popup load, wallet creation through the UI, and the Add Token screen.
|
||||||
`src/popup/views/`.
|
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
|
## Rationale
|
||||||
|
|
||||||
@@ -857,7 +963,12 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
|||||||
- "Hide fake tokens impersonating a known symbol" checkbox
|
- "Hide fake tokens impersonating a known symbol" checkbox
|
||||||
- "Hide tokens with fewer than 1,000 holders" checkbox
|
- "Hide tokens with fewer than 1,000 holders" checkbox
|
||||||
- "Hide transactions from detected fraud contracts" 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
|
- Allowed Sites: list with remove buttons
|
||||||
- Denied Sites: list with remove buttons
|
- Denied Sites: list with remove buttons
|
||||||
- About: project link, license, author, version, release date, and the
|
- 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
|
The backlog lives on the
|
||||||
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
|
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
|
||||||
authoritative; this file does not duplicate it. Full policy file set present. A
|
authoritative; this file does not duplicate it. Full policy file set present.
|
||||||
real-browser end-to-end suite (`make test-e2e`) now sits alongside `make check`,
|
Real-browser end-to-end suites (`make test-e2e` for Chrome,
|
||||||
which cannot see a runtime `ReferenceError` in a popup view.
|
`make test-e2e-firefox` for Firefox) now sit alongside `make check`, which
|
||||||
|
cannot see a runtime `ReferenceError` in a popup view.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
@@ -44,6 +45,45 @@ undefined identifiers, which is how
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
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
|
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
|
- 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
|
input validation) before any 1.0rc tag. Individual filed issues are parts of
|
||||||
it, but the review is broader than any of them.
|
it, but the review is broader than any of them.
|
||||||
- Decide whether docker-in-docker makes `make test-e2e` runnable in the Gitea
|
- Decide whether docker-in-docker makes `make test-e2e` and
|
||||||
workflow. Extending the suite itself is tracked as
|
`make test-e2e-firefox` runnable in the Gitea workflow. Extending the Chrome
|
||||||
[#183](https://git.eeqj.de/sneak/AutistMask/issues/183) and
|
suite itself is tracked as
|
||||||
[#184](https://git.eeqj.de/sneak/AutistMask/issues/184).
|
[#183](https://git.eeqj.de/sneak/AutistMask/issues/183).
|
||||||
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
|
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
|
||||||
land.
|
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,
|
pushCurrentView,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { applyTheme } = require("../theme");
|
const { applyTheme } = require("../theme");
|
||||||
|
const {
|
||||||
|
DUST_THRESHOLD_MESSAGE,
|
||||||
|
parseDustThresholdGwei,
|
||||||
|
} = require("../dustThreshold");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
|
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
|
||||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||||
@@ -329,13 +333,14 @@ function init(ctx) {
|
|||||||
|
|
||||||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||||||
$("settings-dust-threshold").addEventListener("change", async () => {
|
$("settings-dust-threshold").addEventListener("change", async () => {
|
||||||
const raw = $("settings-dust-threshold").value.trim();
|
const val = parseDustThresholdGwei($("settings-dust-threshold").value);
|
||||||
const val = Number(raw);
|
// Rejected input is never coerced. The field is put back to the
|
||||||
// 0 is accepted and means "hide nothing". Empty, negative,
|
// stored threshold so it never shows a value the wallet is not
|
||||||
// fractional and non-numeric input is rejected outright rather than
|
// using, and the message says what the field wants so the snap-back
|
||||||
// coerced, and the field is put back to the stored threshold so it
|
// is explained rather than silent.
|
||||||
// never shows a value the wallet is not using.
|
if (val === null) {
|
||||||
if (raw !== "" && Number.isInteger(val) && val >= 0) {
|
showFlash(DUST_THRESHOLD_MESSAGE);
|
||||||
|
} else {
|
||||||
state.dustThresholdGwei = val;
|
state.dustThresholdGwei = val;
|
||||||
await saveState();
|
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
|
// 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
|
// the context — so there is no post-teardown phase to collect, and this
|
||||||
// class deliberately offers no mechanism pretending to cover one.
|
// 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 {
|
class ErrorCollector {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.entries = [];
|
this.entries = [];
|
||||||
this.taken = 0;
|
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) {
|
record(kind, text) {
|
||||||
const line = kind + ": " + String(text).split("\n")[0];
|
const line = kind + ": " + String(text).split("\n")[0];
|
||||||
if (isAllowed(line)) return;
|
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);
|
this.entries.push(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,13 +322,18 @@ async function createWallet(page) {
|
|||||||
return phrase;
|
return phrase;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reach the address detail screen from wherever the popup restored to.
|
// Reach the address detail screen of the FIRST address of the first wallet,
|
||||||
// Clicking .address-row does not open it; the [info] button does.
|
// 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) {
|
async function openAddressDetail(page) {
|
||||||
const onAddress = await page.isVisible("#view-address");
|
const onAddress = await page.isVisible("#view-address");
|
||||||
if (!onAddress) {
|
if (!onAddress) {
|
||||||
await visible(page, "#view-main");
|
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");
|
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.
|
// log.errorf(), i.e. console.error, which fails the run on its own.
|
||||||
const ZERO_WORD = "0x" + "0".repeat(64);
|
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 = {
|
const RPC_RESULTS = {
|
||||||
eth_chainId: "0x1",
|
eth_chainId: "0x1",
|
||||||
net_version: "1",
|
net_version: "1",
|
||||||
eth_blockNumber: "0x1406f40",
|
eth_blockNumber: "0x1406f40",
|
||||||
eth_getBalance: "0x0",
|
eth_getBalance: "0x0",
|
||||||
eth_call: ZERO_WORD,
|
eth_call: ZERO_WORD,
|
||||||
eth_gasPrice: "0x3b9aca00",
|
eth_getCode: "0x",
|
||||||
eth_estimateGas: "0x5208",
|
eth_gasPrice: hex(GAS_PRICE_WEI),
|
||||||
|
eth_estimateGas: hex(GAS_LIMIT),
|
||||||
eth_getTransactionCount: "0x0",
|
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() {
|
function tokenObject() {
|
||||||
return {
|
return {
|
||||||
address_hash: STUB_TOKEN.address,
|
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
|
// Full details for STUB_TX_HASH. raw_input is "0x" so the calldata
|
||||||
// decoder short-circuits; the on-chain detail fields still populate.
|
// decoder short-circuits; the on-chain detail fields still populate.
|
||||||
function transactionDetails() {
|
function transactionDetails() {
|
||||||
@@ -121,7 +211,82 @@ function blockscoutAddress(pathname) {
|
|||||||
return m ? m[1] : null;
|
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;
|
let payload;
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(postData || "null");
|
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.
|
// ethers batches by default, so the body may be an array.
|
||||||
const batch = Array.isArray(payload) ? payload : [payload];
|
const batch = Array.isArray(payload) ? payload : [payload];
|
||||||
|
|
||||||
// Anything that is not a JSON-RPC object, or a batch of them, is not
|
// Anything that is not a JSON-RPC object, or a NON-EMPTY batch of
|
||||||
// RPC at all and must be reported like any other unrecognised
|
// them, is not RPC at all and must be reported like any other
|
||||||
// outbound traffic rather than dereferenced. request.postData()
|
// unrecognised outbound traffic rather than dereferenced.
|
||||||
// returns null both for a bodyless POST and for a body Playwright
|
//
|
||||||
// cannot decode as UTF-8 (sendBeacon with a Blob, or any binary
|
// The length check is not decoration: every() is vacuously true on an
|
||||||
// payload), so this is not an empty-string special case: it rejects
|
// empty array, so without it a POST with body [] was answered 200 []
|
||||||
// every non-object payload, exactly as the catch above rejects every
|
// and escaped the guard entirely (issue #187). No real batch is empty,
|
||||||
// unparseable one.
|
// 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 (
|
if (
|
||||||
payload === null ||
|
payload === null ||
|
||||||
typeof payload !== "object" ||
|
typeof payload !== "object" ||
|
||||||
|
batch.length === 0 ||
|
||||||
!batch.every((req) => req !== null && typeof req === "object")
|
!batch.every((req) => req !== null && typeof req === "object")
|
||||||
) {
|
) {
|
||||||
report("unstubbed request: POST " + route.request().url());
|
report("unstubbed request: POST " + route.request().url());
|
||||||
return route.abort();
|
return route.abort();
|
||||||
}
|
}
|
||||||
const replies = batch.map((req) => {
|
if (batch.some((req) => req.method === "eth_estimateGas")) {
|
||||||
const result = RPC_RESULTS[req.method];
|
await awaitRelease(opts, report);
|
||||||
if (result === undefined) {
|
}
|
||||||
report("unstubbed RPC method: " + req.method);
|
|
||||||
return {
|
const replies = batch.map((req) => rpcReply(req, opts, report));
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: req.id,
|
|
||||||
error: { code: -32601, message: "unstubbed in e2e harness" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { jsonrpc: "2.0", id: req.id, result };
|
|
||||||
});
|
|
||||||
return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]);
|
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
|
* @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20
|
||||||
* transfer. Read at request time, so a test can flip it on the same
|
* transfer. Read at request time, so a test can flip it on the same
|
||||||
* options object without re-registering the route.
|
* 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) =>
|
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
|
||||||
* Promise<string|null>}>}
|
* Promise<string|null>}>}
|
||||||
*/
|
*/
|
||||||
@@ -241,7 +417,7 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
|
|
||||||
// JSON-RPC endpoint (any host): a POST with a JSON-RPC body.
|
// JSON-RPC endpoint (any host): a POST with a JSON-RPC body.
|
||||||
if (req.method() === "POST") {
|
if (req.method() === "POST") {
|
||||||
return handleRpc(route, req.postData(), report);
|
return handleRpc(route, req.postData(), opts, report);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blockscout v2
|
// Blockscout v2
|
||||||
@@ -259,7 +435,10 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) {
|
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)) {
|
if (p.endsWith("/transactions/" + STUB_TX_HASH)) {
|
||||||
return jsonResponse(route, transactionDetails());
|
return jsonResponse(route, transactionDetails());
|
||||||
@@ -329,6 +508,9 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
installNetworkStubs,
|
installNetworkStubs,
|
||||||
|
FEE_ESTIMATE_WEI,
|
||||||
|
FEE_RESERVE_WEI,
|
||||||
|
STUB_COUNTERPARTY,
|
||||||
STUB_TOKEN,
|
STUB_TOKEN,
|
||||||
STUB_TX_HASH,
|
STUB_TX_HASH,
|
||||||
};
|
};
|
||||||
|
|||||||
733
tests/e2e/run.js
733
tests/e2e/run.js
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
const { formatEther } = require("ethers");
|
||||||
const {
|
const {
|
||||||
PASSWORD,
|
PASSWORD,
|
||||||
createWallet,
|
createWallet,
|
||||||
@@ -18,7 +19,14 @@ const {
|
|||||||
pageCompilesWasm,
|
pageCompilesWasm,
|
||||||
visible,
|
visible,
|
||||||
} = require("./harness");
|
} = 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;
|
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
|
// ---------------------------------------------------------------- runner
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -508,7 +1212,15 @@ async function main() {
|
|||||||
return;
|
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;
|
let session;
|
||||||
try {
|
try {
|
||||||
@@ -530,9 +1242,16 @@ async function main() {
|
|||||||
popupUrl: session.popupUrl,
|
popupUrl: session.popupUrl,
|
||||||
routeOpts,
|
routeOpts,
|
||||||
page: null,
|
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
|
// The recovery phrase of the wallet created in test 2, so later
|
||||||
// tests can assert on the real secret rather than its shape.
|
// tests can assert on the real secret rather than its shape.
|
||||||
phrase: null,
|
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
|
// Attribution of collected errors is total. session.errors has no
|
||||||
@@ -579,6 +1298,16 @@ async function main() {
|
|||||||
failure = "uncaught browser errors during this test";
|
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) {
|
if (failure) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
console.log("not ok " + n + " - " + t.name);
|
console.log("not ok " + n + " - " + t.name);
|
||||||
|
|||||||
Reference in New Issue
Block a user