test: drive ConfirmTx in the e2e suite, gate assertion included (closes #238)
All checks were successful
check / check (push) Successful in 35s

The screen that decides what gets signed had no automated coverage of its
own behaviour: no unit tests, and the e2e suite never reached it. The
arithmetic underneath is well covered in src/shared/txValidation.js; the
gap was the wiring — which number reaches the gate, when the gate re-runs,
what the fee block renders, and whether Send is enabled.

The confirmation screen quotes the fee ESTIMATE (gasLimit * gasPrice) and
gates on the fee RESERVE (gasLimit * maxFeePerGas). Reading the quoted
number instead was issue #154, and until now that was correct by reading
only — a mutant swapping the two passed the whole suite. It no longer
does: two of the new tests fail on it, one per transaction type.

The harness gains a funded-balance fixture to make any of this reachable.
tests/e2e/network.js now serves a configurable ETH balance, an ERC-20
holding, a latest block with a baseFeePerGas (without which ethers has no
maxFeePerGas and the reserve and the estimate collapse into one number),
and decimals() for the stub token. It can also refuse a gas estimate, and
hold one open so the pending state can be observed rather than raced.

Nine tests, over both the native ETH and the ERC-20 path: Send disabled
while the estimate is pending, enabled once it lands, the fee block
quoting both numbers, the distinct message for an estimate that failed,
refusal for a send past the balance, refusal for a send the reserve does
not cover, and the view height constant across every one of those
transitions.

Driving a failure path means provoking the console.error the code is
supposed to emit, which the harness fails a run on. ErrorCollector gains
expect(): it consumes exactly one matching record, and a declaration
nothing matched fails its test just as an undeclared error does, so it
cannot be used to silence anything. Both halves of that were verified by
running the suite against a deliberately wrong pattern.
This commit is contained in:
2026-08-12 08:50:43 +00:00
parent 0a1786b406
commit ecfddeb327
5 changed files with 857 additions and 23 deletions

View File

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