Compare commits

..

1 Commits

Author SHA1 Message Date
588d5fd8bd fix: render the view "Back" lands on after the popup is reopened (closes #268)
All checks were successful
check / check (push) Successful in 55s
goBack() only unhid its target. A reopened popup renders the wallet list
and the one view restoreView() lands on, so every other view is still the
blank static template from index.html: pressing Back from Settings onto an
address showed an empty address line and no balances, and the same held for
address-token, receive, confirm-tx and transaction.

The per-view dispatch and its data guards move out of restoreView() into
src/popup/viewRouter.js, and goBack() now routes a popped view through the
same code by way of a renderer index.js registers with setBackRenderer().
A view whose backing state is gone falls back to Home the way the restore
does, rather than showing an empty template.

The Back path renders only a view this page load has not rendered yet.
viewRouter.js keeps a page-load-scoped set of rendered views, written by
showView() — the last thing every render path runs, forward navigation and
the restore alike, so a view added later registers itself rather than
needing to be remembered. Back onto a view already in that set unhides it
and nothing more, exactly as it does for a view outside RESTORABLE_VIEWS:
rendering a second time would re-fetch and overwrite what the view holds,
such as an edit typed into Settings and not yet saved. Home stays the
exception and is re-rendered on every Back, as it was when goBack() called
the renderWalletList() registered through setRenderMain(), so the wallet
list reflects what changed while the user was away from it.

tests/backNavigation.test.js drives the real goBack() over the reproduction
and over each of address-token, receive, confirm-tx, transaction, success-tx
and error-tx, with and without their backing state, and pins that a view
this page load already rendered — a live-session view, or Settings revisited
— is only unhidden, while Home still re-renders. tests/e2e/run.js adds three
cases against the real popup in a real browser, because make check cannot
see a blank view: a real close and reopen then Back onto the address screen
and onto Receive, and an in-session Back onto Settings that must keep
unsaved input. All three were demonstrated failing first.
2026-08-14 04:11:12 +00:00
3 changed files with 8 additions and 338 deletions

16
TODO.md
View File

@@ -45,22 +45,6 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-14: The parts of the
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) definition of done the
e2e suite did not cover are asserted. It had only shown that the two screens
open without throwing. Now: the Add Token round trip leaves the navigation
stack exactly as it found it, read out of extension storage rather than
inferred from which screen is up, so an orphaned entry — the second-order
damage of #150 — is caught where it happens rather than one Back press later;
a common-token quick-pick puts its contract address in the field; the native
ETH detail path renders with its own type, value and raw quantity and with the
token contract row still hidden, against a new `seedNativeTransfer` fixture,
since the normal-transactions endpoint answered `[]` unconditionally and there
was no non-ERC-20 row to open; and tapping the token contract address puts it
on the real clipboard, read back after a sentinel write. Each of the four was
demonstrated failing against a deliberately broken build
([#188](https://git.eeqj.de/sneak/AutistMask/issues/188)).
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js` - 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
rebuilt every failure as `new Error(error.message)`, so the code the rebuilt every failure as `new Error(error.message)`, so the code the
background produced and the content script relayed intact was dropped in the background produced and the content script relayed intact was dropped in the

View File

@@ -46,24 +46,9 @@ const STUB_TX_HASH =
const STUB_BLOCK_NUMBER = 21000000; const STUB_BLOCK_NUMBER = 21000000;
// The native ETH transfer, seeded by opts.seedNativeTransfer. Its own hash
// and an older block, so it is a second row rather than a leg of the token
// transfer: mergeTransactions() consolidates a native entry and a token
// transfer that share a hash into one row, which would leave nothing native
// to open. 0.25 ETH clears the 100000 gwei dust threshold the default
// filters apply, so the row is not silently dropped.
const STUB_NATIVE_TX_HASH =
"0xe7e0000000000000000000000000000000000000000000000000000000000e7e";
const STUB_NATIVE_BLOCK_NUMBER = STUB_BLOCK_NUMBER - 1;
const STUB_NATIVE_VALUE_WEI = "250000000000000000";
// Fixed instant so timeAgo() output is stable across runs. // Fixed instant so timeAgo() output is stable across runs.
const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z"; const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z";
const STUB_NATIVE_TX_TIMESTAMP = "2026-01-02T02:03:04.000000Z";
// A 32-byte zero word. Returned for every eth_call, which is what makes // A 32-byte zero word. Returned for every eth_call, which is what makes
// ethers' ENS reverse lookup resolve to "no resolver set" and return null // ethers' ENS reverse lookup resolve to "no resolver set" and return null
// instead of throwing. A throw would be logged by src/shared/ens.js via // instead of throwing. A throw would be logged by src/shared/ens.js via
@@ -273,25 +258,6 @@ function tokenTransferItems(address) {
]; ];
} }
// One received native ETH transfer, in the shape src/shared/transactions.js
// parses. to.is_contract is false and there is no method, so parseTx() keeps
// it a plain transfer rather than a contract call — which is what makes the
// detail screen classify it "Native ETH Transfer" and leave the token
// contract row hidden.
function nativeTransactionItems(address) {
return [
{
hash: STUB_NATIVE_TX_HASH,
block_number: STUB_NATIVE_BLOCK_NUMBER,
timestamp: STUB_NATIVE_TX_TIMESTAMP,
from: { hash: STUB_COUNTERPARTY },
to: { hash: address, is_contract: false },
value: STUB_NATIVE_VALUE_WEI,
status: "ok",
},
];
}
// A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving // A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving
// this is what puts an ERC-20 in the send screen's token dropdown, which is // this is what puts an ERC-20 in the send screen's token dropdown, which is
// the only way the confirmation screen's ERC-20 path can be reached. // the only way the confirmation screen's ERC-20 path can be reached.
@@ -304,17 +270,12 @@ function tokenBalanceItems() {
]; ];
} }
// Full details for either seeded transaction — the detail screen fetches // Full details for STUB_TX_HASH. raw_input is "0x" so the calldata
// them for whichever row was opened, and an unstubbed hash would be // decoder short-circuits; the on-chain detail fields still populate.
// reported as escaping traffic. raw_input is "0x" so the calldata decoder function transactionDetails() {
// short-circuits; the on-chain detail fields still populate.
function transactionDetails(hash) {
return { return {
hash: hash, hash: STUB_TX_HASH,
block_number: block_number: STUB_BLOCK_NUMBER,
hash === STUB_NATIVE_TX_HASH
? STUB_NATIVE_BLOCK_NUMBER
: STUB_BLOCK_NUMBER,
nonce: 7, nonce: 7,
gas_used: "51000", gas_used: "51000",
gas_price: "1000000000", gas_price: "1000000000",
@@ -518,10 +479,6 @@ function traceEnabled(raw) {
* @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20 * @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20
* transfer. Read at request time, so a test can flip it on the same * transfer. Read at request time, so a test can flip it on the same
* options object without re-registering the route. * options object without re-registering the route.
* @param {boolean} [opts.seedNativeTransfer] serve the stubbed native ETH
* transfer, read at request time like seedTokenTransfer. Without it the
* normal-transactions endpoint answers with an empty list, so there is no
* non-ERC-20 row to open.
* @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20 * @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20
* holding, which is what makes the token reachable from the send screen. * holding, which is what makes the token reachable from the send screen.
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance; * @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
@@ -593,13 +550,7 @@ async function installNetworkStubs(ctx, opts) {
// Blockscout v2 // Blockscout v2
if (p.includes("/api/v2/")) { if (p.includes("/api/v2/")) {
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
const addr = blockscoutAddress(p); return jsonResponse(route, { items: [] });
return jsonResponse(route, {
items:
opts.seedNativeTransfer && addr
? nativeTransactionItems(addr)
: [],
});
} }
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) {
const addr = blockscoutAddress(p); const addr = blockscoutAddress(p);
@@ -616,10 +567,8 @@ async function installNetworkStubs(ctx, opts) {
opts.seedTokenBalance ? tokenBalanceItems() : [], opts.seedTokenBalance ? tokenBalanceItems() : [],
); );
} }
for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) { if (p.endsWith("/transactions/" + STUB_TX_HASH)) {
if (p.endsWith("/transactions/" + hash)) { return jsonResponse(route, transactionDetails());
return jsonResponse(route, transactionDetails(hash));
}
} }
} }
@@ -691,8 +640,6 @@ module.exports = {
FEE_ESTIMATE_WEI, FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI, FEE_RESERVE_WEI,
STUB_COUNTERPARTY, STUB_COUNTERPARTY,
STUB_NATIVE_TX_HASH,
STUB_NATIVE_VALUE_WEI,
STUB_TOKEN, STUB_TOKEN,
STUB_TX_HASH, STUB_TX_HASH,
}; };

View File

@@ -36,8 +36,6 @@ const {
FEE_ESTIMATE_WEI, FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI, FEE_RESERVE_WEI,
STUB_COUNTERPARTY, STUB_COUNTERPARTY,
STUB_NATIVE_TX_HASH,
STUB_NATIVE_VALUE_WEI,
STUB_TOKEN, STUB_TOKEN,
STUB_TX_HASH, STUB_TX_HASH,
} = require("./network"); } = require("./network");
@@ -171,264 +169,6 @@ test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
assert(dots > 0, "token contract row rendered without its colour dot"); assert(dots > 0, "token contract row rendered without its colour dot");
}); });
// --------------------- the rest of the #150 and #151 definition of done
//
// The two tests above assert that the screens #150 and #151 broke now open
// without throwing, which is narrower than what those issues asked for.
// The four items below are the remainder (#188): the navigation stack out
// of Add Token, the quick-pick actually populating the field, the native
// ETH detail path the ERC-20 fix could have regressed, and tap-to-copy.
// Leave the transaction detail screen for the address screen it was opened
// from. The two tests above finish on it, and so does the last test here.
async function leaveTransactionDetail(page) {
if (await page.isVisible("#view-transaction")) {
await page.click("#btn-tx-back");
}
await openAddressDetail(page);
}
// Back out to Home from wherever the previous test finished.
async function goHome(page) {
await leaveTransactionDetail(page);
await page.click("#btn-address-back");
await visible(page, "#view-main");
}
// The navigation stack as it was actually persisted, read out of extension
// storage rather than inferred from which screen is showing. A stale entry
// left behind by a forward navigation that threw is invisible on screen
// until the user presses Back one time too many — which is exactly the
// second-order damage #150 did — so the stack itself is what gets asserted.
function persistedViewStack(page) {
return page.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
resolve((r.autistmask && r.autistmask.viewStack) || []);
});
}),
);
}
// saveState() is fired from showView() without being awaited, so the write
// lands shortly after the screen does. Polling for the expected stack keeps
// that race out of the assertion; a stack that never becomes the expected
// one fails with what it actually was.
const VIEW_STACK_SETTLE_MS = 5000;
async function waitForViewStack(page, expected, where) {
const want = JSON.stringify(expected);
const deadline = Date.now() + VIEW_STACK_SETTLE_MS;
let seen;
for (;;) {
seen = await persistedViewStack(page);
if (JSON.stringify(seen) === want) return;
if (Date.now() >= deadline) break;
await sleep(50);
}
throw new Error(
"navigation stack " +
where +
" is " +
JSON.stringify(seen) +
", expected " +
want,
);
}
// The invariant is stated as a delta against whatever the earlier tests
// left on the stack, not as an absolute: a round trip into Add Token and
// back out must leave the stack exactly as it found it. That is what "no
// duplicated or orphaned stack entry" means, and it holds whatever the
// starting depth is.
test("Back from Add Token unwinds the stack exactly once (#150)", async (env) => {
await goHome(env.page);
const base = await persistedViewStack(env.page);
await env.page.locator("#wallet-list .btn-addr-info").first().click();
await visible(env.page, "#view-address");
await waitForViewStack(env.page, base.concat("main"), "on address detail");
await env.page.click("#btn-add-token");
await visible(env.page, "#view-add-token");
await waitForViewStack(
env.page,
base.concat("main", "address"),
"on the add token screen",
);
await env.page.click("#btn-add-token-back");
await visible(env.page, "#view-address");
assert(
!(await env.page.isVisible("#view-add-token")),
"the add token screen is still showing after Back",
);
await waitForViewStack(
env.page,
base.concat("main"),
"after Back from add token",
);
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await waitForViewStack(env.page, base, "after a second Back");
});
test("a common-token quick-pick fills in the contract address (#150)", async (env) => {
await openAddressDetail(env.page);
await env.page.click("#btn-add-token");
await visible(env.page, "#view-add-token");
const before = await env.page.inputValue("#add-token-address");
assert(
before === "",
"the add token screen opened with the address field already filled: " +
JSON.stringify(before),
);
const pick = env.page.locator("#common-token-list .common-token").first();
const wanted = await pick.getAttribute("data-address");
assert(
/^0x[0-9a-fA-F]{40}$/.test(wanted || ""),
"the first quick-pick button carries no contract address: " +
JSON.stringify(wanted),
);
await pick.click();
const after = await env.page.inputValue("#add-token-address");
assert(
after === wanted,
"clicking the " +
(await pick.innerText()).trim() +
" quick-pick left the address field as " +
JSON.stringify(after) +
", expected " +
JSON.stringify(wanted),
);
await env.page.click("#btn-add-token-back");
await visible(env.page, "#view-address");
});
// The native amount as the transaction list writes it (four decimals) and
// as the detail screen writes it (full precision). Both are rendered here
// from the fixture rather than read off the screen, so the assertions
// compare against the wei the stub served.
const NATIVE_ROW_TEXT =
parseFloat(formatEther(STUB_NATIVE_VALUE_WEI)).toFixed(4) + " ETH";
const NATIVE_DETAIL_TEXT = formatEther(STUB_NATIVE_VALUE_WEI) + " ETH";
test("the native ETH transaction detail still renders (#151)", async (env) => {
// The ERC-20 fix could only have regressed this path by making the
// token-contract branch run for a transfer that has no contract, so
// the assertions below are as much about that row staying hidden as
// about the screen coming up.
env.routeOpts.seedNativeTransfer = true;
await env.page.reload();
await openAddressDetail(env.page);
const row = env.page
.locator("#tx-list .tx-row")
.filter({ hasText: NATIVE_ROW_TEXT });
await row.waitFor({ state: "visible", timeout: 30000 });
await row.click();
await visible(env.page, "#view-transaction");
const hash = await env.page.locator("#tx-detail-hash").innerText();
assert(
hash.includes(STUB_NATIVE_TX_HASH),
"the native transaction detail shows the wrong hash: " + hash,
);
const type = (await env.page.locator("#tx-detail-type").innerText()).trim();
assert(
type === "Native ETH Transfer",
"the native transaction was classified " + JSON.stringify(type),
);
const value = await env.page.locator("#tx-detail-value").innerText();
assert(
value.includes(NATIVE_DETAIL_TEXT),
"the native transaction detail shows " +
JSON.stringify(value) +
", expected it to contain " +
NATIVE_DETAIL_TEXT,
);
const native = await env.page.locator("#tx-detail-native").innerText();
assert(
native.includes(STUB_NATIVE_VALUE_WEI + " wei"),
"the raw quantity row shows " +
JSON.stringify(native) +
", expected the value in wei",
);
assert(
!(await env.page.isVisible("#tx-detail-token-contract-section")),
"the token contract row is showing on a transfer that has no token " +
"contract",
);
// Back to one seeded transaction for everything after this: the tests
// below were written against a list holding the token transfer alone.
env.routeOpts.seedNativeTransfer = false;
});
test("tap-to-copy on the transaction detail screen copies the address (#151)", async (env) => {
// Read the clipboard back rather than watching the handler run: what
// #151 asks for is the address reaching the clipboard, and a spy on
// navigator.clipboard would assert the call and not the effect.
//
// Granted context-wide rather than for the popup's origin: an
// origin-scoped grant is refused for chrome-extension: URLs, which
// both Playwright and Chrome treat as opaque here.
await env.ctx.grantPermissions(["clipboard-read", "clipboard-write"]);
await leaveTransactionDetail(env.page);
const row = env.page
.locator("#tx-list .tx-row")
.filter({ hasText: STUB_TOKEN.symbol });
await row.waitFor({ state: "visible", timeout: 30000 });
await row.click();
await visible(env.page, "#view-transaction");
await visible(env.page, "#tx-detail-token-contract-section");
// Seed a sentinel first, so a clipboard that nothing writes to cannot
// pass on whatever was left in it.
const SENTINEL = "e2e-clipboard-untouched";
await env.page.evaluate((s) => navigator.clipboard.writeText(s), SENTINEL);
const seeded = await env.page.evaluate(() =>
navigator.clipboard.readText(),
);
assert(
seeded === SENTINEL,
"the harness could not seed the clipboard, so the assertion below " +
"would prove nothing; it read back " +
JSON.stringify(seeded),
);
await env.page.locator("#tx-detail-token-contract [data-copy]").click();
const copied = await env.page.evaluate(() =>
navigator.clipboard.readText(),
);
assert(
copied.toLowerCase() === STUB_TOKEN.address,
"tapping the token contract address put " +
JSON.stringify(copied) +
" on the clipboard, expected " +
STUB_TOKEN.address,
);
const flash = await env.page.locator("#flash-msg").innerText();
assert(
flash.trim() === "Copied!",
"the copy gave no confirmation, flash line reads " +
JSON.stringify(flash),
);
});
// -------------------------------------------- recovery phrase (#161) // -------------------------------------------- recovery phrase (#161)
// The gear toggles, so pressing it while Settings is already up leaves it. // The gear toggles, so pressing it while Settings is already up leaves it.
@@ -2631,7 +2371,6 @@ async function main() {
// starting state of a run is readable without hunting through tests. // starting state of a run is readable without hunting through tests.
const routeOpts = { const routeOpts = {
seedTokenTransfer: false, seedTokenTransfer: false,
seedNativeTransfer: false,
seedTokenBalance: false, seedTokenBalance: false,
ethBalanceWei: null, ethBalanceWei: null,
failGasEstimate: false, failGasEstimate: false,