Compare commits

..

4 Commits

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -157,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
@@ -243,15 +260,23 @@ 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 - **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 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 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, then the browser is torn install to **≈1.5s** after the last step returns — a 500ms settle, a 1000ms
down; measured with throws scheduled at fixed offsets, errors at +0.5s, +1.0s tail sleep and two drain round trips — and then the browser is torn down. That
and +1.5s are reported and +1.6s and later never are. Within that window cut-off is not a hard boundary: with throws scheduled at fixed offsets, three
nothing is dropped — each drain reads and clears the console in a single runs reported everything up to +1.5s and one of the three also reported +1.6s,
chrome round trip, so an error logged mid-drain lands in that batch or the so an error landing near it may or may not be seen, and anything well past it
next one rather than being destroyed unread; a probe of 100 throws at 20ms is not. Inside the window there is no race — each drain reads and clears the
spacing accounts for every one that falls inside the window, twice running. console in a single chrome round trip, so an error logged mid-drain lands in
What poll-based costs is location, not coverage: an error cannot be placed that batch or the next rather than being destroyed unread — but there is a
within a step the way the Chrome suite's `pageerror` events place it. **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.** - **Nothing is stubbed, which inverts the coverage of network-dependent code.**
There is no fixture layer; the container runs with `--network none` instead, 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 so the run is offline and deterministic and no request can escape. The
@@ -938,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

40
TODO.md
View File

@@ -52,14 +52,38 @@ undefined identifiers, which is how
geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the
privileged console service in Marionette's chrome context, because BiDi privileged console service in Marionette's chrome context, because BiDi
`log.entryAdded` reports nothing at all for extension pages; each drain reads `log.entryAdded` reports nothing at all for extension pages; each drain reads
and clears the console in one chrome round trip, so nothing logged between two and clears the console in one chrome round trip, so no error is destroyed
drains is destroyed unread, and errors logged during add-on install and unread by the drain itself, and errors logged during add-on install and
background startup are folded into step 1 instead of being cleared. background startup are folded into step 1 instead of being cleared. The two
Demonstrated discriminating by exiting 1 on a `throw` at the top of measured limits are documented rather than claimed away: the console ring
`src/background/index.js`, on a build with one import removed, on a buffer holds 250 messages (a clean run peaks at 4), and the drained window
`setTimeout` throw whose UI assertions all pass, on an unhandled ends ≈1.5s after the last step returns. Demonstrated discriminating by exiting
`Promise.reject` and on an undefined identifier in `home.js`, and 0 on the 1 on a `throw` at the top of `src/background/index.js`, on a build with one
branch as it stands ([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)). 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

View File

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

View File

@@ -9,6 +9,10 @@ const {
pushCurrentView, 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
View File

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

View File

@@ -397,8 +397,10 @@ class ConsoleErrors {
// in a single chrome round trip. Poll-based, so an error is attributed // 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 // 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 // inside that step at which it happened — see the limitation note in
// run.js. Nothing between two takes is lost, though: an error that // run.js. An error that arrives mid-drain is not lost — it makes this
// arrives mid-drain either makes this batch or the next one. // 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() { async take() {
const found = await this.driver.executeChrome(DRAIN_ERRORS_SCRIPT, [ const found = await this.driver.executeChrome(DRAIN_ERRORS_SCRIPT, [
this.originPrefix, this.originPrefix,

View File

@@ -23,13 +23,17 @@
// drained at each step boundary, so an error is attributed to the step it // 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 // 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 // covers the whole run from add-on install to the last drain below, which
// measures out at ~1.5s after the last step returns — errors at +0.5s, // lands ~1.5s after the last step returns (500ms settle + 1000ms sleep +
// +1.0s and +1.5s are reported, +1.6s and later never are, because the // two drain round trips). That cut-off jitters run to run: three runs of
// browser is torn down first. Nothing inside that window is dropped: the // throws at fixed offsets reported everything to +1.5s and one of them
// drain reads and clears in one chrome round trip, so there is no gap for // also +1.6s, and past it the browser is torn down first. Inside the
// an error to be destroyed unread in. The Chrome harness receives // window there is no race — the drain reads and clears in one chrome
// pageerror events as they happen and can say more. Do not read a green // round trip — but there is a capacity limit: nsIConsoleService keeps
// Firefox run as the same claim. // 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"; "use strict";

View File

@@ -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");
} }

View File

@@ -53,18 +53,96 @@ const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z";
// log.errorf(), i.e. console.error, which fails the run on its own. // 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,
}; };

View File

@@ -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);