// Lifecycle tests for the post-broadcast transaction status views // (src/popup/views/txStatus.js). // // The bug these pin down: the receipt poll rendered both outcomes on the tick // that crossed the 60-second deadline, so a confirmed transaction was replaced // by "not confirmed within 60 seconds" — the user is told their transaction // failed when it succeeded. The same shape applies to any callback that // outlives its wait: a receipt lookup still in flight when the view is left // must not render over whatever replaced it. // // Fake timers make the race deterministic: the receipt promise is already // resolved when the deadline tick runs, so in the unfixed code showSuccess() // is always followed by showError() on that tick. // // No network: getProvider is mocked at the module boundary and there is no // jsdom in this repo, so the handful of DOM calls these views make are served // by the stub below. jest.mock("../src/shared/log", () => ({ log: { debugf: () => {}, infof: () => {}, warnf: () => {}, errorf: jest.fn(), }, debugFetch: jest.fn(), setRuntimeDebug: () => {}, isDebug: () => false, })); const mockReceiptLookup = jest.fn(); // The RPC URL and network of every node a wait was given. const mockNodes = []; jest.mock("../src/shared/balances", () => ({ getProvider: (rpcUrl, networkId) => { mockNodes.push({ rpcUrl, networkId }); return { getTransactionReceipt: mockReceiptLookup }; }, refreshBalances: jest.fn(), })); global.fetch = jest.fn(() => { throw new Error("tests must not perform network requests"); }); // --------------------------------------------------------------------------- // Minimal DOM. Every element is created on demand and remembered by id, so a // test can read back what a view wrote into it. // --------------------------------------------------------------------------- const elements = new Map(); function makeElement(id) { const classes = new Set(["view", "hidden"]); const el = { id, textContent: "", innerHTML: "", style: {}, classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c), toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)), }, addEventListener: () => {}, querySelectorAll: () => [], remove: () => {}, prepend: () => {}, }; // Views reach for .parentElement to hide whole sections. Object.defineProperty(el, "parentElement", { get: () => getElement(id + "-parent"), }); return el; } function getElement(id) { if (!elements.has(id)) elements.set(id, makeElement(id)); return elements.get(id); } global.document = { getElementById: (id) => getElement(id), // escapeHtml() builds a detached div; textContent in, escaped HTML out. createElement: () => { const el = { innerHTML: "" }; Object.defineProperty(el, "textContent", { set(v) { el.innerHTML = String(v) .replace(/&/g, "&") .replace(//g, ">"); }, }); return el; }, body: { prepend: () => {} }, addEventListener: () => {}, }; global.window = { location: { search: "" } }; // Clones in both directions, as the real chrome.storage.local does; the stub // here used to hand back the live stored object, so an in-memory mutation // looked like a write that had reached storage. See // tests/support/storageStub.js. const { makeStorageStub } = require("./support/storageStub"); const storage = makeStorageStub(); global.chrome = { storage }; const txStatus = require("../src/popup/views/txStatus"); const { state } = require("../src/shared/state"); const { NETWORKS } = require("../src/shared/networks"); const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews"); const { log } = require("../src/shared/log"); const TX_HASH = "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84"; const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const TX_INFO = { to: RECIPIENT, amount: "0.0050", token: "ETH", tokenSymbol: null, chainId: NETWORKS.mainnet.chainId, }; // True when a view element is not hidden. function visible(view) { return !getElement("view-" + view).classList.contains("hidden"); } 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(); mockNodes.length = 0; log.errorf.mockClear(); state.networkId = "mainnet"; state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl; state.networkEndpoints = {}; state.wallets = []; state.trackedTokens = []; state.networkTrackedTokens = {}; state.viewData = {}; state.viewStack = []; state.currentView = null; pageClosed = new AbortController(); txStatus.init({ doRefreshAndRender: jest.fn(), pageClosed: pageClosed.signal, }); }); afterEach(() => { txStatus.endWait(); jest.useRealTimers(); }); describe("WaitTx receipt/timeout race", () => { test("a receipt arriving on the deadline tick leaves the user on SuccessTx", async () => { // No receipt for the first five polls; the sixth — the tick at // t=60s, which is also the timeout deadline — returns one. mockReceiptLookup .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) .mockResolvedValue({ blockNumber: 21000000 }); txStatus.showWait(TX_INFO, TX_HASH); expect(visible("wait-tx")).toBe(true); await jest.advanceTimersByTimeAsync(60000); expect(visible("success-tx")).toBe(true); expect(visible("error-tx")).toBe(false); expect(state.currentView).toBe("success-tx"); expect(state.viewData.blockNumber).toBe(21000000); expect(state.viewData.message).toBeUndefined(); // And nothing is left running to undo it. expect(jest.getTimerCount()).toBe(0); await jest.advanceTimersByTimeAsync(300000); expect(state.currentView).toBe("success-tx"); expect(mockReceiptLookup).toHaveBeenCalledTimes(6); }); test("a genuine timeout still shows ErrorTx with the hash", async () => { mockReceiptLookup.mockResolvedValue(null); txStatus.showWait(TX_INFO, TX_HASH); await jest.advanceTimersByTimeAsync(60000); expect(visible("error-tx")).toBe(true); expect(state.currentView).toBe("error-tx"); expect(state.viewData.message).toMatch( /not confirmed within 60 seconds/, ); expect(state.viewData.hash).toBe(TX_HASH); // The hash section carries the hash and the etherscan link. expect(getElement("error-tx-hash").innerHTML).toContain(TX_HASH); expect(getElement("error-tx-hash").innerHTML).toContain( "/tx/" + TX_HASH, ); expect(jest.getTimerCount()).toBe(0); }); test("a receipt still in flight when the view is left does not render over it", async () => { let resolveReceipt; mockReceiptLookup.mockReturnValue( new Promise((r) => { resolveReceipt = r; }), ); txStatus.showWait(TX_INFO, TX_HASH); await jest.advanceTimersByTimeAsync(10000); expect(mockReceiptLookup).toHaveBeenCalledTimes(1); // User leaves the wait (popup navigation / teardown) while the // lookup is outstanding, then the lookup finally answers. txStatus.endWait(); state.currentView = "main"; resolveReceipt({ blockNumber: 21000000 }); await Promise.resolve(); await Promise.resolve(); expect(state.currentView).toBe("main"); expect(visible("success-tx")).toBe(false); }); test("no timer survives the view being left", async () => { mockReceiptLookup.mockResolvedValue(null); txStatus.showWait(TX_INFO, TX_HASH); expect(jest.getTimerCount()).toBeGreaterThan(0); txStatus.endWait(); expect(jest.getTimerCount()).toBe(0); await jest.advanceTimersByTimeAsync(120000); expect(mockReceiptLookup).not.toHaveBeenCalled(); }); }); describe("WaitTx persistence across popup close", () => { test("restoreWait resumes the poll with the deadline running from broadcast", async () => { mockReceiptLookup.mockResolvedValue(null); txStatus.showWait(TX_INFO, TX_HASH); expect(state.viewData.pendingWait.hash).toBe(TX_HASH); const persisted = JSON.parse(JSON.stringify(state.viewData)); // Popup closes: timers die with the page. txStatus.endWait(); // 45 seconds pass with the popup shut, then it is reopened. jest.advanceTimersByTime(45000); state.viewData = persisted; expect(txStatus.restoreWait()).toBe(true); expect(visible("wait-tx")).toBe(true); // Elapsed is counted from the broadcast, not from the reopen. expect(waitStatusText()).toBe("Waiting for confirmation... 45s"); // The immediate poll on resume has already run. await Promise.resolve(); expect(mockReceiptLookup).toHaveBeenCalledTimes(1); // The deadline is 15 seconds away, not 60. await jest.advanceTimersByTimeAsync(20000); expect(state.currentView).toBe("error-tx"); }); test("a rejected lookup on the resume poll keeps waiting instead of reporting failure", async () => { // A wait resumed after the deadline has already passed: the first // poll is immediate and past 60s, so a thrown lookup must not be // read as "no receipt". It means "no answer this tick" — keep // polling, because the transaction may well have confirmed. mockReceiptLookup.mockResolvedValue(null); txStatus.showWait(TX_INFO, TX_HASH); const persisted = JSON.parse(JSON.stringify(state.viewData)); txStatus.endWait(); // Ten minutes with the popup shut, then it is reopened and the // first receipt lookup fails transiently. jest.advanceTimersByTime(600000); mockReceiptLookup.mockReset(); mockReceiptLookup .mockRejectedValueOnce(new Error("rpc unavailable")) .mockResolvedValue({ blockNumber: 21000000 }); state.viewData = persisted; expect(txStatus.restoreWait()).toBe(true); await jest.advanceTimersByTimeAsync(0); // The wait is still alive: no timeout was declared off one error. expect(visible("wait-tx")).toBe(true); expect(visible("error-tx")).toBe(false); expect(state.currentView).toBe("wait-tx"); expect(jest.getTimerCount()).toBeGreaterThan(0); // And the next tick answers, so the confirmed transaction is // reported as confirmed. await jest.advanceTimersByTimeAsync(10000); expect(state.currentView).toBe("success-tx"); expect(state.viewData.blockNumber).toBe(21000000); }); test("a lookup returning null past the deadline still times out", async () => { // The counterpart to the test above: the deadline must still fire // when the lookup actually answers "no receipt". mockReceiptLookup.mockResolvedValue(null); txStatus.showWait(TX_INFO, TX_HASH); const persisted = JSON.parse(JSON.stringify(state.viewData)); txStatus.endWait(); jest.advanceTimersByTime(600000); state.viewData = persisted; expect(txStatus.restoreWait()).toBe(true); await jest.advanceTimersByTimeAsync(0); expect(state.currentView).toBe("error-tx"); expect(state.viewData.message).toMatch( /not confirmed within 60 seconds/, ); }); test("restoreWait reports nothing to resume when no wait is persisted", () => { state.viewData = {}; expect(txStatus.restoreWait()).toBe(false); expect(jest.getTimerCount()).toBe(0); }); test("restoreWait rejects a persisted wait missing its txInfo or broadcast time", () => { for (const bad of [ { hash: TX_HASH, broadcastTime: Date.now() }, { hash: TX_HASH, txInfo: TX_INFO }, { hash: TX_HASH, txInfo: TX_INFO, broadcastTime: "soon" }, { hash: TX_HASH, txInfo: TX_INFO, broadcastTime: NaN }, { hash: TX_HASH, txInfo: "nope", broadcastTime: Date.now() }, // An object that merely lacks a field startWait() dereferences // is the shape that actually escaped: txInfo.to reaches // addressTitle(), which calls address.toLowerCase(). typeof [] // is "object", so an array passes an object check. { hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() }, { hash: TX_HASH, txInfo: [], broadcastTime: Date.now() }, { hash: TX_HASH, txInfo: { to: 42 }, broadcastTime: Date.now() }, // Otherwise complete but for a non-string `to`: only the `to` // check rejects this one, and without it addressTitle() throws // out of restoreView(). { hash: TX_HASH, txInfo: { ...TX_INFO, to: 42 }, broadcastTime: Date.now(), }, // Otherwise complete but an array: only Array.isArray() rejects // it, since typeof [] is "object" and the fields are present. { hash: TX_HASH, txInfo: Object.assign([], TX_INFO), broadcastTime: Date.now(), }, { hash: TX_HASH, txInfo: { to: RECIPIENT }, broadcastTime: Date.now(), }, // Otherwise complete but for a chain id naming no network: there // is no node to ask for the receipt, and no explorer to link to. { hash: TX_HASH, txInfo: { ...TX_INFO, chainId: undefined }, broadcastTime: Date.now(), }, { hash: TX_HASH, txInfo: { ...TX_INFO, chainId: "0x5" }, broadcastTime: Date.now(), }, ]) { state.viewData = { pendingWait: bad }; expect(txStatus.restoreWait()).toBe(false); expect(jest.getTimerCount()).toBe(0); } }); test("restoreWait resumes a wait whose recipient is the empty string", () => { // The shape a contract-deployment approval persists: approval.js // writes `to: toAddr || ""`, and showWait() renders it without // complaint. Validation must not be stricter than the live path, or // that wait is silently abandoned on every popup open. mockReceiptLookup.mockResolvedValue(null); state.viewData = { pendingWait: { hash: TX_HASH, txInfo: { ...TX_INFO, to: "" }, broadcastTime: Date.now(), }, }; expect(txStatus.restoreWait()).toBe(true); expect(visible("wait-tx")).toBe(true); }); }); describe("WaitTx against an RPC that never answers", () => { test("a permanently failing lookup ends the wait instead of polling forever", async () => { mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable")); txStatus.showWait(TX_INFO, TX_HASH); // Six consecutive failures is 60 seconds at the 10s cadence — the // same patience as the confirmation deadline. await jest.advanceTimersByTimeAsync(60000); expect(state.currentView).toBe("error-tx"); expect(visible("wait-tx")).toBe(false); // The user is told what actually happened: the lookup failed. It is // not the same fact as "the transaction did not confirm". expect(state.viewData.message).toMatch(/could not be reached/i); expect(state.viewData.message).not.toMatch(/not confirmed within/); expect(state.viewData.hash).toBe(TX_HASH); // Nothing is left running, and nothing is left to resume onto. expect(jest.getTimerCount()).toBe(0); expect(state.viewData.pendingWait).toBeUndefined(); const calls = mockReceiptLookup.mock.calls.length; await jest.advanceTimersByTimeAsync(3600000); expect(mockReceiptLookup).toHaveBeenCalledTimes(calls); expect(state.currentView).toBe("error-tx"); }); test("an answered lookup clears the failure count, so the bound is on consecutive failures", async () => { // The bound counts failures in a row, not failures in total: a // flaky RPC that keeps answering in between must not accumulate its // way to a false "network unreachable". // // Polls 1-5 (t=10s..50s) alternate reject / null, so three fail and // the last answer resets the count at poll 4. From poll 6 on every // lookup fails. Six in a row is then poll 10, at t=100s. A counter // that never reset would have reached six at poll 8, t=80s, so the // window between those two is what this test occupies. mockReceiptLookup.mockImplementation(() => { const n = mockReceiptLookup.mock.calls.length; if (n <= 5 && n % 2 === 0) return Promise.resolve(null); return Promise.reject(new Error("flaky")); }); txStatus.showWait(TX_INFO, TX_HASH); // t=90s: eight failures in total, five of them in a row. A // cumulative counter has long since fired; a consecutive one has not. await jest.advanceTimersByTimeAsync(90000); expect(state.currentView).toBe("wait-tx"); expect(visible("wait-tx")).toBe(true); expect(jest.getTimerCount()).toBeGreaterThan(0); // t=100s: the sixth in a row. await jest.advanceTimersByTimeAsync(10000); expect(state.currentView).toBe("error-tx"); expect(state.viewData.message).toMatch(/could not be reached/i); // No lookup ever answered "no receipt" past the deadline, so this // is not the timeout and must not be reported as one. expect(state.viewData.message).not.toMatch(/not confirmed within/); expect(jest.getTimerCount()).toBe(0); }); test("a resumed wait against a dead RPC also terminates", async () => { // The reopen path is the one that made this unbounded: the wait is // persisted, so without a bound every popup open resumes it forever. mockReceiptLookup.mockResolvedValue(null); txStatus.showWait(TX_INFO, TX_HASH); const persisted = JSON.parse(JSON.stringify(state.viewData)); txStatus.endWait(); jest.advanceTimersByTime(3600000); mockReceiptLookup.mockReset(); mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable")); state.viewData = persisted; expect(txStatus.restoreWait()).toBe(true); await jest.advanceTimersByTimeAsync(60000); expect(state.currentView).toBe("error-tx"); expect(state.viewData.message).toMatch(/could not be reached/i); expect(jest.getTimerCount()).toBe(0); expect(state.viewData.pendingWait).toBeUndefined(); }); }); // 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(); }); }); // The popup can reopen onto WaitTx after Settings switched the network. The // wait asked the active network's node for the receipt, never found it, and // reported a timeout, and its links went to the active network's explorer // (https://git.eeqj.de/sneak/AutistMask/issues/584). describe("WaitTx for a Sepolia transaction with mainnet active", () => { const SEPOLIA_NODE = "https://sepolia.node.example.invalid"; const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId }; beforeEach(() => { state.rpcUrl = "https://mainnet.node.example.invalid"; state.networkEndpoints = { sepolia: { rpcUrl: SEPOLIA_NODE } }; }); test("the receipt is asked of the Sepolia node Settings has", async () => { mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 }); state.viewData = { pendingWait: { txInfo: sepoliaTx, hash: TX_HASH, broadcastTime: Date.now(), }, }; expect(txStatus.restoreWait()).toBe(true); await jest.advanceTimersByTimeAsync(0); expect(mockNodes).toEqual([ { rpcUrl: SEPOLIA_NODE, networkId: "sepolia" }, ]); expect(state.currentView).toBe("success-tx"); }); test("the recipient and hash link to the Sepolia explorer", () => { mockReceiptLookup.mockResolvedValue(null); txStatus.showWait(sepoliaTx, TX_HASH); expect(getElement("wait-tx-to").innerHTML).toContain( `href="https://sepolia.etherscan.io/address/${RECIPIENT}"`, ); expect(getElement("wait-tx-hash").innerHTML).toContain( `href="https://sepolia.etherscan.io/tx/${TX_HASH}"`, ); }); }); // SuccessTx and ErrorTx follow WaitTx and describe the same transaction, but // their links went to the active network's explorer // (https://git.eeqj.de/sneak/AutistMask/issues/622). Each test checks the // screen again after rendering it from its stored view data alone, as a // reopened popup does. describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active", () => { const SEPOLIA_EXPLORER = "https://sepolia.etherscan.io"; const TOKEN = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"; const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId }; test("the recipient, block and hash on SuccessTx link to the Sepolia explorer", async () => { function expectSepoliaLinks() { expect(getElement("success-tx-to").innerHTML).toContain( `href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`, ); expect(getElement("success-tx-block").innerHTML).toContain( `href="${SEPOLIA_EXPLORER}/block/21000000"`, ); expect(getElement("success-tx-hash").innerHTML).toContain( `href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`, ); } mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 }); txStatus.showWait(sepoliaTx, TX_HASH); await jest.advanceTimersByTimeAsync(10000); expect(state.currentView).toBe("success-tx"); expectSepoliaLinks(); elements.clear(); txStatus.renderSuccess(); expectSepoliaLinks(); }); test("the addresses in SuccessTx's decoded action link to the Sepolia explorer", async () => { function expectSepoliaLinks() { const html = getElement("success-tx-decoded").innerHTML; expect(html).toContain( `href="${SEPOLIA_EXPLORER}/address/${TOKEN}"`, ); expect(html).toContain( `href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`, ); } const decoded = { name: "Transfer", details: [ { label: "Token", address: TOKEN, isToken: true }, { label: "Recipient", address: RECIPIENT }, ], }; mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 }); txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH); await jest.advanceTimersByTimeAsync(10000); expect(state.currentView).toBe("success-tx"); expectSepoliaLinks(); elements.clear(); txStatus.renderSuccess(); expectSepoliaLinks(); }); // https://git.eeqj.de/sneak/AutistMask/issues/629 test("SuccessTx's decoded action names the token from the tokens tracked on Sepolia", async () => { function expectTokenNamed() { expect(getElement("success-tx-decoded").innerHTML).toContain( '
TDOL
', ); } state.networkTrackedTokens = { sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }], }; const decoded = { name: "Transfer", details: [{ label: "Token", address: TOKEN, isToken: true }], }; mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 }); txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH); await jest.advanceTimersByTimeAsync(10000); expect(state.currentView).toBe("success-tx"); expectTokenNamed(); elements.clear(); txStatus.renderSuccess(); expectTokenNamed(); }); test("the recipient and hash on ErrorTx link to the Sepolia explorer", () => { function expectSepoliaLinks() { expect(getElement("error-tx-to").innerHTML).toContain( `href="${SEPOLIA_EXPLORER}/address/${RECIPIENT}"`, ); expect(getElement("error-tx-hash").innerHTML).toContain( `href="${SEPOLIA_EXPLORER}/tx/${TX_HASH}"`, ); } txStatus.showError(sepoliaTx, TX_HASH, "The transaction failed."); expectSepoliaLinks(); elements.clear(); txStatus.renderError(); expectSepoliaLinks(); }); }); 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 // the resume silently — the tests above call restoreWait() directly and // would all still pass. This pins the membership. Mirrors the exclusion // assertions in tests/showPhrase.test.js. test("wait-tx is restorable", () => { expect(RESTORABLE_VIEWS.has("wait-tx")).toBe(true); }); });