From 46f9cbaeae20dc396010a0185cc5c0bbf5138313 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 5 Oct 2026 08:55:39 +0000 Subject: [PATCH] test: drive the StateRecovery screen in both browser suites (closes #361) A stored record a newer build wrote opens the popup on the recovery screen. Export Saved Data puts that record, exactly as stored, in the text box; a near-miss confirmation phrase erases nothing; the exact phrase erases it and reloads into Welcome. Chrome and Firefox run the same four cases, each under its shipped CSP. They 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. If any of them fails, the last one removes the record so later tests still start from Welcome. Model: opus-5-5 --- README.md | 14 +++-- TODO.md | 10 ++++ tests/e2e/firefox/run.js | 122 ++++++++++++++++++++++++++++++++++++++ tests/e2e/run.js | 125 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 265 insertions(+), 6 deletions(-) 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( -- 2.54.0