Compare commits

..
Author SHA1 Message Date
sneak 978e8e49d8 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 for both screens, and
leavePrivkeyScreen() in the e2e suite expects the address screen.

Model: opus-5-5
2026-10-06 17:53:12 +00:00
26 changed files with 186 additions and 773 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
revealed (no screen change)
- "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
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
@@ -1795,6 +1798,9 @@ view would leave a wallet one click from deletion.
- "Reveal" (wrong password) → full-sentence error, nothing revealed (no
screen change)
- "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
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
+8 -15
View File
@@ -45,21 +45,14 @@ but the review is broader than any of them.
# Completed Steps
- 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
token screens, the address scan after a wallet is created, the endpoint checks
in Settings, the wait screen's receipt check, the Send screen's Max fee
estimate, or the token lookup on the two add-token screens
([#475](https://git.eeqj.de/sneak/AutistMask/issues/475)). Each checks the
signal the popup aborts on `pagehide`
([#218](https://git.eeqj.de/sneak/AutistMask/issues/218)) before reporting a
failure; a real failure is still logged. `scanForAddresses()`,
`resolveEnsNames()` and `lookupTokenInfo()` take the signal. The e2e suite
reloads the popup on the address screen with Blockscout held, and during the
address scan with that scan held; jest tests cover each of these with the
popup open and closed. Left out: the transaction detail screen and the
confirmation screen (its fee estimate and its recipient checks), because they
discard the popup context that carries the signal.
- 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: The private key export screen opens again in the same popup
session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)). `show()`
-4
View File
@@ -51,7 +51,6 @@ function init(ctx) {
contractAddr,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
@@ -69,9 +68,6 @@ function init(ctx) {
}
require("./addressDetail").show();
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
const detail = e.shortMessage || e.message || String(e);
log.errorf("Adding token failed for", contractAddr, detail);
// lookupTokenInfo() rejects a contract with a one-line message
+2 -12
View File
@@ -190,12 +190,7 @@ async function importMnemonic(ctx) {
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(
xpub,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
@@ -305,12 +300,7 @@ async function importXprvKey(ctx) {
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(
xpub,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
-4
View File
@@ -135,7 +135,6 @@ async function loadTransactions(address) {
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
ensNameMap = new Map();
@@ -144,9 +143,6 @@ async function loadTransactions(address) {
renderTransactions(txs);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
-4
View File
@@ -219,7 +219,6 @@ async function loadTransactions(address, tokenId) {
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
ensNameMap = new Map();
@@ -228,9 +227,6 @@ async function loadTransactions(address, tokenId) {
renderTransactions(txs);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message);
$("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
+10 -1
View File
@@ -152,7 +152,16 @@ async function reveal() {
}
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
// leave hook. A per-button wipe would only cover this one path.
-3
View File
@@ -292,9 +292,6 @@ async function fillMaxAmount() {
]);
feeWei = feeReserveWei(gasLimit, feeData);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf(
"max amount fee estimate failed:",
e.shortMessage || e.message,
-4
View File
@@ -273,9 +273,6 @@ function init(ctx) {
return;
}
} catch {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
// Not the error's message: fetch puts the whole URL, password and
// key included, in the message of the error it throws for a URL
// with a user name and password or one it cannot parse.
@@ -302,7 +299,6 @@ function init(ctx) {
return;
}
} catch {
if (ctx.pageClosed.aborted) return;
// Not the error's message, as for the RPC check above.
log.errorf("Blockscout validation failed:", urlOrigin(url));
showFlash("Could not reach endpoint.");
-4
View File
@@ -135,7 +135,6 @@ function init(_ctx) {
addr,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
@@ -153,9 +152,6 @@ function init(_ctx) {
renderDropdown();
ctx.doRefreshAndRender();
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
const detail = e.shortMessage || e.message || String(e);
log.errorf("Adding token failed for", addr, detail);
// lookupTokenInfo() rejects a contract with a one-line message
+10 -1
View File
@@ -134,7 +134,16 @@ async function reveal() {
}
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", () => {
goBack();
-3
View File
@@ -132,9 +132,6 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
try {
receipt = await provider.getTransactionReceipt(txHash);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
// A thrown lookup means "no answer this tick", not "no
// receipt": the RPC failed, the chain said nothing. Declaring
// the timeout off it would report a confirmed transaction as
+13 -23
View File
@@ -294,8 +294,7 @@ async function refreshBalances(
// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
// `signal` is as for fetchTokenBalances().
async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId);
const contract = new Contract(contractAddress, ERC20_ABI, provider);
@@ -305,9 +304,7 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
symbol = await contract.symbol();
log.debugf("symbol() =", symbol);
} catch (e) {
if (!signal?.aborted) {
log.errorf("symbol() failed:", e.shortMessage || e.message);
}
log.errorf("symbol() failed:", e.shortMessage || e.message);
throw new Error("Not a valid ERC-20 token (symbol() failed).");
}
@@ -315,9 +312,7 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
decimals = await contract.decimals();
log.debugf("decimals() =", decimals);
} catch (e) {
if (!signal?.aborted) {
log.errorf("decimals() failed:", e.shortMessage || e.message);
}
log.errorf("decimals() failed:", e.shortMessage || e.message);
throw new Error("Not a valid ERC-20 token (decimals() failed).");
}
@@ -325,12 +320,10 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
name = await contract.name();
log.debugf("name() =", name);
} catch (e) {
if (!signal?.aborted) {
log.warnf(
"name() failed, using symbol as name:",
e.shortMessage || e.message,
);
}
log.warnf(
"name() failed, using symbol as name:",
e.shortMessage || e.message,
);
name = symbol;
}
@@ -346,8 +339,7 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
// Checks gapLimit addresses in parallel per batch. Stops when an entire
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
// Returns { addresses: [{ address, index }], nextIndex }.
// `signal` is as for fetchTokenBalances().
async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) {
async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
const provider = getProvider(rpcUrl, networkId);
const used = [];
@@ -370,13 +362,11 @@ async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) {
]);
return { addr, index, isUsed: balance > 0n || txCount > 0 };
} catch (e) {
if (!signal?.aborted) {
log.errorf(
"scanForAddresses check failed",
addr,
e.shortMessage || e.message,
);
}
log.errorf(
"scanForAddresses check failed",
addr,
e.shortMessage || e.message,
);
return { addr, index, isUsed: false };
}
}),
+8 -14
View File
@@ -32,8 +32,7 @@ function setCache(address, name) {
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
}
// `signal` is as for fetchTokenBalances() in src/shared/balances.js.
async function resolveEnsName(address, rpcUrl, networkId, signal) {
async function resolveEnsName(address, rpcUrl, networkId) {
const cached = getCached(address);
if (cached !== undefined) return cached;
@@ -43,26 +42,21 @@ async function resolveEnsName(address, rpcUrl, networkId, signal) {
setCache(address, name);
return name;
} catch (e) {
if (!signal?.aborted) {
log.errorf(
"ENS reverse lookup failed",
address,
e.shortMessage || e.message,
);
}
log.errorf(
"ENS reverse lookup failed",
address,
e.shortMessage || e.message,
);
// Don't cache failures — let subsequent lookups retry
return null;
}
}
async function resolveEnsNames(addresses, rpcUrl, networkId, signal) {
async function resolveEnsNames(addresses, rpcUrl, networkId) {
const results = new Map();
await Promise.all(
addresses.map(async (addr) => {
results.set(
addr,
await resolveEnsName(addr, rpcUrl, networkId, signal),
);
results.set(addr, await resolveEnsName(addr, rpcUrl, networkId));
}),
);
return results;
-121
View File
@@ -1,121 +0,0 @@
// Creating a wallet from a recovery phrase or an extended private key does not
// report an address scan request the popup's own closing cancelled, and still
// reports one that failed while the popup was open
// (https://git.eeqj.de/sneak/AutistMask/issues/475).
//
// In the popup a cancelled request fails with the same "Failed to fetch" as a
// server that cannot be reached, so every RPC request here fails that way, and
// only the signal the popup aborts on pagehide tells the two cases apart.
// Driven against the fake elements tests/tokenLookupCancelled.test.js uses.
const { FetchRequest, HDNodeWallet, Mnemonic } = require("ethers");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PASSWORD = "correct horse battery staple";
let elements;
function fakeElement() {
return {
value: "",
textContent: "",
style: {},
classList: { toggle: () => {} },
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
};
}
// Stands in for document.getElementById(): one fake element per id.
function element(id) {
return (elements[id] ||= fakeElement());
}
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
showFlash: () => {},
goBack: () => {},
clearViewStack: () => {},
onViewLeave: () => {},
}));
// state.js reads chrome.storage.local at load, and saveState() writes it.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const addWallet = require("../src/popup/views/addWallet");
let logged;
beforeEach(() => {
elements = {};
logged = [];
state.wallets = [];
state.rpcUrl = "https://rpc.example.invalid";
state.networkId = "mainnet";
for (const method of ["warn", "error"]) {
jest.spyOn(console, method).mockImplementation((...args) => {
logged.push(args.map(String).join(" "));
});
}
// What the scan found is logged at info level, which is not under test.
jest.spyOn(console, "log").mockImplementation(() => {});
FetchRequest.registerGetUrl(async () => {
throw new TypeError("Failed to fetch");
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
jest.restoreAllMocks();
});
describe.each([
["a recovery phrase", "mnemonic", "wallet-mnemonic", PHRASE],
[
"an extended private key",
"xprv",
"import-xprv-key",
HDNodeWallet.fromSeed(Mnemonic.fromPhrase(PHRASE).computeSeed())
.extendedKey,
],
])("creating a wallet from %s", (_name, mode, field, secret) => {
// Enters `secret` on the add-wallet screen, presses its button and waits
// for the address scan that follows.
async function create(pageClosed) {
addWallet.init({
renderWalletList: () => {},
doRefreshAndRender: () => {},
pageClosed,
});
element("tab-" + mode).listeners.click();
element(field).value = secret;
element("add-wallet-password").value = PASSWORD;
element("add-wallet-password-confirm").value = PASSWORD;
await element("btn-add-wallet-confirm").listeners.click();
}
test("a scan failure while the popup is open is reported", async () => {
await create(new AbortController().signal);
expect(state.wallets).toHaveLength(1);
expect(logged).not.toEqual([]);
for (const line of logged) {
expect(line).toContain("scanForAddresses check failed");
}
});
test("a scan failure once the popup has closed is not", async () => {
const pageClosed = new AbortController();
pageClosed.abort();
await create(pageClosed.signal);
expect(state.wallets).toHaveLength(1);
expect(logged).toEqual([]);
});
});
+6 -34
View File
@@ -1,19 +1,13 @@
// The balance refresh, and the address scan after a wallet is created, do not
// report a request the popup's own closing cancelled, and still report one
// that failed while the popup was open
// (https://git.eeqj.de/sneak/AutistMask/issues/218,
// https://git.eeqj.de/sneak/AutistMask/issues/475).
// The balance refresh does not report a request the popup's own closing
// cancelled, and still reports one that failed while the popup was open
// (https://git.eeqj.de/sneak/AutistMask/issues/218).
//
// In the popup a cancelled fetch() fails with the same "Failed to fetch" as a
// server that cannot be reached, so every request here fails that way, and
// only the signal the popup aborts on pagehide tells the two cases apart.
const { FetchRequest } = require("ethers");
const { refreshBalances, scanForAddresses } = require("../src/shared/balances");
const {
generateMnemonic,
hdWalletFromMnemonic,
} = require("../src/shared/wallet");
const { refreshBalances } = require("../src/shared/balances");
const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
@@ -30,9 +24,8 @@ beforeEach(() => {
const failedToFetch = async () => {
throw new TypeError("Failed to fetch");
};
// The RPC calls (ETH balance, ENS name, and the scan's balance and
// transaction count) and the explorer request (token balances) all fail
// the same way.
// The RPC calls (ETH balance, ENS name) and the explorer request (token
// balances) all fail the same way.
FetchRequest.registerGetUrl(failedToFetch);
globalThis.fetch = jest.fn(failedToFetch);
});
@@ -72,24 +65,3 @@ test("a failure once the popup has closed is not", async () => {
await refresh(pageClosed.signal);
expect(logged).toEqual([]);
});
function scan(signal) {
// What the scan found is logged at info level, which is not under test.
jest.spyOn(console, "log").mockImplementation(() => {});
const { xpub } = hdWalletFromMnemonic(generateMnemonic());
return scanForAddresses(xpub, RPC_URL, "mainnet", signal);
}
test("a scan failure while the popup is open is reported", async () => {
await scan(new AbortController().signal);
expect(
logged.some((line) => line.includes("scanForAddresses check failed")),
).toBe(true);
});
test("a scan failure once the popup has closed is not", async () => {
const pageClosed = new AbortController();
pageClosed.abort();
await scan(pageClosed.signal);
expect(logged).toEqual([]);
});
+6 -11
View File
@@ -420,12 +420,12 @@ const HOLD_MAX_MS = 30000;
// Hold a reply open for as long as the test asks: until opts[name] is false.
//
// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read
// here rather than captured, so a test flips it on the same options object the
// route was registered with — the same pattern as seedTokenTransfer. This is
// the only way to observe a screen while its request is genuinely in flight;
// sampling the screen and hoping to win a race against the network would
// assert nothing on a slow machine.
// The switch (holdGasEstimate or holdBlockscout) is read here rather than
// captured, so a test flips it on the same options object the route was
// registered with — the same pattern as seedTokenTransfer. This is the only way
// to observe a screen while its request is genuinely in flight; sampling the
// screen and hoping to win a race against the network would assert nothing on
// a slow machine.
//
// It never gives up quietly. A hold that outlives the bound is reported like
// any other harness fault, because a "pending" state that stopped being
@@ -560,9 +560,6 @@ async function handleRpc(route, postData, opts, report) {
if (batch.some((req) => req.method === "eth_estimateGas")) {
await awaitRelease(opts, "holdGasEstimate", report);
}
if (batch.some((req) => req.method === "eth_getTransactionCount")) {
await awaitRelease(opts, "holdTransactionCount", report);
}
const replies = batch.map((req) => rpcReply(req, opts, report));
return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]);
@@ -617,8 +614,6 @@ function traceEnabled(raw) {
* node-side refusal.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again.
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an
* eth_getTransactionCount until this is cleared again.
* @param {boolean} [opts.holdBlockscout] hold every Blockscout request until
* this is cleared again.
* @param {boolean} [opts.failTransactionList] fail every request for an
+11 -50
View File
@@ -249,23 +249,8 @@ test("the exact confirmation phrase erases the record and reloads into Welcome (
}
});
// The address scan that follows creating the wallet is held, so the popup
// reloads while it is in flight. As for the reloads mid-refresh further down,
// the assertion for that is the harness's own: a console.error from the page
// being reloaded fails this test (#475).
test("wallet creation through the UI reaches the main view, and reloading mid-scan reports no failure (#475)", async (env) => {
env.routeOpts.holdTransactionCount = true;
try {
const scanning = env.page.waitForRequest((r) =>
(r.postData() || "").includes("eth_getTransactionCount"),
);
env.phrase = await createWallet(env.page);
await scanning;
await env.page.reload();
} finally {
env.routeOpts.holdTransactionCount = false;
}
await visible(env.page, "#view-main");
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",
@@ -715,8 +700,9 @@ test("the token contract row links to the explorer's token page (#151)", async (
//
// Reloading or closing the popup makes Chrome cancel the requests it still has
// open, and in the page a cancelled fetch() fails with the same "Failed to
// fetch" as a server that cannot be reached. The reload during the address
// scan that follows creating a wallet is in the wallet creation test (#475).
// fetch" as a server that cannot be reached. Here rather than straight after
// wallet creation because a reload also cancels the address scan that follows
// it, which still logs (#475).
// Blockscout is held, so the home screen's transaction list and token balances
// cannot have been answered when the popup reloads. The assertion is the
@@ -766,30 +752,6 @@ test("a transaction list that cannot be fetched is still reported (#218)", async
}
});
// The address screen's transaction list, held the same way. The popup reopens
// on the address screen, which asks for its list as it is drawn, so once the
// screen is up its request is in flight (#475).
test("reloading the popup on the address screen reports no failure (#475)", async (env) => {
await openAddressDetail(env.page);
await waitForPersisted(
env.page,
"currentView",
"address",
"before reloading the popup",
);
env.routeOpts.holdBlockscout = true;
try {
await env.page.reload();
await visible(env.page, "#view-address");
await env.page.reload();
} finally {
env.routeOpts.holdBlockscout = false;
}
await visible(env.page, "#view-address");
// Home again, where the tests below expect to start.
await goHome(env.page);
});
// -------------------------------------------- recovery phrase (#161)
// The gear toggles, so pressing it while Settings is already up leaves it.
@@ -1075,13 +1037,13 @@ async function revealPrivkey(page) {
}
// 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
// Home. Leaving takes the export screen off the Back stack, so from Settings
// Back goes to the address screen it was opened from
// (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");
await visible(page, "#view-address");
}
if (await page.isVisible("#view-export-privkey")) {
await page.click("#btn-export-privkey-back");
@@ -2199,7 +2161,9 @@ function quantity(wei) {
// Wait on the main view until a changed balance fixture has been picked up.
//
// Not a reload: the popup re-refreshes on a 10-second timer by itself.
// Not a reload: the popup re-refreshes on a 10-second timer by itself, and a
// reload on the address screen still logs the transaction list it cancels
// (#475).
//
// It also deliberately settles on MAIN rather than on the address screen.
// The address screen builds the send screen's token dropdown once, from the
@@ -4559,9 +4523,6 @@ async function main() {
ethBalanceWei: null,
failGasEstimate: false,
holdGasEstimate: false,
// Whether a JSON-RPC batch asking for a transaction count is held
// unanswered: the address scan after creating a wallet (#475).
holdTransactionCount: false,
// Whether Blockscout requests are held unanswered, and whether an
// address's transaction list fails as a network error (#218).
holdBlockscout: false,
+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", () => {
// 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.
-1
View File
@@ -91,7 +91,6 @@ describe.each([
require("../src/popup/views/" + view).init({
doRefreshAndRender: () => {},
pageClosed: new AbortController().signal,
});
element(field).value = ADDRESS;
await element(button).listeners.click();
+2 -51
View File
@@ -65,7 +65,7 @@ jest.mock("../src/shared/log", () => ({
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: jest.fn(),
errorf: () => {},
},
// The explorer's token list, which refreshBalances() also fetches.
debugFetch: jest.fn(async () => ({
@@ -143,7 +143,6 @@ const { Transaction, Wallet, formatEther } = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
const { encryptWithPassword } = require("../src/shared/vault");
const { state } = require("../src/shared/state");
const { log } = require("../src/shared/log");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
@@ -227,16 +226,10 @@ function tokenRow(value, decimals = "18") {
// The confirmation screen Review leads to, once shown.
let confirmed = null;
// Stands in for the popup's: aborting it is the popup closing.
let pageClosed;
// Open the Send screen for `token` ("ETH" or a token address), with the
// recipient entered.
function openSend(token = "ETH") {
send.init({
showConfirmTx: (info) => (confirmed = info),
pageClosed: pageClosed.signal,
});
send.init({ showConfirmTx: (info) => (confirmed = info) });
confirmTx.init({});
send.resetSendValidation();
state.currentView = "send";
@@ -267,8 +260,6 @@ function canSend() {
beforeEach(() => {
elements.clear();
log.errorf.mockClear();
pageClosed = new AbortController();
confirmed = null;
state.selectedToken = null;
state.trackedTokens = [];
@@ -456,46 +447,6 @@ describe("Max on an ETH send", () => {
expect(text("flash-msg")).toBe("");
});
// Holds the node's fee answer until the returned function fails it, as a
// node that cannot be reached, or a request the popup's closing
// cancelled, does.
function holdFailingFeeEstimate() {
let fail;
mockNode.feeData = new Promise((_, reject) => {
fail = () => reject(new TypeError("Failed to fetch"));
});
return fail;
}
test("reports a fee estimate that fails while the popup is open", async () => {
await refreshWith(BALANCE_WEI);
openSend();
const fail = holdFailingFeeEstimate();
const pressed = pressMax();
fail();
await pressed;
expect(log.errorf).toHaveBeenCalledWith(
"max amount fee estimate failed:",
"Failed to fetch",
);
expect(text("flash-msg")).toBe(
"The network fee could not be estimated.",
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/475
test("does not report a fee estimate that fails once the popup has closed", async () => {
await refreshWith(BALANCE_WEI);
openSend();
const fail = holdFailingFeeEstimate();
const pressed = pressMax();
pageClosed.abort();
fail();
await pressed;
expect(log.errorf).not.toHaveBeenCalled();
expect(el("send-amount").value).toBe("");
});
test("fills in once the held fee estimate arrives with nothing changed", async () => {
await refreshWith(BALANCE_WEI);
openSend();
+2 -23
View File
@@ -50,8 +50,7 @@ function element(id) {
return (elements[id] ||= fakeElement());
}
// `pageClosed` stands in for the popup's: aborting it is the popup closing.
function loadSettingsView(pageClosed = new AbortController()) {
function loadSettingsView() {
elements = {};
flashes = [];
@@ -75,9 +74,7 @@ function loadSettingsView(pageClosed = new AbortController()) {
state.blockscoutUrl = SAVED_BLOCKSCOUT;
require("../src/shared/log").setRuntimeDebug(true);
require("../src/popup/views/settings").init({
pageClosed: pageClosed.signal,
});
require("../src/popup/views/settings").init({});
}
async function save(fieldId, buttonId, typed) {
@@ -149,21 +146,3 @@ test("the Blockscout check of a URL with a user name and password", async () =>
const line = expectFailedWithoutSecrets("Blockscout validation failed");
expect(line).toContain("https://explorer.example.invalid");
});
// In the popup a check its own closing cancelled fails as these do, and is
// not reported (https://git.eeqj.de/sneak/AutistMask/issues/475). Nothing is
// saved either.
test("a check that fails once the popup has closed is not reported", async () => {
const pageClosed = new AbortController();
pageClosed.abort();
loadSettingsView(pageClosed);
await save("settings-rpc", "btn-save-rpc", RPC_UNPARSEABLE);
await save(
"settings-blockscout",
"btn-save-blockscout",
BLOCKSCOUT_WITH_PASSWORD,
);
expect(console.error).not.toHaveBeenCalled();
expect(state.rpcUrl).toBe(SAVED_RPC);
expect(state.blockscoutUrl).toBe(SAVED_BLOCKSCOUT);
});
+70 -7
View File
@@ -1,12 +1,17 @@
// Tests for the recovery phrase display (issue #161).
//
// These cover the parts that do not need a DOM: which wallet types may be
// offered the action at all, the exclusion of the screen from the set of
// views the popup may reopen onto, and the absence of any path from this
// module to the logger. The DOM behaviour it guards — nothing rendered
// before the password is accepted, a wrong password revealing nothing, and
// the wipe on 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.
// These cover which wallet types may be offered the action at all, the
// exclusion of the screen from the set of views the popup may reopen onto,
// the absence of any path from this module to the logger, and, against a
// minimal DOM stub, where Back goes after the screen is left by the settings
// gear. The rest of the DOM behaviour it guards — nothing rendered before the
// password is accepted, a wrong password revealing nothing, and the wipe on
// 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 path = require("path");
@@ -74,6 +79,64 @@ 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.
function makeDocument() {
const els = new Map();
function makeElement() {
const classes = new Set();
return {
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: () => {},
};
}
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", () => {
// 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", () => {
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"];
// 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);
});
});
describe("the phrase cannot reach the logger", () => {
const source = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "views", "showPhrase.js"),
-167
View File
@@ -1,167 +0,0 @@
// The two add-token screens do not report a token lookup the popup's own
// closing cancelled, and still report one that failed while the popup was open
// (https://git.eeqj.de/sneak/AutistMask/issues/475).
//
// In the popup a cancelled request fails with the same "Failed to fetch" as a
// server that cannot be reached, so every RPC request here fails that way, and
// only the signal the popup aborts on pagehide tells the two cases apart. The
// real lookupTokenInfo() runs, which logs a failed lookup itself before the
// screen does. Driven against the fake elements tests/flashLine.test.js uses.
// The last tests call lookupTokenInfo() directly with symbol() answered, so
// that its later calls are the ones that fail.
const {
FetchRequest,
Interface,
toUtf8Bytes,
toUtf8String,
} = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const RPC_URL = "https://rpc.example.invalid";
let elements;
function fakeElement() {
return {
value: "",
textContent: "",
style: {},
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
};
}
// Stands in for document.getElementById(): one fake element per id.
function element(id) {
return (elements[id] ||= fakeElement());
}
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
showFlash: () => {},
escapeHtml: (s) => s,
goBack: () => {},
}));
// state.js reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const { lookupTokenInfo } = require("../src/shared/balances");
let logged;
beforeEach(() => {
elements = {};
logged = [];
state.trackedTokens = [];
for (const method of ["warn", "error"]) {
jest.spyOn(console, method).mockImplementation((...args) => {
logged.push(args.map(String).join(" "));
});
}
FetchRequest.registerGetUrl(async () => {
throw new TypeError("Failed to fetch");
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
jest.restoreAllMocks();
});
describe.each([
["addToken", "add-token-address", "btn-add-token-confirm"],
[
"settingsAddToken",
"settings-addtoken-address",
"btn-settings-addtoken-manual",
],
])("looking up a token on %s", (view, field, button) => {
// Clicks the screen's add button for ADDRESS and waits for the lookup.
async function add(pageClosed) {
require("../src/popup/views/" + view).init({
doRefreshAndRender: () => {},
pageClosed,
});
element(field).value = ADDRESS;
await element(button).listeners.click();
}
test("a failure while the popup is open is reported", async () => {
await add(new AbortController().signal);
expect(logged).toHaveLength(2);
expect(logged[0]).toContain("symbol() failed:");
expect(logged[1]).toBe(
"[AutistMask] Adding token failed for " +
ADDRESS +
" Not a valid ERC-20 token (symbol() failed).",
);
});
test("a failure once the popup has closed is not", async () => {
const pageClosed = new AbortController();
pageClosed.abort();
await add(pageClosed.signal);
expect(logged).toEqual([]);
});
});
// Answers the contract call for each function named in `answers` with its
// value, and fails every other request as above.
function answer(answers) {
const erc20 = new Interface(ERC20_ABI);
FetchRequest.registerGetUrl(async (req) => {
const { id, params } = JSON.parse(toUtf8String(req.body));
const { name } = erc20.parseTransaction({ data: params[0].data });
if (!(name in answers)) {
throw new TypeError("Failed to fetch");
}
const result = erc20.encodeFunctionResult(name, [answers[name]]);
return {
statusCode: 200,
statusMessage: "OK",
headers: {},
body: toUtf8Bytes(JSON.stringify({ jsonrpc: "2.0", id, result })),
};
});
}
describe.each([
["decimals() fails", { symbol: "TKN" }, "decimals() failed:"],
[
"name() fails",
{ symbol: "TKN", decimals: 18 },
"name() failed, using symbol as name:",
],
])("a token lookup where %s", (_, answers, report) => {
async function lookUp(pageClosed) {
// A token found is logged at info level, which is not under test.
jest.spyOn(console, "log").mockImplementation(() => {});
answer(answers);
// A failed decimals() also fails the lookup, which the screens report
// as tested above.
await lookupTokenInfo(ADDRESS, RPC_URL, "mainnet", pageClosed).catch(
() => {},
);
}
test("is reported while the popup is open", async () => {
await lookUp(new AbortController().signal);
expect(logged).toEqual([expect.stringContaining(report)]);
});
test("is not once the popup has closed", async () => {
const pageClosed = new AbortController();
pageClosed.abort();
await lookUp(pageClosed.signal);
expect(logged).toEqual([]);
});
});
-181
View File
@@ -1,181 +0,0 @@
// The address and token screens do not report a transaction list, or an ENS
// name lookup for the addresses in it, that the popup's own closing cancelled,
// and still report one that failed while the popup was open
// (https://git.eeqj.de/sneak/AutistMask/issues/475).
//
// In the popup a cancelled request fails with the same "Failed to fetch" as a
// server that cannot be reached, so the requests here fail that way, and only
// the signal the popup aborts on pagehide tells the two cases apart. Driven
// against a minimal DOM stub in the shape tests/timestampDisplay.test.js uses.
// The explorer answers with mockHistory, and fails when it is null.
let mockHistory = null;
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: async () => {
if (mockHistory === null) throw new TypeError("Failed to fetch");
return mockHistory;
},
}));
// Every ENS name lookup fails.
jest.mock("../src/shared/balances", () => ({
...jest.requireActual("../src/shared/balances"),
getProvider: () => ({
lookupAddress: async () => {
throw new TypeError("Failed to fetch");
},
}),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A transaction ADDRESS sent, as the history lists hold it.
function historyTx() {
return {
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
from: ADDRESS,
to: RECIPIENT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
};
}
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
let logged;
beforeEach(() => {
mockHistory = null;
logged = [];
jest.spyOn(console, "error").mockImplementation((...args) => {
logged.push(args.map(String).join(" "));
});
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.wallets = [
{
name: "Main",
type: "key",
addresses: [{ address: ADDRESS, balance: "0.0000" }],
},
];
state.trackedTokens = [];
state.viewStack = [];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
});
afterEach(() => {
jest.restoreAllMocks();
});
describe.each([
["the address screen", addressDetail, "tx-list"],
["the token screen", addressToken, "address-token-tx-list"],
])("the transaction list on %s", (_name, view, listId) => {
// Open the screen and wait for its transaction list to load or fail.
async function open(pageClosed) {
view.init({ pageClosed });
view.show();
await new Promise((resolve) => setTimeout(resolve, 0));
}
test("a failure while the popup is open is reported", async () => {
await open(new AbortController().signal);
expect(logged).toEqual([
"[AutistMask] loadTransactions failed: Failed to fetch",
]);
expect(node(listId).innerHTML).toContain(
"Failed to load transactions.",
);
});
test("a failure once the popup has closed is not", async () => {
const pageClosed = new AbortController();
pageClosed.abort();
await open(pageClosed.signal);
expect(logged).toEqual([]);
});
test("a name lookup that fails while the popup is open is reported", async () => {
mockHistory = [historyTx()];
await open(new AbortController().signal);
expect(logged).toContain(
"[AutistMask] ENS reverse lookup failed " +
RECIPIENT +
" Failed to fetch",
);
expect(node(listId).innerHTML).toContain("tx-row");
});
test("a name lookup that fails once the popup has closed is not", async () => {
mockHistory = [historyTx()];
const pageClosed = new AbortController();
pageClosed.abort();
await open(pageClosed.signal);
expect(logged).toEqual([]);
expect(node(listId).innerHTML).toContain("tx-row");
});
});
+2 -35
View File
@@ -21,7 +21,7 @@ jest.mock("../src/shared/log", () => ({
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: jest.fn(),
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
@@ -108,7 +108,6 @@ global.chrome = { storage };
const txStatus = require("../src/popup/views/txStatus");
const { state } = require("../src/shared/state");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const { log } = require("../src/shared/log");
const TX_HASH =
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
@@ -129,24 +128,16 @@ function waitStatusText() {
return getElement("wait-tx-status").textContent;
}
// Stands in for the popup's: aborting it is the popup closing.
let pageClosed;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
elements.clear();
mockReceiptLookup.mockReset();
log.errorf.mockClear();
state.wallets = [];
state.viewData = {};
state.viewStack = [];
state.currentView = null;
pageClosed = new AbortController();
txStatus.init({
doRefreshAndRender: jest.fn(),
pageClosed: pageClosed.signal,
});
txStatus.init({ doRefreshAndRender: jest.fn() });
});
afterEach(() => {
@@ -475,30 +466,6 @@ describe("WaitTx against an RPC that never answers", () => {
});
});
// In the popup a lookup its own closing cancelled fails as a lookup against an
// RPC that cannot be reached does, and is not reported
// (https://git.eeqj.de/sneak/AutistMask/issues/475).
describe("WaitTx when the popup closes", () => {
test("a lookup that fails while the popup is open is reported", async () => {
mockReceiptLookup.mockRejectedValue(new TypeError("Failed to fetch"));
txStatus.showWait(TX_INFO, TX_HASH);
await jest.advanceTimersByTimeAsync(10000);
expect(log.errorf).toHaveBeenCalledWith(
"poll receipt failed:",
"Failed to fetch",
);
});
test("a lookup that fails once the popup has closed is not", async () => {
mockReceiptLookup.mockRejectedValue(new TypeError("Failed to fetch"));
txStatus.showWait(TX_INFO, TX_HASH);
pageClosed.abort();
await jest.advanceTimersByTimeAsync(10000);
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
expect(log.errorf).not.toHaveBeenCalled();
});
});
describe("wait-tx is a view the popup may reopen onto", () => {
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
// refuses any view not in the set, so dropping "wait-tx" from it kills