test: drive the Settings screen in a browser and guard every popup element id (closes #229)
Some checks failed
check / check (push) Has been cancelled
e2e / e2e-chrome (push) Has been cancelled
e2e / e2e-firefox (push) Has been cancelled

This commit was merged in pull request #299.
This commit is contained in:
2026-08-17 09:14:59 +02:00
parent 47bf38644d
commit a60c4a616a
5 changed files with 581 additions and 13 deletions

View File

@@ -42,6 +42,7 @@ const {
STUB_TX_HASH,
} = require("./network");
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
const { NETWORKS } = require("../../src/shared/networks");
const TEST_TIMEOUT_MS = 120000;
@@ -944,6 +945,328 @@ test("confirming removes the address and returns Home (#162)", async (env) => {
);
});
// ------------------------------------------------- Settings screen (#229)
// Settings holds the densest run of $("...") lookups in the codebase, and
// until this section nothing drove it in a browser. One wrong id makes
// settings.init() throw, which aborts the rest of index.js init() before it
// renders anything at all — so a broken id does not degrade Settings, it
// leaves the whole popup blank. These tests assert the controls are there
// AND that they work, because "the view is visible" would still pass
// against a screen whose handlers were never wired.
// The four Token Spam Protection checkboxes, in markup order, with the
// src/shared/state.js key each one is bound to. All four default true.
const SPAM_FILTER_CHECKBOXES = [
{ id: "settings-hide-spoofed-symbols", key: "hideSpoofedSymbols" },
{ id: "settings-hide-low-holders", key: "hideLowHolderTokens" },
{ id: "settings-hide-fraud-contracts", key: "hideFraudContracts" },
{ id: "settings-hide-dust", key: "hideDustTransactions" },
];
// The one toggled through a reopen. Chosen because nothing later in this
// suite depends on it: the other three filter token and transaction lists
// that the ConfirmTx and dApp sections go on to drive.
const TOGGLED_FILTER = "settings-hide-dust";
// Everything the Settings assertions below must observe, recorded as each
// group of them completes. The final test demands the exact set.
//
// The point is that a green run cannot mean the assertions were skipped.
// Navigation that silently fails already fails a test — visible() throws
// on a timeout — but an early return, a deleted test, or a body that
// stopped being reached would otherwise shrink this section quietly
// instead of reddening the run.
const SETTINGS_COVERAGE = [
"about-well",
"spam-checkbox-defaults",
"theme-select",
"network-select",
"selector-round-trip",
"selector-restore",
"toggle-off-survives-reopen",
"toggle-on-survives-reopen",
"wallet-list",
];
// A control read as the DOM has it, not as a selector claims: tag name and
// type distinguish a real <input type="checkbox"> from a <div> that merely
// carries the id, and `checked` is the live property rather than the
// attribute, so it reflects what init() assigned.
function controlState(page, id) {
return page.evaluate((elementId) => {
const el = document.getElementById(elementId);
if (!el) return null;
return {
tag: el.tagName.toLowerCase(),
type: el.type || "",
checked: el.checked,
value: el.value,
options: Array.from(el.options || []).map((o) => o.value),
};
}, id);
}
async function checkboxStates(page) {
const out = {};
for (const { id } of SPAM_FILTER_CHECKBOXES) {
out[id] = await controlState(page, id);
}
return out;
}
function assertSpamCheckbox(st, id, expected, where) {
assert(st !== null, "no element with id " + id + " on Settings " + where);
assert(
st.tag === "input" && st.type === "checkbox",
id + " is a <" + st.tag + " type=" + st.type + ">, not a checkbox",
);
assert(
st.checked === expected,
id +
" reads " +
st.checked +
" " +
where +
", expected " +
expected +
" — the checkbox is on screen but not carrying the persisted value",
);
}
test("Settings renders with the whole screen populated (#229)", async (env) => {
await visible(env.page, "#view-main");
await openSettings(env.page);
// show() writes the About well near its end — only the debug well and
// the debug-mode checkbox follow it — and showView() is the last thing
// of all, so an id show() cannot find aborts before Settings is ever
// displayed. Reading these values back proves show() ran through to
// there, not just far enough to unhide the section. They are filled
// from build-time constants that always have a value, so empty means
// the write did not happen.
const about = await env.page.evaluate(() => {
const out = {};
for (const id of [
"about-license",
"about-author",
"about-version",
"about-release-date",
"about-commit-link",
]) {
const el = document.getElementById(id);
out[id] = el === null ? null : el.textContent.trim();
}
return out;
});
for (const [id, text] of Object.entries(about)) {
assert(
text !== null && text.length > 0,
"the About well left #" +
id +
" unwritten: " +
JSON.stringify(about),
);
}
env.settingsCoverage.add("about-well");
// The wallet list is rendered by settings.js rather than authored in
// index.html, so an empty container means renderWalletListSettings()
// did not run even though the screen came up.
const wallets = await env.page
.locator("#settings-wallet-list .settings-wallet-name")
.count();
assert(
wallets >= 2,
"Settings lists " +
wallets +
" wallets; the suite created two by this point",
);
env.settingsCoverage.add("wallet-list");
});
test("the four Token Spam Protection checkboxes render, defaulted on (#229)", async (env) => {
await openSettings(env.page);
const states = await checkboxStates(env.page);
for (const { id } of SPAM_FILTER_CHECKBOXES) {
assertSpamCheckbox(states[id], id, true, "on first render");
}
env.settingsCoverage.add("spam-checkbox-defaults");
});
test("the theme and network selectors render their real choices (#229)", async (env) => {
await openSettings(env.page);
const theme = await controlState(env.page, "settings-theme");
assert(theme !== null, "no #settings-theme element on Settings");
assert(
theme.tag === "select",
"#settings-theme is a <" + theme.tag + ">, not a <select>",
);
assert(
theme.options.join(",") === "system,light,dark",
"the theme selector offers " + JSON.stringify(theme.options),
);
env.settingsCoverage.add("theme-select");
const network = await controlState(env.page, "settings-network");
assert(network !== null, "no #settings-network element on Settings");
assert(
network.tag === "select",
"#settings-network is a <" + network.tag + ">, not a <select>",
);
const wantNetworks = Object.keys(NETWORKS).sort().join(",");
assert(
network.options.slice().sort().join(",") === wantNetworks,
"the network selector offers " +
JSON.stringify(network.options) +
", expected the networks in src/shared/networks.js: " +
wantNetworks,
);
env.settingsCoverage.add("network-select");
});
// Reads both selectors in one page task, so a round trip cannot observe
// them at two different moments.
async function selectorValues(page) {
const theme = await controlState(page, "settings-theme");
const network = await controlState(page, "settings-network");
assert(theme !== null, "no #settings-theme element on Settings");
assert(network !== null, "no #settings-network element on Settings");
return { theme: theme.value, network: network.value };
}
function assertSelectors(got, wantTheme, wantNetwork, where) {
assert(
got.theme === wantTheme,
"the theme selector shows " +
JSON.stringify(got.theme) +
" " +
where +
", expected " +
JSON.stringify(wantTheme),
);
assert(
got.network === wantNetwork,
"the network selector shows " +
JSON.stringify(got.network) +
" " +
where +
", expected " +
JSON.stringify(wantNetwork),
);
}
// The two values the selectors are driven to. NEITHER is the first
// <option> of its <select> (`system` and `mainnet` are), and that is the
// entire point: the first option is what the DOM reports with no
// JavaScript involved at all, so asserting it would pass just as happily
// against a Settings screen that never assigned anything. Only a value
// that went out through the change handler and saveState(), and came back
// through loadState() and the assignment show()/init() makes, can be read
// here.
const NONDEFAULT_THEME = "dark";
const NONDEFAULT_NETWORK = "sepolia";
test("the theme and network selectors carry a non-default persisted value (#229)", async (env) => {
await openSettings(env.page);
// selectOption() fires "change", which is what the handlers bind.
await env.page.selectOption("#settings-theme", NONDEFAULT_THEME);
await env.page.selectOption("#settings-network", NONDEFAULT_NETWORK);
await reopenPopup(env, "#view-settings");
assertSelectors(
await selectorValues(env.page),
NONDEFAULT_THEME,
NONDEFAULT_NETWORK,
"after reopening the popup",
);
env.settingsCoverage.add("selector-round-trip");
// Restore, the same way round, and assert the restore actually took
// rather than trusting it: the later sections inherit this fixture,
// and a selector stuck on `dark`/`sepolia` would otherwise be
// indistinguishable here from one that persists correctly. Switching
// the network back also returns state.rpcUrl and state.blockscoutUrl
// to the mainnet defaults that onChainSwitch() overwrote, which are
// the values src/shared/state.js starts with.
await env.page.selectOption("#settings-theme", "system");
await env.page.selectOption("#settings-network", "mainnet");
await reopenPopup(env, "#view-settings");
assertSelectors(
await selectorValues(env.page),
"system",
"mainnet",
"after restoring and reopening the popup",
);
env.settingsCoverage.add("selector-restore");
});
// The functional half. A checkbox that renders but is not wired looks
// identical on screen; only a value that survives being written to storage
// and read back by a fresh page load tells the two apart. That round trip
// runs through the change handler, saveState(), loadState() and the
// assignment init() makes — every part of the wiring at once.
test("a spam filter toggled in Settings survives a popup reopen (#229)", async (env) => {
await openSettings(env.page);
await env.page.click("#" + TOGGLED_FILTER);
const immediately = await controlState(env.page, TOGGLED_FILTER);
assert(
immediately.checked === false,
"clicking #" + TOGGLED_FILTER + " did not clear it",
);
await reopenPopup(env, "#view-settings");
const after = await checkboxStates(env.page);
for (const { id } of SPAM_FILTER_CHECKBOXES) {
assertSpamCheckbox(
after[id],
id,
id !== TOGGLED_FILTER,
"after reopening the popup",
);
}
env.settingsCoverage.add("toggle-off-survives-reopen");
});
test("turning the same filter back on survives a reopen too (#229)", async (env) => {
await openSettings(env.page);
await env.page.click("#" + TOGGLED_FILTER);
await reopenPopup(env, "#view-settings");
// Restores the fixture the later sections inherit, and rules out a
// checkbox that persists "off" only because it is stuck there.
const after = await checkboxStates(env.page);
for (const { id } of SPAM_FILTER_CHECKBOXES) {
assertSpamCheckbox(after[id], id, true, "after toggling back on");
}
env.settingsCoverage.add("toggle-on-survives-reopen");
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-main");
});
test("the Settings assertions above all ran (#229)", async (env) => {
const seen = [...env.settingsCoverage].sort();
const want = SETTINGS_COVERAGE.slice().sort();
assert(
seen.join(",") === want.join(","),
"the Settings section covered " +
JSON.stringify(seen) +
" but must cover " +
JSON.stringify(want) +
" — a green run here would otherwise mean only that fewer " +
"assertions ran, not that they passed",
);
});
// ------------------------------------------------ dust threshold (#233)
// The popup size README documents the UI as designed for. Pages in this
@@ -2672,6 +2995,11 @@ async function main() {
// The recovery phrase of the wallet created in test 2, so later
// tests can assert on the real secret rather than its shape.
phrase: null,
// What the Settings section (#229) actually observed. A guard test
// at the end of that section demands the full set, so a skipped or
// silently shortened assertion reddens the run instead of shrinking
// it.
settingsCoverage: new Set(),
// Confirmation-screen heights, measured in the pending state and
// compared against every later state of the same screen.
ethPendingHeight: null,