Compare commits

...
1 Commits
Author SHA1 Message Date
sneak eca2d15249 fix: Back from Settings no longer lands on a secret screen left by the gear (closes #461)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Leaving the private key export or recovery phrase screen drops the
selection it was showing, but the settings gear had just pushed the
screen onto the Back stack, so Back from Settings landed on a password
prompt that could only fail. Each screen's leave handler now also takes
it off the top of the stack, which is what a reopened popup already does
to these screens. Back from Settings goes to the address screen for the
export screen; for the recovery phrase screen, opened from Settings, it
stays on Settings once, as after a reopen.

Jest tests drive the gear and then Back, and each screen's own Back, for
both screens; leavePrivkeyScreen() in the e2e suite expects the address
screen.

Model: opus-5-5
2026-10-06 21:10:42 +00:00
7 changed files with 160 additions and 12 deletions
+6
View File
@@ -1468,6 +1468,9 @@ view would leave a wallet one click from deletion.
- "Reveal" (wrong password) → full-sentence error on the error line, nothing - "Reveal" (wrong password) → full-sentence error on the error line, nothing
revealed (no screen change) revealed (no screen change)
- "Back" → previous screen (AddressDetail) - "Back" → previous screen (AddressDetail)
- Settings gear → **Settings**, whose "Back" goes to AddressDetail: leaving
drops the address the screen was showing, so it also takes the screen off
the Back stack
- **Secret handling**: nothing is decrypted, no key is derived, and nothing is - **Secret handling**: nothing is decrypted, no key is derived, and nothing is
written into the page until the password is accepted; the key is never stored written into the page until the password is accepted; the key is never stored
in state, and it is wiped from the page whenever the screen is left by any in state, and it is wiped from the page whenever the screen is left by any
@@ -1795,6 +1798,9 @@ view would leave a wallet one click from deletion.
- "Reveal" (wrong password) → full-sentence error, nothing revealed (no - "Reveal" (wrong password) → full-sentence error, nothing revealed (no
screen change) screen change)
- "Back" → previous screen (Settings) - "Back" → previous screen (Settings)
- Settings gear → **Settings**, whose "Back" never lands back on this
screen: leaving drops the wallet the screen was showing, so it also takes
the screen off the Back stack
- **Secret handling**: nothing is decrypted or written into the page until the - **Secret handling**: nothing is decrypted or written into the page until the
password is accepted; the phrase is never stored in state, and it is wiped password is accepted; the phrase is never stored in state, and it is wiped
from the page whenever the screen is left by any route, including the Settings from the page whenever the screen is left by any route, including the Settings
+9
View File
@@ -45,6 +45,15 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-10-06: Back from Settings no longer lands on the private key export or
recovery phrase screen after either was left by the settings gear
([#461](https://git.eeqj.de/sneak/AutistMask/issues/461)). Leaving drops the
screen's selection, so its leave handler now also takes it off the Back stack,
as a reopened popup already does. `tests/exportPrivkey.test.js` and
`tests/showPhrase.test.js` drive the gear and then Back, and
`leavePrivkeyScreen()` in `tests/e2e/run.js` expects the address screen after
Settings.
- 2026-10-06: Reloading or closing the popup no longer logs a request it cancels - 2026-10-06: Reloading or closing the popup no longer logs a request it cancels
as a failure in the transaction lists and ENS name lookups on the address and as a failure in the transaction lists and ENS name lookups on the address and
token screens, the address scan after a wallet is created, the endpoint checks token screens, the address scan after a wallet is created, the endpoint checks
+10 -1
View File
@@ -152,7 +152,16 @@ async function reveal() {
} }
function init() { function init() {
onViewLeave(VIEW, clear); // Leaving drops the address selection, so the screen also comes off the
// Back stack, where the settings gear has just put it: Back from Settings
// must not land on a password prompt that can only fail. A reopened popup
// drops it from the stack the same way
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
onViewLeave(VIEW, () => {
clear();
const stack = state.viewStack;
if (stack[stack.length - 1] === VIEW) stack.pop();
});
// No wipe here: goBack() routes through showView(), which runs the // No wipe here: goBack() routes through showView(), which runs the
// leave hook. A per-button wipe would only cover this one path. // leave hook. A per-button wipe would only cover this one path.
+10 -1
View File
@@ -134,7 +134,16 @@ async function reveal() {
} }
function init() { function init() {
onViewLeave(VIEW, clear); // Leaving drops the wallet selection, so the screen also comes off the
// Back stack, where the settings gear has just put it: Back from Settings
// must not land on a password prompt that can only fail. A reopened popup
// drops it from the stack the same way
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
onViewLeave(VIEW, () => {
clear();
const stack = state.viewStack;
if (stack[stack.length - 1] === VIEW) stack.pop();
});
$("btn-show-phrase-back").addEventListener("click", () => { $("btn-show-phrase-back").addEventListener("click", () => {
goBack(); goBack();
+3 -3
View File
@@ -1075,13 +1075,13 @@ async function revealPrivkey(page) {
} }
// Leave the export screen, or the Settings screen the gear left it for, for // 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 // Home. Leaving takes the export screen off the Back stack, so from Settings
// way home passes through it, already emptied // Back goes to the address screen it was opened from
// (https://git.eeqj.de/sneak/AutistMask/issues/461). // (https://git.eeqj.de/sneak/AutistMask/issues/461).
async function leavePrivkeyScreen(page) { async function leavePrivkeyScreen(page) {
if (await page.isVisible("#view-settings")) { if (await page.isVisible("#view-settings")) {
await page.click("#btn-settings-back"); await page.click("#btn-settings-back");
await visible(page, "#view-export-privkey"); await visible(page, "#view-address");
} }
if (await page.isVisible("#view-export-privkey")) { if (await page.isVisible("#view-export-privkey")) {
await page.click("#btn-export-privkey-back"); await page.click("#btn-export-privkey-back");
+30
View File
@@ -336,6 +336,36 @@ describe("opening the screen again in the same popup session", () => {
}); });
}); });
describe("Back from Settings after leaving by the settings gear", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/461: leaving drops the
// address selection, so Back onto this screen showed a password prompt
// that could only answer "No address is selected."
test("goes to the address screen it was opened from", () => {
const { helpers, state, exportPrivkey } = load();
state.viewStack = ["main"];
exportPrivkey.show(0, 0);
// The settings gear: push the current view, then show Settings.
helpers.pushCurrentView();
helpers.showView("settings");
helpers.goBack();
expect(state.currentView).toBe("address");
expect(state.viewStack).toEqual(["main"]);
});
test("its own Back button leaves the rest of the stack alone", async () => {
const { state, exportPrivkey } = load();
state.viewStack = ["main"];
exportPrivkey.show(0, 0);
await click("btn-export-privkey-back");
expect(state.currentView).toBe("address");
expect(state.viewStack).toEqual(["main"]);
});
});
describe("views the popup may reopen onto", () => { describe("views the popup may reopen onto", () => {
// Restoring onto this screen would put a private key on display with no // Restoring onto this screen would put a private key on display with no
// password prompt in front of it, on a popup reopened by accident. // password prompt in front of it, on a popup reopened by accident.
+92 -7
View File
@@ -1,12 +1,17 @@
// Tests for the recovery phrase display (issue #161). // Tests for the recovery phrase display (issue #161).
// //
// These cover the parts that do not need a DOM: which wallet types may be // These cover which wallet types may be offered the action at all, the
// offered the action at all, the exclusion of the screen from the set of // exclusion of the screen from the set of views the popup may reopen onto,
// views the popup may reopen onto, and the absence of any path from this // the absence of any path from this module to the logger, and, against a
// module to the logger. The DOM behaviour it guards — nothing rendered // minimal DOM stub, where Back goes after the screen is left by the settings
// before the password is accepted, a wrong password revealing nothing, and // gear or by its own Back. The rest of the DOM behaviour it guards — nothing rendered before the
// the wipe on leaving — is driven against the real popup in a real browser // password is accepted, a wrong password revealing nothing, and the wipe on
// by tests/e2e/run.js, which is where every other view behaviour is tested. // leaving — is driven against the real popup in a real browser by
// tests/e2e/run.js, which is where every other view behaviour is tested.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
@@ -74,6 +79,86 @@ describe("views the popup may reopen onto", () => {
}); });
}); });
// Just enough document for helpers.showView() and this view: every element
// is made on first lookup and keeps what the view writes to it, its click
// handler included.
function makeDocument() {
const els = new Map();
function makeElement() {
const classes = new Set();
const el = {
textContent: "",
value: "",
style: {},
classList: {
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
contains: (name) => classes.has(name),
toggle: (name, on) =>
on ? classes.add(name) : classes.delete(name),
},
addEventListener: (name, fn) => {
if (name === "click") el.onClick = fn;
},
};
return el;
}
return {
getElementById(id) {
// Created on demand by helpers.js; absent on a mainnet popup
// that is not a debug build.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement());
return els.get(id);
},
};
}
describe("Back from Settings after leaving by the settings gear", () => {
// On Settings, opened from Home.
function load() {
jest.resetModules();
globalThis.document = makeDocument();
const helpers = loadHelpers();
const { state } = require("../src/shared/state");
const showPhrase = require("../src/popup/views/showPhrase");
showPhrase.init();
state.wallets = [{ name: "Wallet 1", type: "hd", addresses: [] }];
state.currentView = "settings";
state.viewStack = ["main"];
return { helpers, state, showPhrase };
}
// https://git.eeqj.de/sneak/AutistMask/issues/461: leaving drops the
// wallet selection, so Back onto this screen showed a password prompt
// that could only answer "No wallet is selected."
test("does not land on the recovery phrase screen", () => {
const { helpers, state, showPhrase } = load();
// Opened from the wallet list in Settings, then left by the gear:
// push the current view, then show Settings.
showPhrase.show(0);
helpers.pushCurrentView();
helpers.showView("settings");
expect(state.viewStack).toEqual(["main", "settings"]);
helpers.goBack();
expect(state.currentView).not.toBe(SHOW_PHRASE_VIEW);
});
// This Back takes Settings off the stack before the screen is left, so
// the stack's top is then the entry Back from Settings will need.
test("its own Back button leaves the rest of the stack alone", () => {
const { state, showPhrase } = load();
showPhrase.show(0);
globalThis.document.getElementById("btn-show-phrase-back").onClick();
expect(state.currentView).toBe("settings");
expect(state.viewStack).toEqual(["main"]);
});
});
describe("the phrase cannot reach the logger", () => { describe("the phrase cannot reach the logger", () => {
const source = fs.readFileSync( const source = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "views", "showPhrase.js"), path.join(__dirname, "..", "src", "popup", "views", "showPhrase.js"),