Compare commits

..
Author SHA1 Message Date
clawbot 35125db6d1 test: drive the StateRecovery screen in both browser suites (closes #361)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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
2026-10-05 11:43:07 +02:00
4 changed files with 294 additions and 183 deletions
+10 -4
View File
@@ -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
+9 -11
View File
@@ -45,17 +45,15 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-05: The Chrome end-to-end suite drives the private key export screen
as it drives the recovery phrase screen
([#253](https://git.eeqj.de/sneak/AutistMask/issues/253)): the correct
password shows the key, leaving by the settings gear empties the screen, and
leaving while the password is still being checked never puts the key on it.
The cases use the imported key wallet rather than the HD one. Leaving drops
the address the screen was showing, and an HD wallet's key cannot be derived
without it, so on an HD wallet a late decrypt fails by itself and would never
exercise the check that discards it. The screen cannot yet be opened twice in
one popup session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)),
so the cases reopen the popup before the second open.
- 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
+122
View File
@@ -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");
+153 -168
View File
@@ -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(
@@ -485,26 +606,22 @@ async function openSettings(page) {
await visible(page, "#view-settings");
}
// Everything a screen that shows a secret is holding, read straight out of
// 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. `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,
);
// 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) {
@@ -519,12 +636,12 @@ async function revealPhrase(page) {
await visible(page, "#show-phrase-result", 60000);
}
function assertWiped(st, secret, where) {
assert(st.value === "", "the secret is still in the DOM " + where);
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(secret),
"the secret is still somewhere in the screen markup " + where,
!st.html.includes(phrase),
"the recovery phrase is still somewhere in the screen markup " + where,
);
}
@@ -546,8 +663,7 @@ test("only an HD wallet is offered the recovery phrase action (#161)", async (en
// 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).
// private keys, test ones included.
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
const { Wallet } = require("ethers");
@@ -555,8 +671,10 @@ test("a key wallet is not offered the recovery phrase action (#161)", async (env
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(
"#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");
@@ -578,7 +696,7 @@ test("a key wallet is not offered the recovery phrase action (#161)", async (env
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");
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",
@@ -596,7 +714,7 @@ test("a wrong password reveals nothing (#161)", async (env) => {
{ timeout: 60000 },
);
const st = await secretScreenState(env.page, "show-phrase");
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "after a wrong password");
assert(
/^[A-Z].*\.$/.test(st.error.trim()),
@@ -612,7 +730,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
try {
await revealPhrase(env.page);
const st = await secretScreenState(env.page, "show-phrase");
const st = await phraseScreenState(env.page);
assert(
st.value === env.phrase,
"the displayed phrase is not the wallet's phrase, verbatim",
@@ -643,7 +761,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
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");
const st = await phraseScreenState(env.page);
assert(st.viewHidden, "the recovery phrase screen is still on top");
assertWiped(st, env.phrase, "after Back");
});
@@ -655,7 +773,7 @@ test("leaving by the settings gear wipes it too (#161)", async (env) => {
await revealPhrase(env.page);
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
const st = await secretScreenState(env.page, "show-phrase");
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "after leaving via the settings gear");
});
@@ -692,7 +810,7 @@ test("leaving while the decrypt is in flight reveals nothing (#161)", async (env
);
await sleep(2000);
const st = await secretScreenState(env.page, "show-phrase");
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
@@ -729,141 +847,11 @@ test("reopening the popup never lands on the phrase screen (#161)", async (env)
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-main");
const st = await secretScreenState(env.page, "show-phrase");
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");
});
// ------------------------------------------ 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,
@@ -4213,9 +4201,6 @@ async function main() {
// 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,
// The private key of the key wallet imported by the recovery phrase
// tests (#161), asserted on by the private key export tests (#253).
privateKey: null,
// What the Settings section (#229) actually observed. A guard test
// at the end of that section demands the full set, so a skipped or
// silently shortened assertion reddens the run instead of shrinking