Compare commits

...
2 Commits
Author SHA1 Message Date
sneak 3ddea28ed8 test: drive WaitTx's timeout and failed-lookup exits end to end (closes #315)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
Two Chrome end-to-end cases send ETH and leave the wait for its receipt
running. In one, lookups answer "no receipt" until the 60-second deadline
ends the wait with the timeout message. In the other, a new fixture switch
makes every receipt lookup fail, and the sixth failure in a row ends the
wait with the message naming the unreachable network. Both check the exact
message and that Done returns to the address screen. Both wait in real
time: Playwright's clock would apply to every later test, and backdating
the stored broadcast time races the popup's own save.

Model: opus-5-5
2026-10-05 12:00:17 +00:00
clawbot 9bd607b411 test: assert the last #150 and #151 items in the Chrome suite (closes #295)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
Adding a token by its contract address is checked against the address
screen's balance list. TransactionDetail opened from the token screen is
checked on the persisted navigation stack, on arrival and after Back,
which is what tells it apart from the address screen's entry point. The
token contract row's explorer link is read off the anchor, not followed,
so it needs no network fixture.

The network stub now answers symbol() and name() for the stub token,
which Add Token reads; before, both decoded as empty strings. The token
stays tracked for the rest of the run.

Model: opus-5-5
2026-10-05 13:59:18 +02:00
4 changed files with 240 additions and 7 deletions
+9 -1
View File
@@ -376,6 +376,12 @@ reserve while sitting on the same side of the estimate, so swapping the two in
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only. previously correct by reading only.
It also covers both ways the wait for a sent transaction's receipt ends on the
error screen: lookups that still find no receipt 60 seconds after the broadcast,
and six lookups in a row that fail. Each must show its own message, and Done
must lead back to the address screen. Both wait in real time, about a minute
each.
It also covers the **dApp approval round trips** — the one place where the It also covers the **dApp approval round trips** — the one place where the
content script, the inpage provider, the background worker and the approval content script, the inpage provider, the background worker and the approval
popup all have to work together. A local test page is served by the route popup all have to work together. A local test page is served by the route
@@ -640,7 +646,9 @@ suite outright if its network interception is not in effect.
Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
Firefox and geckodriver downloads the same way. Firefox and geckodriver downloads the same way. The `e2e-chrome` figures predate
the two cases that wait for a receipt to end in error, which add about two
minutes of real waiting.
### Element id guard (part of `make check`) ### Element id guard (part of `make check`)
+21
View File
@@ -45,6 +45,27 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-10-05: The Chrome end-to-end suite drives both ways the wait for a
transaction's receipt ends on the error screen
([#315](https://git.eeqj.de/sneak/AutistMask/issues/315)): lookups that still
find no receipt 60 seconds after the broadcast end it with the timeout
message, and six lookups that fail in a row end it with the message naming the
unreachable network. Done then returns to the address screen. Both cases wait
in real time, about a minute each. Playwright's clock would apply to every
later test in the run and cannot be removed, and moving the stored broadcast
time back can be undone by the save the popup makes every ten seconds.
- 2026-10-05: The Chrome end-to-end suite covers the last of the
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) items
([#295](https://git.eeqj.de/sneak/AutistMask/issues/295)): a token added on
Add Token by its contract address is listed on the address screen;
TransactionDetail opened from the token screen leaves that screen on the
persisted navigation stack, and Back returns to it; and the token contract row
links to the explorer's token page, read off the link rather than followed.
The network stub answers `symbol()` and `name()` for the stub token, which
adding it reads.
- 2026-10-05: Each control that leads to a signature or to the private key has a - 2026-10-05: Each control that leads to a signature or to the private key has a
test that it refuses a defective wallet before asking for a password test that it refuses a defective wallet before asking for a password
([#254](https://git.eeqj.de/sneak/AutistMask/issues/254)): Send on the main, ([#254](https://git.eeqj.de/sneak/AutistMask/issues/254)): Send on the main,
+30 -6
View File
@@ -22,7 +22,7 @@
"use strict"; "use strict";
const { Transaction } = require("ethers"); const { AbiCoder, Transaction } = require("ethers");
// Fictional ERC-20 used to seed the transaction-detail test. The symbol // Fictional ERC-20 used to seed the transaction-detail test. The symbol
// must not collide with any entry in src/shared/tokenList.js, or // must not collide with any entry in src/shared/tokenList.js, or
@@ -244,12 +244,16 @@ function latestBlock() {
}; };
} }
// keccak("decimals()")[0:4]. // keccak("decimals()")[0:4], and the same for symbol() and name().
const SELECTOR_DECIMALS = "0x313ce567"; const SELECTOR_DECIMALS = "0x313ce567";
const SELECTOR_SYMBOL = "0x95d89b41";
const SELECTOR_NAME = "0x06fdde03";
// Every eth_call still answers with a zero word except decimals() on the // Every eth_call still answers with a zero word except decimals(), symbol()
// stub token, which the wallet reads back at signing time to compare with // and name() on the stub token. The wallet reads decimals() back at signing
// the scale the confirmation screen rendered (issue #305). // time to compare with the scale the confirmation screen rendered (issue
// #305). Adding the token by its contract address reads all three (issue
// #295); symbol() and name() answer what the explorer reports for it.
// //
// opts.tokenDecimalsOverride is the lying contract: set it and decimals() // opts.tokenDecimalsOverride is the lying contract: set it and decimals()
// answers something other than the value this same fixture reports through // answers something other than the value this same fixture reports through
@@ -263,9 +267,17 @@ function ethCallResult(req, opts) {
if (!call || typeof call !== "object") return ZERO_WORD; if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase(); const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase(); const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) { if (to !== STUB_TOKEN.address) return ZERO_WORD;
if (data.startsWith(SELECTOR_DECIMALS)) {
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals); return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
} }
const abi = AbiCoder.defaultAbiCoder();
if (data.startsWith(SELECTOR_SYMBOL)) {
return abi.encode(["string"], [tokenObject(opts).symbol]);
}
if (data.startsWith(SELECTOR_NAME)) {
return abi.encode(["string"], [tokenObject(opts).name]);
}
return ZERO_WORD; return ZERO_WORD;
} }
@@ -448,6 +460,16 @@ function rpcReply(req, opts, report) {
return Object.assign(envelope, { result: ethCallResult(req, opts) }); return Object.assign(envelope, { result: ethCallResult(req, opts) });
} }
if (req.method === "eth_getTransactionReceipt") { if (req.method === "eth_getTransactionReceipt") {
// A lookup that fails, which the wait screen counts differently from
// one that answers "not mined yet" (README.md, WaitTx).
if (opts.failReceiptLookup) {
return Object.assign(envelope, {
error: {
code: -32000,
message: "e2e fixture: receipt lookup failed",
},
});
}
const hash = Array.isArray(req.params) ? req.params[0] : null; const hash = Array.isArray(req.params) ? req.params[0] : null;
return Object.assign(envelope, { return Object.assign(envelope, {
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null, result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
@@ -607,6 +629,8 @@ function traceEnabled(raw) {
* symbol is markup; read at request time. * symbol is markup; read at request time.
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a * @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
* confirmed receipt instead of null, so a wait screen resolves. * confirmed receipt instead of null, so a wait screen resolves.
* @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt
* with an error, so every receipt lookup fails; read at request time.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>} * Promise<string|null>}>}
*/ */
+180
View File
@@ -596,6 +596,106 @@ test("tap-to-copy on the transaction detail screen copies the address (#151)", a
); );
}); });
// ------------------------- the last of the #150 and #151 items (#295)
//
// Add Token's confirm button, TransactionDetail opened from the token screen
// and Back from it, and the explorer link on the token contract row.
// The stub token stays tracked for the rest of the run: the next test reaches
// its token screen through the balance row this one adds.
test("a token added by its contract address is listed on the address screen (#150)", async (env) => {
await leaveTransactionDetail(env.page);
await env.page.click("#btn-add-token");
await visible(env.page, "#view-add-token");
await env.page.fill("#add-token-address", STUB_TOKEN.address);
await env.page.click("#btn-add-token-confirm");
await visible(env.page, "#view-address");
// No wait: the confirm renders the balance list before it shows the
// screen, and nothing renders the list again while the screen is up.
const row = env.page.locator(
'#address-balances [data-token="' + STUB_TOKEN.address + '"]',
{ hasText: STUB_TOKEN.symbol },
);
const balances = await env.page.locator("#address-balances").innerText();
assert(
(await row.count()) === 1,
"the balance list has no " +
STUB_TOKEN.symbol +
" row for the token just added: " +
JSON.stringify(balances),
);
});
// TransactionDetail looks the same from either entry point. Only the
// persisted stack says which one opened it, so that is what is asserted: from
// the token screen it ends in "address-token", and Back has to land there
// rather than on the address screen beneath it.
test("transaction detail opened from the token screen goes Back to it (#151)", 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 env.page
.locator('#address-balances [data-token="' + STUB_TOKEN.address + '"]')
.click();
await visible(env.page, "#view-address-token");
const row = env.page.locator("#address-token-tx-list .tx-row").first();
await row.waitFor({ state: "visible", timeout: 30000 });
await row.click();
await visible(env.page, "#view-transaction");
await waitForPersisted(
env.page,
"viewStack",
base.concat("main", "address", "address-token"),
"on transaction detail opened from the token screen",
);
// The stack is checked before the screen, so a Back that lands on the
// wrong screen fails by saying what the stack holds.
await env.page.click("#btn-tx-back");
await waitForPersisted(
env.page,
"viewStack",
base.concat("main", "address"),
"after Back from transaction detail",
);
await visible(env.page, "#view-address-token");
// Onto the address screen, which the next test starts from.
await env.page.click("#btn-address-token-back");
await visible(env.page, "#view-address");
});
// Read off the anchor rather than followed: where it points is all the popup
// decides, and following it would only load the explorer's page. The suite is
// on mainnet until the Settings section.
test("the token contract row links to the explorer's token page (#151)", async (env) => {
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");
const href = await env.page
.locator("#tx-detail-token-contract a")
.getAttribute("href");
const expected = "https://etherscan.io/token/" + STUB_TOKEN.address;
assert(
href === expected,
"the token contract row links to " +
JSON.stringify(href) +
", expected " +
expected,
);
});
// -------------------------------------------- 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.
@@ -2699,6 +2799,84 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
} }
}); });
// ------------------------------ the wait for a receipt ending in error (#315)
//
// README.md (WaitTx) documents two ways the wait ends on the error screen: a
// lookup that answers "no receipt" 60 seconds or more after the broadcast, and
// six lookups in a row that fail. They are different facts with different
// messages, so each is driven to its own.
//
// Both wait in real time, about a minute each. The wait reads the popup's own
// clock and its own ten-second timer. Playwright's clock would move both, but
// it is installed on the whole browser context and cannot be removed, so every
// later test would run on it. Moving the stored broadcast time back instead can
// be undone by the save the popup makes every ten seconds.
// Send ETH from the address screen and stop on the wait for its receipt.
async function sendEthToWait(env) {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await waitForEstimate(env.page);
await fillPasswordAndSend(env.page);
await visible(env.page, "#view-wait-tx", 60000);
}
test("a wait still without a receipt after 60 seconds ends on the timeout message (#315)", async (env) => {
try {
await sendEthToWait(env);
// Lookups run every ten seconds and answer "no receipt", so the one
// that ends the wait comes about 60 seconds after the broadcast.
await visible(env.page, "#view-error-tx", 90000);
const message = (
await env.page.locator("#error-tx-message").innerText()
).trim();
assert(
message ===
"Transaction was not confirmed within 60 seconds. It may still confirm later — check Etherscan.",
"the wait did not end on the timeout message: " +
JSON.stringify(message),
);
await env.page.click("#btn-error-tx-done");
await visible(env.page, "#view-address");
} finally {
await backToAddressAfterSend(env);
}
});
test("six failed receipt lookups in a row end on the unreachable-network message (#315)", async (env) => {
// Each failed lookup is logged through log.errorf, i.e. console.error.
// Exactly six are declared: a wait that ended sooner leaves one unmatched,
// and one that went on logs a seventh, and either fails this test.
for (let i = 1; i <= 6; i++) {
env.errors.expect(
"failed receipt lookup " + i + " of 6",
/poll receipt failed/,
);
}
env.routeOpts.failReceiptLookup = true;
try {
await sendEthToWait(env);
await visible(env.page, "#view-error-tx", 90000);
const message = (
await env.page.locator("#error-tx-message").innerText()
).trim();
assert(
message ===
"The network could not be reached to check this transaction — 6 lookups failed in a row. Check the RPC URL in Settings. The transaction may still have confirmed — check Etherscan.",
"the wait did not end on the unreachable-network message: " +
JSON.stringify(message),
);
await env.page.click("#btn-error-tx-done");
await visible(env.page, "#view-address");
} finally {
env.routeOpts.failReceiptLookup = false;
await backToAddressAfterSend(env);
}
});
// ------------------------------------------- hostile token symbol (#307) // ------------------------------------------- hostile token symbol (#307)
// //
// The reproduction from the issue, in the real browser against the real // The reproduction from the issue, in the real browser against the real
@@ -4302,6 +4480,8 @@ async function main() {
// Whether eth_getTransactionReceipt confirms a transaction rather than // Whether eth_getTransactionReceipt confirms a transaction rather than
// answering "not mined yet". // answering "not mined yet".
seedReceipt: false, seedReceipt: false,
// Whether eth_getTransactionReceipt fails instead of answering (#315).
failReceiptLookup: false,
// Every raw signed transaction handed to eth_sendRawTransaction, in // Every raw signed transaction handed to eth_sendRawTransaction, in
// order. The dApp transaction round trip asserts against these bytes // order. The dApp transaction round trip asserts against these bytes
// rather than against anything the extension reported about them. // rather than against anything the extension reported about them.