// 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 { RESTORABLE_VIEWS } = require("../src/popup/restorableViews"); 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("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: { to: 42, amount: "0.0050" }, 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([], { to: RECIPIENT, amount: "0.0050" }), broadcastTime: Date.now(), }, { hash: TX_HASH, txInfo: { to: RECIPIENT }, 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(); }); }); 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); }); });