// 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, visible, } = require("./harness"); const { STUB_TOKEN, STUB_TX_HASH } = require("./network"); 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); }); 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"); }); // ---------------------------------------------------------------- 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; });