Compare commits
1 Commits
52fb765232
...
0434163ce8
| Author | SHA1 | Date | |
|---|---|---|---|
| 0434163ce8 |
50
README.md
50
README.md
@@ -157,23 +157,6 @@ 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
|
||||||
@@ -260,23 +243,15 @@ 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 — a 500ms settle, a 1000ms
|
install to **≈1.5s** after the last step returns, then the browser is torn
|
||||||
tail sleep and two drain round trips — and then the browser is torn down. That
|
down; measured with throws scheduled at fixed offsets, errors at +0.5s, +1.0s
|
||||||
cut-off is not a hard boundary: with throws scheduled at fixed offsets, three
|
and +1.5s are reported and +1.6s and later never are. Within that window
|
||||||
runs reported everything up to +1.5s and one of the three also reported +1.6s,
|
nothing is dropped — each drain reads and clears the console in a single
|
||||||
so an error landing near it may or may not be seen, and anything well past it
|
chrome round trip, so an error logged mid-drain lands in that batch or the
|
||||||
is not. Inside the window there is no race — each drain reads and clears the
|
next one rather than being destroyed unread; a probe of 100 throws at 20ms
|
||||||
console in a single chrome round trip, so an error logged mid-drain lands in
|
spacing accounts for every one that falls inside the window, twice running.
|
||||||
that batch or the next rather than being destroyed unread — but there is a
|
What poll-based costs is location, not coverage: an error cannot be placed
|
||||||
**capacity limit**: `nsIConsoleService` keeps a ring buffer of 250 messages
|
within a step the way the Chrome suite's `pageerror` events place it.
|
||||||
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
|
||||||
@@ -963,12 +938,7 @@ 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. The
|
- "Hide dust transactions below N gwei" checkbox + threshold input
|
||||||
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
40
TODO.md
@@ -52,38 +52,14 @@ 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 no error is destroyed
|
and clears the console in one chrome round trip, so nothing logged between two
|
||||||
unread by the drain itself, and errors logged during add-on install and
|
drains is destroyed unread, and errors logged during add-on install and
|
||||||
background startup are folded into step 1 instead of being cleared. The two
|
background startup are folded into step 1 instead of being cleared.
|
||||||
measured limits are documented rather than claimed away: the console ring
|
Demonstrated discriminating by exiting 1 on a `throw` at the top of
|
||||||
buffer holds 250 messages (a clean run peaks at 4), and the drained window
|
`src/background/index.js`, on a build with one import removed, on a
|
||||||
ends ≈1.5s after the last step returns. Demonstrated discriminating by exiting
|
`setTimeout` throw whose UI assertions all pass, on an unhandled
|
||||||
1 on a `throw` at the top of `src/background/index.js`, on a build with one
|
`Promise.reject` and on an undefined identifier in `home.js`, and 0 on the
|
||||||
import removed, on a `setTimeout` throw whose UI assertions all pass, on an
|
branch as it stands ([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)).
|
||||||
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
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
// 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,10 +9,6 @@ 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");
|
||||||
@@ -333,14 +329,13 @@ 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 val = parseDustThresholdGwei($("settings-dust-threshold").value);
|
const raw = $("settings-dust-threshold").value.trim();
|
||||||
// Rejected input is never coerced. The field is put back to the
|
const val = Number(raw);
|
||||||
// stored threshold so it never shows a value the wallet is not
|
// 0 is accepted and means "hide nothing". Empty, negative,
|
||||||
// using, and the message says what the field wants so the snap-back
|
// fractional and non-numeric input is rejected outright rather than
|
||||||
// is explained rather than silent.
|
// coerced, and the field is put back to the stored threshold so it
|
||||||
if (val === null) {
|
// never shows a value the wallet is not using.
|
||||||
showFlash(DUST_THRESHOLD_MESSAGE);
|
if (raw !== "" && Number.isInteger(val) && val >= 0) {
|
||||||
} else {
|
|
||||||
state.dustThresholdGwei = val;
|
state.dustThresholdGwei = val;
|
||||||
await saveState();
|
await saveState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
// 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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -397,10 +397,8 @@ 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. An error that arrives mid-drain is not lost — it makes this
|
// run.js. Nothing between two takes is lost, though: an error that
|
||||||
// batch or the next one — but the console service ring buffer holds
|
// arrives mid-drain either makes this batch or the next one.
|
||||||
// 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,
|
||||||
|
|||||||
@@ -23,17 +23,13 @@
|
|||||||
// 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
|
||||||
// lands ~1.5s after the last step returns (500ms settle + 1000ms sleep +
|
// measures out at ~1.5s after the last step returns — errors at +0.5s,
|
||||||
// two drain round trips). That cut-off jitters run to run: three runs of
|
// +1.0s and +1.5s are reported, +1.6s and later never are, because the
|
||||||
// throws at fixed offsets reported everything to +1.5s and one of them
|
// browser is torn down first. Nothing inside that window is dropped: the
|
||||||
// also +1.6s, and past it the browser is torn down first. Inside the
|
// drain reads and clears in one chrome round trip, so there is no gap for
|
||||||
// window there is no race — the drain reads and clears in one chrome
|
// an error to be destroyed unread in. The Chrome harness receives
|
||||||
// round trip — but there is a capacity limit: nsIConsoleService keeps
|
// pageerror events as they happen and can say more. Do not read a green
|
||||||
// only the newest 250 messages, so 400 throws in one step report as
|
// Firefox run as the same claim.
|
||||||
// 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";
|
||||||
|
|
||||||
|
|||||||
@@ -53,47 +53,15 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,18 +290,13 @@ async function createWallet(page) {
|
|||||||
return phrase;
|
return phrase;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reach the address detail screen of the FIRST address of the first wallet,
|
// Reach the address detail screen from wherever the popup restored to.
|
||||||
// from wherever the popup restored to. Clicking .address-row does not open
|
// Clicking .address-row does not open it; the [info] button does.
|
||||||
// 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.locator("#wallet-list .btn-addr-info").first().click();
|
await page.click("#wallet-list .btn-addr-info");
|
||||||
}
|
}
|
||||||
await visible(page, "#view-address");
|
await visible(page, "#view-address");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,96 +53,18 @@ 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_getCode: "0x",
|
eth_gasPrice: "0x3b9aca00",
|
||||||
eth_gasPrice: hex(GAS_PRICE_WEI),
|
eth_estimateGas: "0x5208",
|
||||||
eth_estimateGas: hex(GAS_LIMIT),
|
|
||||||
eth_getTransactionCount: "0x0",
|
eth_getTransactionCount: "0x0",
|
||||||
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
|
eth_maxPriorityFeePerGas: "0x3b9aca00",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -170,18 +92,6 @@ 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() {
|
||||||
@@ -211,82 +121,7 @@ function blockscoutAddress(pathname) {
|
|||||||
return m ? m[1] : null;
|
return m ? m[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms) {
|
function handleRpc(route, postData, report) {
|
||||||
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");
|
||||||
@@ -297,36 +132,34 @@ async function handleRpc(route, postData, opts, 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 NON-EMPTY batch of
|
// Anything that is not a JSON-RPC object, or a batch of them, is not
|
||||||
// them, is not RPC at all and must be reported like any other
|
// RPC at all and must be reported like any other unrecognised
|
||||||
// unrecognised outbound traffic rather than dereferenced.
|
// outbound traffic rather than dereferenced. request.postData()
|
||||||
//
|
// returns null both for a bodyless POST and for a body Playwright
|
||||||
// The length check is not decoration: every() is vacuously true on an
|
// cannot decode as UTF-8 (sendBeacon with a Blob, or any binary
|
||||||
// empty array, so without it a POST with body [] was answered 200 []
|
// payload), so this is not an empty-string special case: it rejects
|
||||||
// and escaped the guard entirely (issue #187). No real batch is empty,
|
// every non-object payload, exactly as the catch above rejects every
|
||||||
// so nothing legitimate is caught by it.
|
// unparseable one.
|
||||||
//
|
|
||||||
// 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();
|
||||||
}
|
}
|
||||||
if (batch.some((req) => req.method === "eth_estimateGas")) {
|
const replies = batch.map((req) => {
|
||||||
await awaitRelease(opts, report);
|
const result = RPC_RESULTS[req.method];
|
||||||
}
|
if (result === undefined) {
|
||||||
|
report("unstubbed RPC method: " + req.method);
|
||||||
const replies = batch.map((req) => rpcReply(req, opts, report));
|
return {
|
||||||
|
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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,15 +199,6 @@ 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>}>}
|
||||||
*/
|
*/
|
||||||
@@ -417,7 +241,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(), opts, report);
|
return handleRpc(route, req.postData(), report);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blockscout v2
|
// Blockscout v2
|
||||||
@@ -435,10 +259,7 @@ 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(
|
return jsonResponse(route, []);
|
||||||
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());
|
||||||
@@ -508,9 +329,6 @@ 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,7 +9,6 @@
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const { formatEther } = require("ethers");
|
|
||||||
const {
|
const {
|
||||||
PASSWORD,
|
PASSWORD,
|
||||||
createWallet,
|
createWallet,
|
||||||
@@ -19,14 +18,7 @@ const {
|
|||||||
pageCompilesWasm,
|
pageCompilesWasm,
|
||||||
visible,
|
visible,
|
||||||
} = require("./harness");
|
} = require("./harness");
|
||||||
const {
|
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
|
||||||
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;
|
||||||
|
|
||||||
@@ -501,702 +493,6 @@ 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() {
|
||||||
@@ -1212,15 +508,7 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every fixture switch the suite can flip, declared in one place so the
|
const routeOpts = { seedTokenTransfer: false };
|
||||||
// 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 {
|
||||||
@@ -1242,16 +530,9 @@ 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
|
||||||
@@ -1298,16 +579,6 @@ 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