From e53bcb655d7e8d8b0d399be915b708c4ee81fdb6 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 14 Aug 2026 04:21:27 +0000 Subject: [PATCH] test: assert the #150 and #151 items the harness did not cover (closes #188) The suite asserted that the two screens those issues broke now open without throwing, which is narrower than their definition of done. The four remaining items are asserted here, additively; nothing existing was restructured. Back navigation out of Add Token is checked against the persisted navigation stack, read from extension storage, as a delta: the round trip Home -> AddressDetail -> AddToken -> Back -> Back must leave the stack exactly as it found it. A stale entry is invisible on screen until the user presses Back one time too many, which is precisely the second-order damage of #150, so the stack rather than the visible view is what gets asserted. Stating it as a delta keeps it independent of whatever depth earlier tests leave behind. The quick-pick test clicks a button and requires the address field to hold that button's contract address; the old assertion only counted the buttons rendered. The native ETH detail path needed a fixture: the normal-transactions endpoint answered with an empty list unconditionally, so there was no non-ERC-20 row to open at all. seedNativeTransfer serves one, and the detail screen must show the native type, the value, the raw wei quantity and no token contract row - the row whose branch is where a regression of the non-ERC-20 case would land. Tap-to-copy reads the real clipboard back rather than watching the handler run, after seeding a sentinel so an untouched clipboard cannot pass. Clipboard permissions are granted context-wide because an origin-scoped grant is refused for chrome-extension: URLs. Each of the four was demonstrated failing against a deliberately broken build; the captured output is in the pull request. --- TODO.md | 16 +++ tests/e2e/network.js | 69 ++++++++++-- tests/e2e/run.js | 261 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index 86fd8b7..5d5d0d1 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,22 @@ undefined identifiers, which is how # 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` rebuilt every failure as `new Error(error.message)`, so the code the background produced and the content script relayed intact was dropped in the diff --git a/tests/e2e/network.js b/tests/e2e/network.js index 64f43a3..a820f14 100644 --- a/tests/e2e/network.js +++ b/tests/e2e/network.js @@ -46,9 +46,24 @@ const STUB_TX_HASH = 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. 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 // 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 @@ -258,6 +273,25 @@ 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 // 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. @@ -270,12 +304,17 @@ function tokenBalanceItems() { ]; } -// Full details for STUB_TX_HASH. raw_input is "0x" so the calldata -// decoder short-circuits; the on-chain detail fields still populate. -function transactionDetails() { +// Full details for either seeded transaction — the detail screen fetches +// them for whichever row was opened, and an unstubbed hash would be +// reported as escaping traffic. raw_input is "0x" so the calldata decoder +// short-circuits; the on-chain detail fields still populate. +function transactionDetails(hash) { return { - hash: STUB_TX_HASH, - block_number: STUB_BLOCK_NUMBER, + hash: hash, + block_number: + hash === STUB_NATIVE_TX_HASH + ? STUB_NATIVE_BLOCK_NUMBER + : STUB_BLOCK_NUMBER, nonce: 7, gas_used: "51000", gas_price: "1000000000", @@ -479,6 +518,10 @@ function traceEnabled(raw) { * @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20 * transfer. Read at request time, so a test can flip it on the same * 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 * holding, which is what makes the token reachable from the send screen. * @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance; @@ -550,7 +593,13 @@ async function installNetworkStubs(ctx, opts) { // Blockscout v2 if (p.includes("/api/v2/")) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) { - return jsonResponse(route, { items: [] }); + const addr = blockscoutAddress(p); + return jsonResponse(route, { + items: + opts.seedNativeTransfer && addr + ? nativeTransactionItems(addr) + : [], + }); } if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) { const addr = blockscoutAddress(p); @@ -567,8 +616,10 @@ async function installNetworkStubs(ctx, opts) { opts.seedTokenBalance ? tokenBalanceItems() : [], ); } - if (p.endsWith("/transactions/" + STUB_TX_HASH)) { - return jsonResponse(route, transactionDetails()); + for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) { + if (p.endsWith("/transactions/" + hash)) { + return jsonResponse(route, transactionDetails(hash)); + } } } @@ -640,6 +691,8 @@ module.exports = { FEE_ESTIMATE_WEI, FEE_RESERVE_WEI, STUB_COUNTERPARTY, + STUB_NATIVE_TX_HASH, + STUB_NATIVE_VALUE_WEI, STUB_TOKEN, STUB_TX_HASH, }; diff --git a/tests/e2e/run.js b/tests/e2e/run.js index 3998316..d22de3c 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -36,6 +36,8 @@ const { FEE_ESTIMATE_WEI, FEE_RESERVE_WEI, STUB_COUNTERPARTY, + STUB_NATIVE_TX_HASH, + STUB_NATIVE_VALUE_WEI, STUB_TOKEN, STUB_TX_HASH, } = require("./network"); @@ -169,6 +171,264 @@ test("transaction detail renders an ERC-20 transfer (#151)", async (env) => { 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) // The gear toggles, so pressing it while Settings is already up leaves it. @@ -2371,6 +2631,7 @@ async function main() { // starting state of a run is readable without hunting through tests. const routeOpts = { seedTokenTransfer: false, + seedNativeTransfer: false, seedTokenBalance: false, ethBalanceWei: null, failGasEstimate: false,