fix: explain a rejected dust threshold instead of silently snapping back (closes #233)
All checks were successful
check / check (push) Successful in 33s
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.
This commit was merged in pull request #243.
This commit is contained in:
130
tests/e2e/run.js
130
tests/e2e/run.js
@@ -19,6 +19,7 @@ const {
|
||||
visible,
|
||||
} = require("./harness");
|
||||
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
|
||||
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
|
||||
|
||||
const TEST_TIMEOUT_MS = 120000;
|
||||
|
||||
@@ -493,6 +494,135 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- runner
|
||||
|
||||
async function main() {
|
||||
|
||||
Reference in New Issue
Block a user