// 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: () => {}, }, debugFetch: jest.fn(), setRuntimeDebug: () => {}, isDebug: () => false, })); const mockReceiptLookup = jest.fn(); jest.mock("../src/shared/balances", () => ({ getProvider: () => ({ 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: "" } }; const stored = {}; global.chrome = { storage: { local: { set: (obj) => { Object.assign(stored, obj); return Promise.resolve(); }, get: () => Promise.resolve(stored), }, }, }; const txStatus = require("../src/popup/views/txStatus"); const { state } = require("../src/shared/state"); const TX_HASH = "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84"; const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const TX_INFO = { to: RECIPIENT, amount: "0.0050", token: "ETH", tokenSymbol: null, }; // 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; } beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(new Date("2026-08-11T12:00:00Z")); elements.clear(); mockReceiptLookup.mockReset(); state.wallets = []; state.viewData = {}; state.viewStack = []; state.currentView = null; txStatus.init({ doRefreshAndRender: jest.fn() }); }); 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("restoreWait reports nothing to resume when no wait is persisted", () => { state.viewData = {}; expect(txStatus.restoreWait()).toBe(false); expect(jest.getTimerCount()).toBe(0); }); });