diff --git a/README.md b/README.md index 806476b..a0cebb1 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,11 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully offline; unrecognised outbound requests are reported as failures rather than silently allowed. +It also covers the StateRecovery screen, under the shipped CSP: a stored record +this build cannot read opens the popup on it, its export text box holds that +record exactly as stored, a near-miss confirmation phrase erases nothing, and +the exact one erases the record and reloads into Welcome. + It also covers the **Settings screen**, which holds the densest run of element id lookups in the codebase and where one wrong id leaves the whole popup blank rather than only degrading Settings: that the screen renders populated — the @@ -464,10 +469,11 @@ Chrome that ever changes this fails the run instead of passing it. `make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver. -It covers popup load, wallet creation through the UI, the Add Token screen, and -the four dApp round trips — `eth_requestAccounts`, `personal_sign`, -`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001 -— driven through the real content script, background page and approval windows. +It covers popup load, the StateRecovery screen (the same cases as the Chrome +suite), wallet creation through the UI, the Add Token screen, and the four dApp +round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and +a closed approval window rejecting with EIP-1193 4001 — driven through the real +content script, background page and approval windows. The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has **no npm dependencies at all**: it is built on global `fetch` and diff --git a/TODO.md b/TODO.md index 4a5d041..62d8652 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,16 @@ but the review is broader than any of them. # Completed Steps +- 2026-10-05: The StateRecovery screen is driven in a real browser under the + shipped CSP, in both end-to-end suites + ([#361](https://git.eeqj.de/sneak/AutistMask/issues/361)). A stored record + this build cannot read opens the popup on it; its export text box holds the + record exactly as stored; a near-miss confirmation phrase erases nothing; and + the exact phrase erases the record and reloads into Welcome. The cases run + before any wallet exists: with no wallet nothing saves on a timer, so no save + can write a good record over the unreadable one, and the erase leaves the + popup on Welcome for wallet creation. + - 2026-10-05: Escaping in the popup's views follows its own rule with no exceptions ([#329](https://git.eeqj.de/sneak/AutistMask/issues/329)). The decimals and holder count on a token's screen, and every USD figure (the ETH diff --git a/tests/e2e/firefox/run.js b/tests/e2e/firefox/run.js index a7d5d99..a2cc491 100644 --- a/tests/e2e/firefox/run.js +++ b/tests/e2e/firefox/run.js @@ -46,6 +46,7 @@ const fs = require("fs"); const path = require("path"); +const { isDeepStrictEqual } = require("util"); const { Transaction, @@ -62,6 +63,10 @@ const { const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver"); const { startDappServer } = require("./dapp"); const { STUB_COUNTERPARTY } = require("../network"); +const { + STATE_SCHEMA_VERSION, + stateProblem, +} = require("../../../src/shared/stateSchema"); const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html"; @@ -122,6 +127,123 @@ step("the popup is drawn in the monospace font it declares", async (env) => { ); }); +// The recovery screen (#361): the Chrome suite's four cases, run before any +// wallet exists for the same reason. With no wallet nothing saves on a timer, +// so 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 on the popup page. +function storedRecord(d) { + return d.executeAsync( + `const done = arguments[arguments.length - 1]; + browser.storage.local.get("autistmask").then((r) => done(r.autistmask));`, + ); +} + +step( + "an unreadable stored record opens the popup on the recovery screen", + async (env) => { + const d = env.driver; + // The popup the first step opened saves once, as it shows Welcome. + // Stored before that save lands, the record would be written over. + const deadline = Date.now() + 15000; + for (;;) { + const stored = await storedRecord(d); + if (stored && stored.currentView === "welcome") break; + assert( + Date.now() < deadline, + "the Welcome screen's save never landed: " + + JSON.stringify(stored), + ); + await sleep(100); + } + await d.executeAsync( + `const done = arguments[arguments.length - 1]; + browser.storage.local.set({ autistmask: arguments[0] }).then(() => done());`, + [UNREADABLE_RECORD], + ); + + await d.navigate(POPUP_URL); + await d.waitVisible("#view-state-recovery"); + const problem = await d.text("#state-recovery-problem"); + assert( + problem === stateProblem(UNREADABLE_RECORD), + "the recovery screen names the problem as " + + JSON.stringify(problem), + ); + }, +); + +step("Export Saved Data shows the stored record verbatim", async (env) => { + const d = env.driver; + await d.click("#btn-state-recovery-export"); + await d.waitVisible("#state-recovery-blob"); + const exported = await d.value("#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, + ); +}); + +step("a near-miss confirmation phrase erases nothing", async (env) => { + const d = env.driver; + await d.fill("#state-recovery-reset-input", "ERASE MY WALLETS"); + await d.click("#btn-state-recovery-reset"); + await d.waitFor( + "the refusal on the error line", + `return document.getElementById("state-recovery-flash").textContent === + "Type ERASE MY WALLET to confirm. Nothing was erased.";`, + ); + const stored = await storedRecord(d); + assert( + isDeepStrictEqual(stored, UNREADABLE_RECORD), + "the stored record changed: " + JSON.stringify(stored), + ); +}); + +step( + "the exact confirmation phrase erases the record and reloads into Welcome", + async (env) => { + const d = env.driver; + try { + await d.fill("#state-recovery-reset-input", "ERASE MY WALLET"); + await d.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 d.waitVisible("#view-welcome"); + } finally { + // Whatever failed in these four steps, wallet creation starts + // from Welcome. The record left stored would fail every step + // after this. + if (!(await d.isVisible("#view-welcome"))) { + await d.executeAsync( + `const done = arguments[arguments.length - 1]; + browser.storage.local.remove("autistmask").then(() => done());`, + ); + await d.navigate(POPUP_URL); + } + } + }, +); + step("wallet creation through the UI reaches the main view", async (env) => { const d = env.driver; await d.click("#btn-welcome-add"); diff --git a/tests/e2e/run.js b/tests/e2e/run.js index d851832..e08f0ea 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -9,6 +9,8 @@ "use strict"; +const { isDeepStrictEqual } = require("util"); + const { Transaction, formatEther, @@ -47,6 +49,10 @@ const { } = 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; @@ -115,8 +121,8 @@ test("the popup is drawn in the monospace font it declares (#418)", async (env) // 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. +// 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( @@ -128,6 +134,121 @@ test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) ); }); +// 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(