// 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 { isDeepStrictEqual } = require("util"); const { Transaction, formatEther, formatUnits, getAddress, getBytes, hexlify, parseEther, parseUnits, toQuantity, toUtf8Bytes, verifyMessage, verifyTypedData, } = require("ethers"); const { PASSWORD, createWallet, launch, openAddressDetail, openPopup, pageCompilesWasm, visible, } = require("./harness"); const { DAPP_ORIGIN, DAPP_URL, PHISHING_DAPP_ORIGIN, PHISHING_DAPP_URL, FEE_ESTIMATE_WEI, FEE_RESERVE_WEI, STUB_COUNTERPARTY, STUB_NATIVE_TX_HASH, STUB_NATIVE_VALUE_WEI, STUB_TOKEN, STUB_TX_HASH, } = require("./network"); const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold"); const { NETWORKS } = require("../../src/shared/networks"); const { STATE_SCHEMA_VERSION, stateProblem, } = require("../../src/shared/stateSchema"); 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( name + ": 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); }); // Chrome's own stylesheet for extension pages sets the font on body; see the // comment above in src/popup/index.html. test("the popup is drawn in the monospace font it declares (#418)", async (env) => { const font = await env.page.evaluate( () => getComputedStyle(document.body).fontFamily, ); // --font-mono in src/popup/styles/main.css, as the browser writes it out. assert( font === 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', "the popup is drawn in " + font + ", not in --font-mono", ); }); // 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 wallet creation below 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", ); }); // The screen the popup shows when it cannot read the stored profile // (src/popup/views/stateRecovery.js), driven under the shipped CSP (#361). // // These run before any wallet exists, on purpose. With no wallet neither the // popup nor the background refreshes balances, so nothing saves while they run // and no save can write a good record over the unreadable one. The last of // them erases it, which leaves the popup on Welcome for wallet creation. // A profile a newer build wrote: a wallet with its encrypted secret, under a // schema version this build refuses to read. const UNREADABLE_RECORD = { schemaVersion: STATE_SCHEMA_VERSION + 1, wallets: [ { type: "hd", name: "Main", xpub: "xpub-written-by-a-newer-build", encryptedSecret: "ciphertext-written-by-a-newer-build", nextIndex: 1, addresses: [{ address: STUB_COUNTERPARTY }], }, ], }; // The whole stored record, read out of extension storage. function storedRecord(page) { return page.evaluate( () => new Promise((resolve) => { chrome.storage.local.get("autistmask", (r) => resolve(r.autistmask), ); }), ); } test("an unreadable stored record opens the popup on the recovery screen (#361)", async (env) => { // The popup the first test opened saves once, as it shows Welcome. Stored // before that save lands, the record would be written over. await waitForPersisted( env.page, "currentView", "welcome", "before the unreadable record is stored", ); await env.page.evaluate( (record) => new Promise((resolve) => { chrome.storage.local.set({ autistmask: record }, resolve); }), UNREADABLE_RECORD, ); await env.page.close(); env.errors.expect( "the recovery screen logging the problem as it goes up", /state is unusable, showing the recovery screen/, ); env.page = await openPopup(env.ctx, env.popupUrl); await visible(env.page, "#view-state-recovery"); const problem = await env.page.textContent("#state-recovery-problem"); assert( problem === stateProblem(UNREADABLE_RECORD), "the recovery screen names the problem as " + JSON.stringify(problem), ); }); test("Export Saved Data shows the stored record verbatim (#361)", async (env) => { await env.page.click("#btn-state-recovery-export"); await visible(env.page, "#state-recovery-blob"); const exported = await env.page.inputValue("#state-recovery-blob"); assert(exported !== "", "Export Saved Data left the text box empty"); assert( isDeepStrictEqual(JSON.parse(exported), UNREADABLE_RECORD), "the text box does not hold the stored record: " + exported, ); }); test("a near-miss confirmation phrase erases nothing (#361)", async (env) => { await env.page.fill("#state-recovery-reset-input", "ERASE MY WALLETS"); await env.page.click("#btn-state-recovery-reset"); await env.page.waitForFunction( () => document.getElementById("state-recovery-flash").textContent === "Type ERASE MY WALLET to confirm. Nothing was erased.", ); const stored = await storedRecord(env.page); assert( isDeepStrictEqual(stored, UNREADABLE_RECORD), "the stored record changed: " + JSON.stringify(stored), ); }); test("the exact confirmation phrase erases the record and reloads into Welcome (#361)", async (env) => { try { await env.page.fill("#state-recovery-reset-input", "ERASE MY WALLET"); await env.page.click("#btn-state-recovery-reset"); // Welcome is the proof of the erase: the record still stored would // put the recovery screen up again, and its wallet would open Home. await visible(env.page, "#view-welcome"); } finally { // Whatever failed in these four tests, wallet creation starts from // Welcome. The record left stored would fail every test after this. if (!(await env.page.isVisible("#view-welcome"))) { await env.page.evaluate( () => new Promise((resolve) => { chrome.storage.local.remove("autistmask", resolve); }), ); await env.page.reload(); } } }); 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[class*="rounded-[50%]"]').count(); assert(dots > 0, "token contract row rendered without its colour dot"); }); // --------------------- the rest of the #150 and #151 definition of done // // The two tests above assert that the screens #150 and #151 broke now open // without throwing, which is narrower than what those issues asked for. // The four items below are the remainder (#188): the navigation stack out // of Add Token, the quick-pick actually populating the field, the native // ETH detail path the ERC-20 fix could have regressed, and tap-to-copy. // Leave the transaction detail screen for the address screen it was opened // from. The two tests above finish on it, and so does the last test here. async function leaveTransactionDetail(page) { if (await page.isVisible("#view-transaction")) { await page.click("#btn-tx-back"); } await openAddressDetail(page); } // Back out to Home from wherever the previous test finished. async function goHome(page) { await leaveTransactionDetail(page); await page.click("#btn-address-back"); await visible(page, "#view-main"); } // One field of the popup's state as it was actually persisted, read out of // extension storage rather than inferred from what is on screen. function persistedField(page, field) { return page.evaluate( (key) => new Promise((resolve) => { chrome.storage.local.get("autistmask", (r) => { resolve(r.autistmask ? r.autistmask[key] : undefined); }); }), field, ); } // The navigation stack as it was actually persisted. A stale entry left // behind by a forward navigation that threw is invisible on screen until // the user presses Back one time too many — which is exactly the // second-order damage #150 did — so the stack itself is what gets asserted. async function persistedViewStack(page) { return (await persistedField(page, "viewStack")) || []; } // Nothing here can await the popup's saves. showView() fires saveState() // without awaiting it, and a Settings control's "change" handler awaits its // save only after click() or selectOption() has already returned. So the // write lands shortly after the screen or the control changes, and a popup // closed before then loses it. Polling for the expected value keeps that // race out of the assertion and out of the close; a value that never // arrives fails with what it actually was. const SAVE_SETTLE_MS = 5000; async function waitForPersisted(page, field, expected, where) { const want = JSON.stringify(expected); const deadline = Date.now() + SAVE_SETTLE_MS; let seen; for (;;) { seen = await persistedField(page, field); if (JSON.stringify(seen) === want) return; if (Date.now() >= deadline) break; await sleep(50); } throw new Error( "the persisted " + field + " " + where + " is " + JSON.stringify(seen) + ", expected " + want, ); } // The invariant is stated as a delta against whatever the earlier tests // left on the stack, not as an absolute: a round trip into Add Token and // back out must leave the stack exactly as it found it. That is what "no // duplicated or orphaned stack entry" means, and it holds whatever the // starting depth is. test("Back from Add Token unwinds the stack exactly once (#150)", async (env) => { await goHome(env.page); const base = await persistedViewStack(env.page); await env.page.locator("#wallet-list .btn-addr-info").first().click(); await visible(env.page, "#view-address"); await waitForPersisted( env.page, "viewStack", base.concat("main"), "on address detail", ); await env.page.click("#btn-add-token"); await visible(env.page, "#view-add-token"); await waitForPersisted( env.page, "viewStack", base.concat("main", "address"), "on the add token screen", ); await env.page.click("#btn-add-token-back"); await visible(env.page, "#view-address"); assert( !(await env.page.isVisible("#view-add-token")), "the add token screen is still showing after Back", ); await waitForPersisted( env.page, "viewStack", base.concat("main"), "after Back from add token", ); await env.page.click("#btn-address-back"); await visible(env.page, "#view-main"); await waitForPersisted(env.page, "viewStack", base, "after a second Back"); }); test("a common-token quick-pick fills in the contract address (#150)", async (env) => { await openAddressDetail(env.page); await env.page.click("#btn-add-token"); await visible(env.page, "#view-add-token"); const before = await env.page.inputValue("#add-token-address"); assert( before === "", "the add token screen opened with the address field already filled: " + JSON.stringify(before), ); const pick = env.page.locator("#common-token-list .common-token").first(); const wanted = await pick.getAttribute("data-address"); assert( /^0x[0-9a-fA-F]{40}$/.test(wanted || ""), "the first quick-pick button carries no contract address: " + JSON.stringify(wanted), ); await pick.click(); const after = await env.page.inputValue("#add-token-address"); assert( after === wanted, "clicking the " + (await pick.innerText()).trim() + " quick-pick left the address field as " + JSON.stringify(after) + ", expected " + JSON.stringify(wanted), ); await env.page.click("#btn-add-token-back"); await visible(env.page, "#view-address"); }); // The native amount as the transaction list writes it (four decimals) and // as the detail screen writes it (full precision). Both are rendered here // from the fixture rather than read off the screen, so the assertions // compare against the wei the stub served. const NATIVE_ROW_TEXT = parseFloat(formatEther(STUB_NATIVE_VALUE_WEI)).toFixed(4) + " ETH"; const NATIVE_DETAIL_TEXT = formatEther(STUB_NATIVE_VALUE_WEI) + " ETH"; test("the native ETH transaction detail still renders (#151)", async (env) => { // The ERC-20 fix could only have regressed this path by making the // token-contract branch run for a transfer that has no contract, so // the assertions below are as much about that row staying hidden as // about the screen coming up. env.routeOpts.seedNativeTransfer = true; try { await env.page.reload(); await openAddressDetail(env.page); const row = env.page .locator("#tx-list .tx-row") .filter({ hasText: NATIVE_ROW_TEXT }); await row.waitFor({ state: "visible", timeout: 30000 }); await row.click(); await visible(env.page, "#view-transaction"); const hash = await env.page.locator("#tx-detail-hash").innerText(); assert( hash.includes(STUB_NATIVE_TX_HASH), "the native transaction detail shows the wrong hash: " + hash, ); const type = ( await env.page.locator("#tx-detail-type").innerText() ).trim(); assert( type === "Native ETH Transfer", "the native transaction was classified " + JSON.stringify(type), ); const value = await env.page.locator("#tx-detail-value").innerText(); assert( value.includes(NATIVE_DETAIL_TEXT), "the native transaction detail shows " + JSON.stringify(value) + ", expected it to contain " + NATIVE_DETAIL_TEXT, ); const native = await env.page.locator("#tx-detail-native").innerText(); assert( native.includes(STUB_NATIVE_VALUE_WEI + " wei"), "the raw quantity row shows " + JSON.stringify(native) + ", expected the value in wei", ); assert( !(await env.page.isVisible("#tx-detail-token-contract-section")), "the token contract row is showing on a transfer that has no " + "token contract", ); } finally { // Back to one seeded transaction for everything after this: the // tests below were written against a list holding the token transfer // alone. env.routeOpts.seedNativeTransfer = false; } }); test("tap-to-copy on the transaction detail screen copies the address (#151)", async (env) => { // Read the clipboard back rather than watching the handler run: what // #151 asks for is the address reaching the clipboard, and a spy on // navigator.clipboard would assert the call and not the effect. // // Granted context-wide rather than for the popup's origin: an // origin-scoped grant is refused for chrome-extension: URLs, which // both Playwright and Chrome treat as opaque here. await env.ctx.grantPermissions(["clipboard-read", "clipboard-write"]); await leaveTransactionDetail(env.page); const row = env.page .locator("#tx-list .tx-row") .filter({ hasText: STUB_TOKEN.symbol }); await row.waitFor({ state: "visible", timeout: 30000 }); await row.click(); await visible(env.page, "#view-transaction"); await visible(env.page, "#tx-detail-token-contract-section"); // Seed a sentinel first, so a clipboard that nothing writes to cannot // pass on whatever was left in it. const SENTINEL = "e2e-clipboard-untouched"; await env.page.evaluate((s) => navigator.clipboard.writeText(s), SENTINEL); const seeded = await env.page.evaluate(() => navigator.clipboard.readText(), ); assert( seeded === SENTINEL, "the harness could not seed the clipboard, so the assertion below " + "would prove nothing; it read back " + JSON.stringify(seeded), ); await env.page.locator("#tx-detail-token-contract [data-copy]").click(); const copied = await env.page.evaluate(() => navigator.clipboard.readText(), ); assert( copied.toLowerCase() === STUB_TOKEN.address, "tapping the token contract address put " + JSON.stringify(copied) + " on the clipboard, expected " + STUB_TOKEN.address, ); const flash = await env.page.locator("#flash-msg").innerText(); assert( flash.trim() === "Copied!", "the copy gave no confirmation, flash line reads " + JSON.stringify(flash), ); }); // ------------------------- the last of the #150 and #151 items (#295) // // Add Token's confirm button, TransactionDetail opened from the token screen // and Back from it, and the explorer link on the token contract row. // The stub token stays tracked for the rest of the run: the next test reaches // its token screen through the balance row this one adds. test("a token added by its contract address is listed on the address screen (#150)", async (env) => { await leaveTransactionDetail(env.page); await env.page.click("#btn-add-token"); await visible(env.page, "#view-add-token"); await env.page.fill("#add-token-address", STUB_TOKEN.address); await env.page.click("#btn-add-token-confirm"); await visible(env.page, "#view-address"); // No wait: the confirm renders the balance list before it shows the // screen, and nothing renders the list again while the screen is up. const row = env.page.locator( '#address-balances [data-token="' + STUB_TOKEN.address + '"]', { hasText: STUB_TOKEN.symbol }, ); const balances = await env.page.locator("#address-balances").innerText(); assert( (await row.count()) === 1, "the balance list has no " + STUB_TOKEN.symbol + " row for the token just added: " + JSON.stringify(balances), ); }); // TransactionDetail looks the same from either entry point. Only the // persisted stack says which one opened it, so that is what is asserted: from // the token screen it ends in "address-token", and Back has to land there // rather than on the address screen beneath it. test("transaction detail opened from the token screen goes Back to it (#151)", async (env) => { await goHome(env.page); const base = await persistedViewStack(env.page); await env.page.locator("#wallet-list .btn-addr-info").first().click(); await visible(env.page, "#view-address"); await env.page .locator('#address-balances [data-token="' + STUB_TOKEN.address + '"]') .click(); await visible(env.page, "#view-address-token"); const row = env.page.locator("#address-token-tx-list .tx-row").first(); await row.waitFor({ state: "visible", timeout: 30000 }); await row.click(); await visible(env.page, "#view-transaction"); await waitForPersisted( env.page, "viewStack", base.concat("main", "address", "address-token"), "on transaction detail opened from the token screen", ); // The stack is checked before the screen, so a Back that lands on the // wrong screen fails by saying what the stack holds. await env.page.click("#btn-tx-back"); await waitForPersisted( env.page, "viewStack", base.concat("main", "address"), "after Back from transaction detail", ); await visible(env.page, "#view-address-token"); // Onto the address screen, which the next test starts from. await env.page.click("#btn-address-token-back"); await visible(env.page, "#view-address"); }); // Read off the anchor rather than followed: where it points is all the popup // decides, and following it would only load the explorer's page. The suite is // on mainnet until the Settings section. test("the token contract row links to the explorer's token page (#151)", async (env) => { await leaveTransactionDetail(env.page); const row = env.page .locator("#tx-list .tx-row") .filter({ hasText: STUB_TOKEN.symbol }); await row.waitFor({ state: "visible", timeout: 30000 }); await row.click(); await visible(env.page, "#view-transaction"); await visible(env.page, "#tx-detail-token-contract-section"); const href = await env.page .locator("#tx-detail-token-contract a") .getAttribute("href"); const expected = "https://etherscan.io/token/" + STUB_TOKEN.address; assert( href === expected, "the token contract row links to " + JSON.stringify(href) + ", expected " + expected, ); }); // -------------------------------------------- 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 a screen that shows a secret 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. `view` is "show-phrase" or "export-privkey"; the two // screens name their elements the same way. async function secretScreenState(page, view) { return page.evaluate( (v) => ({ value: document.getElementById(v + "-value").textContent, error: document.getElementById(v + "-flash").textContent, html: document.getElementById("view-" + v).innerHTML, resultHidden: document .getElementById(v + "-result") .classList.contains("hidden"), viewHidden: document .getElementById("view-" + v) .classList.contains("hidden"), }), view, ); } 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, secret, where) { assert(st.value === "", "the secret is still in the DOM " + where); assert(st.resultHidden, "result section still shown " + where); assert( !st.html.includes(secret), "the secret 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. It is kept on env for the private key // export tests (#253). 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"); env.privateKey = Wallet.createRandom().privateKey; await env.page.fill("#import-private-key", env.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 secretScreenState(env.page, "show-phrase"); 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 secretScreenState(env.page, "show-phrase"); 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 secretScreenState(env.page, "show-phrase"); 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 secretScreenState(env.page, "show-phrase"); 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 secretScreenState(env.page, "show-phrase"); 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 secretScreenState(env.page, "show-phrase"); // 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 waitForPersisted( env.page, "currentView", "show-phrase", "before closing the popup", ); await env.page.close(); env.page = await openPopup(env.ctx, env.popupUrl); await visible(env.page, "#view-main"); const st = await secretScreenState(env.page, "show-phrase"); assert(st.viewHidden, "the popup reopened onto the recovery phrase screen"); assertWiped(st, env.phrase, "after reopening the popup"); }); // ------------------------------------------ private key export (#253) // The recovery phrase cases above, on the private key export screen. They run // against the key wallet imported above, not the HD wallet: leaving the screen // drops the address it was showing, and without one an HD wallet's key cannot // be derived, so there a decrypt that finished late would fail on its own and // the liveness check would go untested. // From Home to the export screen of the key wallet's one address. The key // wallet is the second wallet in the list. async function openPrivkeyScreen(page) { await visible(page, "#view-main"); await page.click('#wallet-list .btn-addr-info[data-wallet="1"]'); await visible(page, "#view-address"); await page.click("#btn-more-menu"); await page.click("#btn-export-privkey"); await visible(page, "#view-export-privkey"); } async function revealPrivkey(page) { await page.fill("#export-privkey-password", PASSWORD); await page.click("#btn-export-privkey-confirm"); await visible(page, "#export-privkey-result", 60000); } // Leave the export screen, or the Settings screen the gear left it for, for // Home. The gear put the export screen on the Back stack, so from Settings the // way home passes through it, already emptied // (https://git.eeqj.de/sneak/AutistMask/issues/461). async function leavePrivkeyScreen(page) { if (await page.isVisible("#view-settings")) { await page.click("#btn-settings-back"); await visible(page, "#view-export-privkey"); } if (await page.isVisible("#view-export-privkey")) { await page.click("#btn-export-privkey-back"); await visible(page, "#view-address"); } if (await page.isVisible("#view-address")) { await page.click("#btn-address-back"); } await visible(page, "#view-main"); } test("the correct password reveals the private key, and nothing logs it (#253)", async (env) => { const console_ = []; const listener = (msg) => console_.push(msg.text()); env.page.on("console", listener); try { await openPrivkeyScreen(env.page); await revealPrivkey(env.page); const st = await secretScreenState(env.page, "export-privkey"); assert( st.value === env.privateKey, "the displayed key is not the wallet's private key, verbatim", ); const promptShown = await env.page.isVisible( "#export-privkey-password-section", ); assert(!promptShown, "the password prompt is still shown after unlock"); const title = await env.page.getAttribute( "#export-privkey-value", "title", ); assert(title === "Click to copy", "the key is not click-to-copy"); const leaked = console_.filter((line) => line.includes(env.privateKey)); assert( leaked.length === 0, "the private key reached the console: " + JSON.stringify(leaked), ); } finally { env.page.off("console", listener); } }); test("leaving by the settings gear wipes the private key (#253)", async (env) => { await visible(env.page, "#export-privkey-result"); await env.page.click("#btn-settings"); await visible(env.page, "#view-settings"); const st = await secretScreenState(env.page, "export-privkey"); assertWiped(st, env.privateKey, "after leaving via the settings gear"); }); // The same interleaving as the recovery phrase case above, and for the same // reason: both clicks in one page task, so the leave and its wipe run while // the decrypt is still awaited. Reveal stays disabled while the decrypt runs, // so reading it after the gear click shows the leave really came mid-decrypt. test("leaving while the decrypt is in flight reveals no private key (#253)", async (env) => { try { await leavePrivkeyScreen(env.page); // The export screen cannot yet be opened twice in one popup session // (https://git.eeqj.de/sneak/AutistMask/issues/460), so this second // open gets a fresh one. await reopenPopup(env, "main"); await openPrivkeyScreen(env.page); await env.page.fill("#export-privkey-password", PASSWORD); const inFlight = await env.page.evaluate(() => { const reveal = document.getElementById( "btn-export-privkey-confirm", ); reveal.click(); document.getElementById("btn-settings").click(); return reveal.disabled; }); assert( inFlight, "the decrypt was not running when the screen was left", ); await visible(env.page, "#view-settings"); // Reveal is re-enabled in the same continuation that would have // written the key, so once it is back the decrypt has finished. await env.page.waitForFunction( () => !document.getElementById("btn-export-privkey-confirm").disabled, null, { timeout: 60000 }, ); const st = await secretScreenState(env.page, "export-privkey"); assert(st.viewHidden, "the private key screen is still on top"); assertWiped(st, env.privateKey, "after leaving mid-decrypt"); } finally { await leavePrivkeyScreen(env.page); } }); // ------------------------------- Back after reopening the popup (#268) // A reopened popup renders the wallet list and the view it restores onto, // and nothing else: every other screen is still the blank static template // from index.html. Back used to only unhide its target, which is why these // have to run against the real popup — the template is present and // well-formed, so only its emptiness distinguishes the defect, and only a // real reopen produces it. // Everything the address screen must have on it, read out of the DOM. function addressScreenState(page) { return page.evaluate(() => { const line = document.getElementById("address-line"); const balances = document.getElementById("address-balances"); return { hidden: document .getElementById("view-address") .classList.contains("hidden"), line: line ? line.innerText.trim() : "", balances: balances ? balances.innerText.trim() : "", }; }); } // Close and reopen the page rather than reload it: that is what the toolbar // popup does, and it is the only thing that produces the unrendered views. // The popup reopens on the view it last saved, so the close waits until // `view` is the one saved. That wait cannot see a save that leaves the value // as it was: a caller whose popup already had `view` saved waits for a // screen in between first. async function reopenPopup(env, view) { await waitForPersisted( env.page, "currentView", view, "before closing the popup", ); await env.page.close(); env.page = await openPopup(env.ctx, env.popupUrl); await visible(env.page, "#view-" + view); } // The reproduction from the issue, step for step. test("Back after reopening the popup renders the address screen (#268)", async (env) => { await openAddressDetail(env.page); const before = await addressScreenState(env.page); assert( before.line.length > 0, "the address screen was blank to begin with", ); await env.page.click("#btn-settings"); await visible(env.page, "#view-settings"); await reopenPopup(env, "settings"); await env.page.click("#btn-settings-back"); await visible(env.page, "#view-address"); const after = await addressScreenState(env.page); assert( after.line === before.line, "the address line reads " + JSON.stringify(after.line) + ", expected " + JSON.stringify(before.line), ); assert( after.balances.includes("ETH"), "the balances read " + JSON.stringify(after.balances), ); }); // The same defect one screen further in. Receive holds the address twice // over — as text and as the QR code the sender scans — and a blank one is // worse than a missing screen. // Everything the Receive screen must have on it. The QR code is read as // pixels, not as an element: the blank template carries the canvas too, a // default 300x150 one with nothing drawn on it and every pixel fully // transparent. A drawn QR paints an opaque background across the whole // canvas, so a single opaque pixel is the whole question. function receiveScreenState(page) { return page.evaluate(() => { const block = document.getElementById("receive-address-block"); const canvas = document.getElementById("receive-qr"); const px = canvas .getContext("2d") .getImageData(0, 0, canvas.width, canvas.height).data; let opaque = 0; for (let i = 3; i < px.length; i += 4) { if (px[i] > 0) opaque += 1; } return { address: block.dataset.full || "", text: block.innerText.trim(), qrOpaquePixels: opaque, }; }); } test("Back after reopening the popup renders the Receive screen (#268)", async (env) => { await openAddressDetail(env.page); await env.page.click("#btn-receive"); await visible(env.page, "#view-receive"); const before = await receiveScreenState(env.page); assert( /^0x[0-9a-fA-F]{40}$/.test(before.address), "Receive showed no address to begin with: " + JSON.stringify(before.address), ); // The test above left `settings` saved too, so reopenPopup() could not // tell this page's save of it from that one without a save in between. await waitForPersisted( env.page, "currentView", "receive", "on the Receive screen", ); await env.page.click("#btn-settings"); await visible(env.page, "#view-settings"); await reopenPopup(env, "settings"); await env.page.click("#btn-settings-back"); await visible(env.page, "#view-receive"); const shown = await receiveScreenState(env.page); assert( shown.address === before.address, "Receive shows " + JSON.stringify(shown.address) + ", expected " + JSON.stringify(before.address), ); assert( shown.text.includes(before.address), "the Receive address is not on screen: " + JSON.stringify(shown.text), ); assert(shown.qrOpaquePixels > 0, "Receive shows an unpainted QR code"); // Leave the suite where it found it. await env.page.click("#btn-receive-back"); await visible(env.page, "#view-address"); await env.page.click("#btn-address-back"); await visible(env.page, "#view-main"); }); // The other half of the requirement: Back renders a screen this page load // never rendered, and must NOT re-render one it already has on screen. // settings.show() reassigns #settings-rpc from persisted state, so // re-rendering Settings on the way back would silently revert whatever the // user typed and had not saved yet — and they could then press Save and // store the value they believed they had replaced. No reopen here: this is // an ordinary in-session forward-and-back, which is exactly why the render // must not happen. test("Back onto Settings keeps unsaved input (#268)", async (env) => { await visible(env.page, "#view-main"); await env.page.click("#btn-settings"); await visible(env.page, "#view-settings"); const typed = "https://rpc.example.invalid/unsaved"; await env.page.fill("#settings-rpc", typed); await env.page.click("#btn-settings-add-token"); await visible(env.page, "#view-settings-addtoken"); await env.page.click("#btn-settings-addtoken-back"); await visible(env.page, "#view-settings"); const kept = await env.page.inputValue("#settings-rpc"); assert( kept === typed, "the unsaved RPC URL reads " + JSON.stringify(kept) + ", expected " + JSON.stringify(typed), ); // Leave the suite where it found it. The typed value was never saved, // and Settings reloads the field from state next time it renders. await env.page.click("#btn-settings-back"); await visible(env.page, "#view-main"); }); // -------------------------------------------- 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", ); }); // ------------------------------------------------- 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 from a
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 ", ); 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 //