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.
774 lines
30 KiB
JavaScript
774 lines
30 KiB
JavaScript
// End-to-end suite entrypoint. Run via script/test-e2e (which builds
|
|
// dist/chrome/ and starts the pinned container); running it directly
|
|
// requires a Chromium that playwright-core can find.
|
|
//
|
|
// A plain runner rather than jest on purpose: jest's default testMatch
|
|
// would pull these files into script/test, and browser tests do not fit
|
|
// inside the 20-second cap REPO_POLICIES.md puts on make test. Nothing
|
|
// here is named *.test.js for the same reason.
|
|
|
|
"use strict";
|
|
|
|
const {
|
|
PASSWORD,
|
|
createWallet,
|
|
launch,
|
|
openAddressDetail,
|
|
openPopup,
|
|
pageCompilesWasm,
|
|
visible,
|
|
} = require("./harness");
|
|
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
|
|
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
|
|
|
|
const TEST_TIMEOUT_MS = 120000;
|
|
|
|
// How long to keep collecting after the final test returns; see the
|
|
// trailing drain in main().
|
|
const TRAILING_WATCH_MS = 1500;
|
|
|
|
const tests = [];
|
|
|
|
function test(name, fn) {
|
|
tests.push({ name, fn });
|
|
}
|
|
|
|
function assert(cond, message) {
|
|
if (!cond) throw new Error(message);
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function withTimeout(promise, name) {
|
|
let timer;
|
|
const timeout = new Promise((_, reject) => {
|
|
timer = setTimeout(
|
|
() =>
|
|
reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")),
|
|
TEST_TIMEOUT_MS,
|
|
);
|
|
});
|
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
// ----------------------------------------------------------------- tests
|
|
|
|
test("popup loads and reaches the welcome view", async (env) => {
|
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
|
await visible(env.page, "#view-welcome");
|
|
const title = await env.page.title();
|
|
assert(title === "AutistMask", "unexpected popup title: " + title);
|
|
});
|
|
|
|
// The empirical half of #182. The manifest change is only a claim about
|
|
// what the CSP permits; this is the observation. Two things have to hold
|
|
// together, and the run covers both: the popup realm compiles WASM (here),
|
|
// and no WASM refusal or abort is recorded anywhere in the run — the
|
|
// harness allowlist that used to excuse exactly that error is now empty,
|
|
// so a recurrence fails whichever test it lands in rather than being
|
|
// tolerated. Since libsodium's WASM module is embedded in the bundle and
|
|
// needs no fetch, a realm that compiles WASM is a realm where libsodium
|
|
// takes the WASM path, and the next test drives a real vault encryption
|
|
// through it.
|
|
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
|
|
const ok = await pageCompilesWasm(env.page);
|
|
assert(
|
|
ok,
|
|
"the popup refused to compile WebAssembly. The shipped manifest CSP " +
|
|
"has lost 'wasm-unsafe-eval', so libsodium is back on its wasm2js " +
|
|
"fallback and every password derivation costs roughly 20x what it " +
|
|
"should — see the backend note in src/shared/vault.js",
|
|
);
|
|
});
|
|
|
|
test("wallet creation through the UI reaches the main view", async (env) => {
|
|
env.phrase = await createWallet(env.page);
|
|
assert(
|
|
env.phrase.split(/\s+/).length >= 12,
|
|
"wallet creation did not yield a recovery phrase",
|
|
);
|
|
const addrCount = await env.page
|
|
.locator("#wallet-list .btn-addr-info")
|
|
.count();
|
|
assert(addrCount > 0, "no addresses rendered in the wallet list");
|
|
});
|
|
|
|
test("add token screen opens from address detail (#150)", async (env) => {
|
|
await openAddressDetail(env.page);
|
|
await env.page.click("#btn-add-token");
|
|
await visible(env.page, "#view-add-token");
|
|
const quickPicks = await env.page
|
|
.locator("#common-token-list .common-token")
|
|
.count();
|
|
assert(quickPicks > 0, "no common-token quick-pick buttons rendered");
|
|
});
|
|
|
|
test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
|
|
// Serve the stubbed token transfer from here on, then reload so the
|
|
// address detail screen refetches its transaction list.
|
|
env.routeOpts.seedTokenTransfer = true;
|
|
await env.page.reload();
|
|
await openAddressDetail(env.page);
|
|
|
|
await visible(env.page, "#tx-list .tx-row");
|
|
const rowText = await env.page
|
|
.locator("#tx-list .tx-row")
|
|
.first()
|
|
.innerText();
|
|
assert(
|
|
rowText.includes(STUB_TOKEN.symbol),
|
|
"token transfer row missing symbol " +
|
|
STUB_TOKEN.symbol +
|
|
", got: " +
|
|
JSON.stringify(rowText),
|
|
);
|
|
|
|
await env.page.locator("#tx-list .tx-row").first().click();
|
|
await visible(env.page, "#view-transaction");
|
|
|
|
const hash = await env.page.locator("#tx-detail-hash").innerText();
|
|
assert(
|
|
hash.includes(STUB_TX_HASH),
|
|
"transaction detail shows the wrong hash: " + hash,
|
|
);
|
|
|
|
// The token contract row is the field that crashes when
|
|
// addressDotHtml is not imported: it renders only for transfers with
|
|
// a contractAddress, which is every ERC-20 transfer.
|
|
await visible(env.page, "#tx-detail-token-contract-section");
|
|
const contract = env.page.locator("#tx-detail-token-contract");
|
|
const contractText = await contract.innerText();
|
|
assert(
|
|
contractText.toLowerCase().includes(STUB_TOKEN.address),
|
|
"token contract row missing the contract address, got: " +
|
|
JSON.stringify(contractText),
|
|
);
|
|
const dots = await contract.locator('span[style*="border-radius"]').count();
|
|
assert(dots > 0, "token contract row rendered without its colour dot");
|
|
});
|
|
|
|
// -------------------------------------------- recovery phrase (#161)
|
|
|
|
// The gear toggles, so pressing it while Settings is already up leaves it.
|
|
async function openSettings(page) {
|
|
if (!(await page.isVisible("#view-settings"))) {
|
|
await page.click("#btn-settings");
|
|
}
|
|
await visible(page, "#view-settings");
|
|
}
|
|
|
|
// Everything the recovery phrase screen is holding, read straight out of
|
|
// the DOM whether or not that screen is the one on top. Reading it while it
|
|
// is hidden is the point: "cleared on leave" means the node is empty, not
|
|
// merely off-screen.
|
|
async function phraseScreenState(page) {
|
|
return page.evaluate(() => ({
|
|
value: document.getElementById("show-phrase-value").textContent,
|
|
error: document.getElementById("show-phrase-flash").textContent,
|
|
html: document.getElementById("view-show-phrase").innerHTML,
|
|
resultHidden: document
|
|
.getElementById("show-phrase-result")
|
|
.classList.contains("hidden"),
|
|
viewHidden: document
|
|
.getElementById("view-show-phrase")
|
|
.classList.contains("hidden"),
|
|
}));
|
|
}
|
|
|
|
async function openPhraseScreen(page) {
|
|
await openSettings(page);
|
|
await page.click("#settings-wallet-list .btn-show-phrase");
|
|
await visible(page, "#view-show-phrase");
|
|
}
|
|
|
|
async function revealPhrase(page) {
|
|
await page.fill("#show-phrase-password", PASSWORD);
|
|
await page.click("#btn-show-phrase-reveal");
|
|
await visible(page, "#show-phrase-result", 60000);
|
|
}
|
|
|
|
function assertWiped(st, phrase, where) {
|
|
assert(st.value === "", "phrase still in the DOM " + where);
|
|
assert(st.resultHidden, "result section still shown " + where);
|
|
assert(
|
|
!st.html.includes(phrase),
|
|
"the recovery phrase is still somewhere in the screen markup " + where,
|
|
);
|
|
}
|
|
|
|
test("only an HD wallet is offered the recovery phrase action (#161)", async (env) => {
|
|
await openSettings(env.page);
|
|
const offered = await env.page
|
|
.locator("#settings-wallet-list .btn-show-phrase")
|
|
.count();
|
|
const wallets = await env.page
|
|
.locator("#settings-wallet-list .btn-delete-wallet")
|
|
.count();
|
|
assert(wallets === 1, "expected exactly one wallet row, got " + wallets);
|
|
assert(
|
|
offered === 1,
|
|
"the HD wallet was not offered the recovery phrase action",
|
|
);
|
|
});
|
|
|
|
// The other half of the gate, against the real UI: a wallet holding a bare
|
|
// private key has no phrase to show, so no row of it may offer the action.
|
|
// The key is generated here rather than committed — the repo holds no
|
|
// private keys, test ones included.
|
|
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
|
|
const { Wallet } = require("ethers");
|
|
|
|
await openSettings(env.page);
|
|
await env.page.click("#btn-main-add-wallet");
|
|
await visible(env.page, "#view-add-wallet");
|
|
await env.page.click("#tab-privkey");
|
|
await env.page.fill(
|
|
"#import-private-key",
|
|
Wallet.createRandom().privateKey,
|
|
);
|
|
await env.page.fill("#add-wallet-password", PASSWORD);
|
|
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
|
|
await env.page.click("#btn-add-wallet-confirm");
|
|
await visible(env.page, "#view-main", 60000);
|
|
|
|
await openSettings(env.page);
|
|
const wallets = await env.page
|
|
.locator("#settings-wallet-list .btn-delete-wallet")
|
|
.count();
|
|
const offered = await env.page
|
|
.locator("#settings-wallet-list .btn-show-phrase")
|
|
.count();
|
|
assert(wallets === 2, "expected two wallet rows, got " + wallets);
|
|
assert(
|
|
offered === 1,
|
|
"the key wallet was offered the recovery phrase action",
|
|
);
|
|
});
|
|
|
|
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
const st = await phraseScreenState(env.page);
|
|
assertWiped(st, env.phrase, "before any password was entered");
|
|
const passwordShown = await env.page.isVisible(
|
|
"#show-phrase-password-section",
|
|
);
|
|
assert(passwordShown, "the password prompt is not shown");
|
|
});
|
|
|
|
test("a wrong password reveals nothing (#161)", async (env) => {
|
|
await env.page.fill("#show-phrase-password", "not-the-password");
|
|
await env.page.click("#btn-show-phrase-reveal");
|
|
await env.page.waitForFunction(
|
|
() =>
|
|
document.getElementById("show-phrase-flash").textContent.length > 0,
|
|
null,
|
|
{ timeout: 60000 },
|
|
);
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
assertWiped(st, env.phrase, "after a wrong password");
|
|
assert(
|
|
/^[A-Z].*\.$/.test(st.error.trim()),
|
|
"the wrong-password error is not a full sentence: " +
|
|
JSON.stringify(st.error),
|
|
);
|
|
});
|
|
|
|
test("the correct password reveals the full phrase, and nothing logs it (#161)", async (env) => {
|
|
const console_ = [];
|
|
const listener = (msg) => console_.push(msg.text());
|
|
env.page.on("console", listener);
|
|
try {
|
|
await revealPhrase(env.page);
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
assert(
|
|
st.value === env.phrase,
|
|
"the displayed phrase is not the wallet's phrase, verbatim",
|
|
);
|
|
const promptShown = await env.page.isVisible(
|
|
"#show-phrase-password-section",
|
|
);
|
|
assert(!promptShown, "the password prompt is still shown after unlock");
|
|
|
|
// Full Identifiers Policy: shown whole, and copyable.
|
|
const title = await env.page.getAttribute(
|
|
"#show-phrase-value",
|
|
"title",
|
|
);
|
|
assert(title === "Click to copy", "the phrase is not click-to-copy");
|
|
|
|
const leaked = console_.filter((line) => line.includes(env.phrase));
|
|
assert(
|
|
leaked.length === 0,
|
|
"the recovery phrase reached the console: " +
|
|
JSON.stringify(leaked),
|
|
);
|
|
} finally {
|
|
env.page.off("console", listener);
|
|
}
|
|
});
|
|
|
|
test('"Back" wipes the revealed phrase (#161)', async (env) => {
|
|
await env.page.click("#btn-show-phrase-back");
|
|
await visible(env.page, "#view-settings");
|
|
const st = await phraseScreenState(env.page);
|
|
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
|
assertWiped(st, env.phrase, "after Back");
|
|
});
|
|
|
|
// The settings gear leaves the screen without touching its Back button. A
|
|
// clear wired only to Back would pass the test above and leak here.
|
|
test("leaving by the settings gear wipes it too (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
await revealPhrase(env.page);
|
|
await env.page.click("#btn-settings");
|
|
await visible(env.page, "#view-settings");
|
|
const st = await phraseScreenState(env.page);
|
|
assertWiped(st, env.phrase, "after leaving via the settings gear");
|
|
});
|
|
|
|
// The same leave, but taken while the decrypt is still running. Both
|
|
// clicks are dispatched inside one page task on purpose: "Reveal" runs its
|
|
// handler up to the await, the gear then runs the leave — and the wipe with
|
|
// it — to completion, and the decrypt's continuation resumes afterwards.
|
|
// Without a liveness check that continuation writes the phrase into the
|
|
// hidden screen after the wipe, and nothing is left to wipe it again.
|
|
//
|
|
// A human cannot produce this interleaving by hand once libsodium's wasm is
|
|
// warm, because crypto_pwhash is synchronous and the only suspension point
|
|
// is a microtask; the window a user can actually hit is a still-pending
|
|
// sodium.ready on the first vault use of a page load. Forcing it here is
|
|
// the only way to test the guard deterministically.
|
|
test("leaving while the decrypt is in flight reveals nothing (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
await env.page.fill("#show-phrase-password", PASSWORD);
|
|
await env.page.evaluate(() => {
|
|
document.getElementById("btn-show-phrase-reveal").click();
|
|
document.getElementById("btn-settings").click();
|
|
});
|
|
await visible(env.page, "#view-settings");
|
|
|
|
// The Reveal button is disabled for exactly the duration of the
|
|
// decrypt and re-enabled in the same continuation that would have
|
|
// written the phrase, so waiting for it to come back is a precise
|
|
// "the decrypt has settled and its handler has finished" signal
|
|
// rather than a guess at a duration.
|
|
await env.page.waitForFunction(
|
|
() => !document.getElementById("btn-show-phrase-reveal").disabled,
|
|
null,
|
|
{ timeout: 60000 },
|
|
);
|
|
await sleep(2000);
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
// Printed on every run, pass or fail: "the phrase is not there" is
|
|
// worth more as a measurement than as a silent assertion, and the
|
|
// same line read from a build without the guard is what this test
|
|
// exists to prevent.
|
|
console.log(
|
|
"# probe: len=" +
|
|
st.value.length +
|
|
" equalsPhrase=" +
|
|
(st.value === env.phrase) +
|
|
" resultHidden=" +
|
|
st.resultHidden +
|
|
" viewHidden=" +
|
|
st.viewHidden,
|
|
);
|
|
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
|
assertWiped(st, env.phrase, "after leaving mid-decrypt");
|
|
});
|
|
|
|
// Closing and reopening the page rather than reloading it: that is what
|
|
// the toolbar popup actually does, and the persisted currentView is
|
|
// "show-phrase" at the moment it happens, which is precisely the state
|
|
// RESTORABLE_VIEWS has to refuse.
|
|
test("reopening the popup never lands on the phrase screen (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
await revealPhrase(env.page);
|
|
|
|
await env.page.close();
|
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
|
await visible(env.page, "#view-main");
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
|
|
assertWiped(st, env.phrase, "after reopening the popup");
|
|
});
|
|
|
|
// -------------------------------------------- address removal (#162)
|
|
|
|
// Number of address rows across every wallet in the list, counted in the DOM
|
|
// whether or not Home is the screen on top.
|
|
function addressRowCount(page) {
|
|
return page.locator("#wallet-list .btn-addr-info").count();
|
|
}
|
|
|
|
function waitForAddressRows(page, n) {
|
|
return page.waitForFunction(
|
|
(want) =>
|
|
document.querySelectorAll("#wallet-list .btn-addr-info").length ===
|
|
want,
|
|
n,
|
|
{ timeout: 60000 },
|
|
);
|
|
}
|
|
|
|
// The suite arrives here with two wallets, an HD one and a key one, holding
|
|
// one address each.
|
|
test("only a wallet that can spare an address offers to remove one (#162)", async (env) => {
|
|
await visible(env.page, "#view-main");
|
|
const rows = await addressRowCount(env.page);
|
|
assert(rows === 2, "expected two address rows, got " + rows);
|
|
const offered = await env.page
|
|
.locator("#wallet-list .btn-remove-address")
|
|
.count();
|
|
assert(
|
|
offered === 0,
|
|
"a wallet holding its last address offered to remove it",
|
|
);
|
|
|
|
await env.page.click("#wallet-list .btn-add-address");
|
|
await waitForAddressRows(env.page, 3);
|
|
|
|
// Only the HD wallet's two rows; the key wallet still holds one address.
|
|
const nowOffered = await env.page
|
|
.locator("#wallet-list .btn-remove-address")
|
|
.count();
|
|
assert(
|
|
nowOffered === 2,
|
|
"expected the HD wallet's two rows to offer removal, got " + nowOffered,
|
|
);
|
|
});
|
|
|
|
// The gate itself: the control opens a confirmation, and leaving that
|
|
// confirmation by "Back" removes nothing.
|
|
test("leaving the removal confirmation removes nothing (#162)", async (env) => {
|
|
await env.page.locator("#wallet-list .btn-remove-address").nth(1).click();
|
|
await visible(env.page, "#view-delete-address-confirm");
|
|
|
|
const label = await env.page.locator("#delete-address-label").innerText();
|
|
assert(
|
|
label === "Address 2",
|
|
"the confirmation names the wrong address: " + JSON.stringify(label),
|
|
);
|
|
|
|
// The route back is written by the view, not by index.html, so an empty
|
|
// paragraph here means the user is confirming with no idea what it
|
|
// takes to undo. This wallet is an HD one, so it is told about its
|
|
// recovery phrase.
|
|
const recovery = await env.page
|
|
.locator("#delete-address-recovery")
|
|
.innerText();
|
|
assert(
|
|
recovery.includes("delete the whole wallet in Settings") &&
|
|
recovery.includes("recovery phrase"),
|
|
"the confirmation does not state the route back: " +
|
|
JSON.stringify(recovery),
|
|
);
|
|
|
|
// "Back" re-renders Home, so a count taken after it is a real
|
|
// measurement of the wallet rather than a stale screen.
|
|
await env.page.click("#btn-delete-address-back");
|
|
await visible(env.page, "#view-main");
|
|
const rows = await addressRowCount(env.page);
|
|
assert(rows === 3, "the address was removed without a confirmation");
|
|
});
|
|
|
|
test("confirming removes the address and returns Home (#162)", async (env) => {
|
|
await env.page.locator("#wallet-list .btn-remove-address").nth(1).click();
|
|
await visible(env.page, "#view-delete-address-confirm");
|
|
await env.page.click("#btn-delete-address-confirm");
|
|
await visible(env.page, "#view-main");
|
|
|
|
await waitForAddressRows(env.page, 2);
|
|
const offered = await env.page
|
|
.locator("#wallet-list .btn-remove-address")
|
|
.count();
|
|
assert(
|
|
offered === 0,
|
|
"the HD wallet still offers to remove its last address",
|
|
);
|
|
});
|
|
|
|
// ------------------------------------------------ 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() {
|
|
// A suite that runs nothing must never report success. If a refactor
|
|
// drops the registrations above, or a require() of this file stops
|
|
// reaching them, the only honest outcome is a red run — reporting
|
|
// "0/0 passed" and exiting 0 is the same vacuous-check failure this
|
|
// whole harness exists to prevent.
|
|
if (tests.length === 0) {
|
|
console.log("1..0");
|
|
console.log("# FAILED: the e2e suite registered no tests");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const routeOpts = { seedTokenTransfer: false };
|
|
|
|
let session;
|
|
try {
|
|
session = await launch(routeOpts);
|
|
} catch (e) {
|
|
// Never skip and report success: a browser we cannot start, or
|
|
// one whose network interception is not in force, is a failure of
|
|
// the suite, not an absent one.
|
|
console.error("e2e: cannot run the suite: " + e.message);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
console.log("# extension id: " + session.extensionId);
|
|
console.log("1.." + tests.length);
|
|
|
|
const env = {
|
|
ctx: session.ctx,
|
|
popupUrl: session.popupUrl,
|
|
routeOpts,
|
|
page: null,
|
|
// 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,
|
|
};
|
|
|
|
// Attribution of collected errors is total. session.errors has no
|
|
// window API at all: take() always drains everything outstanding, so
|
|
// successive takes partition the whole stream, and the phases below
|
|
// cover the entire life of the run. Nothing the collector holds can
|
|
// go unread.
|
|
//
|
|
// launch .. end of test 1 -> test 1 (so the worker's startup
|
|
// fetches land on a test, not
|
|
// nowhere)
|
|
// end of test k .. end of k+1 -> test k+1
|
|
// last test .. teardown -> the suite, via the trailing drain
|
|
//
|
|
// Those three phases cover the entire life of the browser context.
|
|
// There is no fourth: once the context is closed nothing can record,
|
|
// because the route handler and the console listeners died with it.
|
|
// Traffic that a test defers past the trailing drain is therefore
|
|
// never observed at all — a real limit of this design, stated in the
|
|
// README, and not one any post-teardown hook could close.
|
|
//
|
|
// Two green-but-vacuous runs on this harness were the same shape: a
|
|
// record falling outside somebody's window and being dropped. First
|
|
// the mark started after test 1, discarding launch-time records;
|
|
// then the tail after the last test was never read. Patching a
|
|
// second boundary would have invited a third, so the window concept
|
|
// is gone rather than fixed.
|
|
let failed = 0;
|
|
let n = 0;
|
|
for (const t of tests) {
|
|
n += 1;
|
|
let failure = null;
|
|
try {
|
|
await withTimeout(t.fn(env), t.name);
|
|
} catch (e) {
|
|
failure = e.message;
|
|
}
|
|
|
|
// Any uncaught page error, console.error or unstubbed request
|
|
// fails the test that provoked it, whether or not its assertions
|
|
// passed. This is the mechanism that caught #150.
|
|
const newErrors = session.errors.take();
|
|
if (!failure && newErrors.length > 0) {
|
|
failure = "uncaught browser errors during this test";
|
|
}
|
|
|
|
if (failure) {
|
|
failed += 1;
|
|
console.log("not ok " + n + " - " + t.name);
|
|
console.log(" " + failure);
|
|
for (const line of newErrors) {
|
|
console.log(" " + line);
|
|
}
|
|
} else {
|
|
console.log("ok " + n + " - " + t.name);
|
|
}
|
|
}
|
|
|
|
// Keep watching after the last test returns, before tearing the
|
|
// browser down. A request a test fires without awaiting is still in
|
|
// flight when its function resolves; measured here it reaches the
|
|
// route handler about 10ms later, but closing the context does not
|
|
// wait for it — with no window at all the request dies unobserved
|
|
// and the run goes green, which is exactly how escaping traffic
|
|
// stays invisible.
|
|
//
|
|
// A fixed bounded window rather than a quiescence poll on purpose:
|
|
// the collector being quiet is not evidence, because a request that
|
|
// has not been dispatched yet has recorded nothing to be quiet
|
|
// about. Playwright offers no "is anything in flight" question to
|
|
// ask either — the route handler is the only observation point — so
|
|
// a grace period is the mechanism available, and this one is ~150x
|
|
// the measured latency for 1.5s on a ~25s suite.
|
|
await new Promise((resolve) => setTimeout(resolve, TRAILING_WATCH_MS));
|
|
|
|
await session.close();
|
|
|
|
// The tail. These cannot be blamed on any single test, so they are
|
|
// reported against the suite rather than guessed at — but they are
|
|
// reported, and they fail the run.
|
|
const trailing = session.errors.take();
|
|
|
|
console.log(
|
|
"# " + (tests.length - failed) + "/" + tests.length + " tests passed",
|
|
);
|
|
|
|
if (trailing.length > 0) {
|
|
console.log(
|
|
"# " +
|
|
trailing.length +
|
|
" browser error(s) recorded after the last test finished, " +
|
|
"not attributable to any single test:",
|
|
);
|
|
for (const line of trailing) {
|
|
console.log("# " + line);
|
|
}
|
|
}
|
|
|
|
if (failed > 0 || trailing.length > 0) {
|
|
console.log("# FAILED");
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("e2e: " + (e && e.stack ? e.stack : e));
|
|
process.exitCode = 1;
|
|
});
|