// 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: "" } }; const fs = require("fs"); const path = require("path"); // 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", value: TOKEN, address: TOKEN, isToken: true }, { label: "Recipient", value: 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", value: 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("the Moved from the sender lines of a swap on WaitTx and ErrorTx", () => { const moved = (value) => ({ label: "Moved from the sender", value }); const decoded = { details: [ moved("Up to 1.0000 ETH"), { label: "Token moved", value: "ETH" }, moved("400.0000 DAI"), ], }; function expectEveryLine(id) { const html = getElement(id).innerHTML; expect(html).toContain("Up to 1.0000 ETH"); expect(html).toContain("<b>400.0000</b> DAI"); expect(html).not.toContain("Token moved"); } test("WaitTx shows each line's amount, escaped", () => { mockReceiptLookup.mockResolvedValue(null); txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH); expectEveryLine("wait-tx-token-note"); }); test("ErrorTx shows each line's amount, escaped", () => { txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed."); expectEveryLine("error-tx-token-note"); }); test("a wait that ends in ErrorTx keeps them", async () => { mockReceiptLookup.mockResolvedValue(null); txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH); await jest.advanceTimersByTimeAsync(60000); expect(state.currentView).toBe("error-tx"); expectEveryLine("error-tx-token-note"); }); // A saved record comes back from storage, in whatever shape it is in. A // throw here would leave the popup's restore unfinished. test("a saved record in another shape shows only the lines it can read", () => { mockReceiptLookup.mockResolvedValue(null); function noteOnReopen(saved) { state.viewData = { pendingWait: { hash: TX_HASH, txInfo: { ...TX_INFO, decoded: saved }, broadcastTime: Date.now(), }, }; expect(txStatus.restoreWait()).toBe(true); const waitNote = getElement("wait-tx-token-note").innerHTML; txStatus.endWait(); state.viewData = { message: "It failed.", to: RECIPIENT, chainId: TX_INFO.chainId, decoded: saved, }; txStatus.renderError(); expect(getElement("error-tx-token-note").innerHTML).toBe(waitNote); return waitNote; } for (const saved of [ 7, "text", { details: 7 }, { details: [null] }, { details: [{ label: "Moved from the sender" }] }, { details: [moved(7)] }, { details: [moved({ toString: 1 })] }, { details: [{ label: "Permit2 allowance", value: 7 }] }, { details: [ { label: "Permit2 allowance", value: "text", address: 7 }, ], }, { details: [ { label: "Allowance token", value: "text", address: 7, isToken: true, }, ], }, ]) { expect(noteOnReopen(saved)).toBe(""); } expect( noteOnReopen({ details: [null, moved("Up to 1.0000 ETH")] }), ).toContain("Up to 1.0000 ETH"); }); }); describe("the Permit2 allowance lines of a swap on WaitTx and ErrorTx", () => { const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const allowance = (value) => ({ label: "Permit2 allowance", value }); const decoded = { details: [ allowance("Unlimited"), { label: "Allowance token", value: "USDC (" + USDC + ")", address: USDC, isToken: true, }, { label: "Allowance spender", value: RECIPIENT, address: RECIPIENT, }, allowance("400.0000 DAI"), ], }; function expectEveryLine(id) { const html = getElement(id).innerHTML; expect(html).toContain("Permit2 allowance"); expect(html).toContain("Unlimited"); expect(html).toContain("<b>400.0000</b> DAI"); expect(html).not.toContain("Allowance spender"); expect(html).not.toContain(RECIPIENT); } // `Unlimited` alone does not say which token it covers. The address gets // the row of its own that every address gets, in the transaction's network. function expectTokenLine(id) { const html = getElement(id).innerHTML; expect(html.indexOf("Unlimited")).toBeLessThan( html.indexOf("Allowance token"), ); expect(html).toContain('
USDC
'); expect(html).toContain(`data-copy="${USDC}"`); expect(html).toContain(`href="https://etherscan.io/address/${USDC}"`); expect(html).toMatch(/
.*class="am-address"/); } test("WaitTx says which token an allowance covers", () => { mockReceiptLookup.mockResolvedValue(null); txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH); expectTokenLine("wait-tx-token-note"); }); test("ErrorTx says which token an allowance covers", () => { txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed."); expectTokenLine("error-tx-token-note"); }); test("the token's address links to the transaction's network", () => { const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId, decoded, }; txStatus.showError(sepoliaTx, TX_HASH, "It failed."); expect(getElement("error-tx-token-note").innerHTML).toContain( `href="https://sepolia.etherscan.io/address/${USDC}"`, ); }); test("the token's address can be copied on an ErrorTx with no hash", () => { const view = getElement("view-error-tx"); view.querySelectorAll = jest.fn(() => []); txStatus.showError({ ...TX_INFO, decoded }, null, "It failed."); expect(view.querySelectorAll).toHaveBeenCalledWith("[data-copy]"); }); test("WaitTx shows each line's amount, escaped", () => { mockReceiptLookup.mockResolvedValue(null); txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH); expectEveryLine("wait-tx-token-note"); }); test("ErrorTx shows each line's amount, escaped", () => { txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed."); expectEveryLine("error-tx-token-note"); }); test("a wait that ends in ErrorTx keeps them", async () => { mockReceiptLookup.mockResolvedValue(null); txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH); await jest.advanceTimersByTimeAsync(60000); expect(state.currentView).toBe("error-tx"); expectEveryLine("error-tx-token-note"); }); }); // An amount of about 47 digits or more is wider than the screen. Without the // break-words class its last digits are cut off at the edge. That class keeps // the words of a line such as `(decimals unknown)` whole. describe("a long amount on WaitTx and ErrorTx", () => { test("each amount under the Amount figure wraps", () => { const decoded = { details: [ { label: "Moved from the sender", value: "1.0000 ETH" }, { label: "Permit2 allowance", value: "Unlimited" }, ], }; txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed."); const html = getElement("error-tx-token-note").innerHTML; expect(html).toContain( '
1.0000 ETH<', ); expect(html).toContain('
Unlimited<'); }); test("the Amount figure wraps", () => { const html = fs.readFileSync( path.join(__dirname, "..", "src", "popup", "index.html"), "utf8", ); expect(html).toMatch( /id="wait-tx-summary"\s+class="font-bold break-words"/, ); expect(html).toMatch( /id="error-tx-summary"\s+class="font-bold break-words"/, ); }); }); // A saved wait comes back from storage, in whatever shape it is in. A throw when // its receipt arrives would leave the popup on WaitTx with the counter stopped. describe("a saved wait in another shape when its receipt arrives", () => { const LINE = { label: "Steps", value: "V3_SWAP_EXACT_IN" }; async function receiptForSavedWait(decoded) { mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 }); state.viewData = { pendingWait: { hash: TX_HASH, txInfo: { ...TX_INFO, decoded }, broadcastTime: Date.now(), }, }; expect(txStatus.restoreWait()).toBe(true); await jest.advanceTimersByTimeAsync(0); } test.each([ { details: 7 }, { details: "text" }, { details: { label: "Steps", value: "text" } }, { details: [null] }, { details: [7] }, { details: ["text"] }, { details: [{ value: "text" }] }, { details: [{ label: 7, value: "text" }] }, { details: [{ label: { toString: 1 }, value: "text" }] }, { details: [{ label: "Steps" }] }, { details: [{ label: "Steps", value: 7 }] }, { details: [{ label: "Steps", value: { toString: 1 } }] }, { details: [{ label: "Steps", address: RECIPIENT }] }, { details: [{ label: "Steps", value: "text", address: 7 }] }, { details: [ { label: "Steps", value: "text", address: 7, isToken: true }, ], }, { name: "Swap", description: "A swap", details: [null] }, ])("%j shows SuccessTx with the amount and recipient", async (decoded) => { await receiptForSavedWait(decoded); expect(state.currentView).toBe("success-tx"); expect( getElement("success-tx-decoded").classList.contains("hidden"), ).toBe(true); expect( getElement("success-tx-summary-parent").classList.contains( "hidden", ), ).toBe(false); expect(getElement("success-tx-summary").textContent).toBe("0.0050 ETH"); expect( getElement("success-tx-to-parent").classList.contains("hidden"), ).toBe(false); expect(getElement("success-tx-to").innerHTML).toContain(RECIPIENT); }); test.each([ { details: [null, LINE, 7] }, { name: 7, details: [LINE] }, { name: { toString: 1 }, details: [LINE] }, { description: 7, details: [LINE] }, { description: { toString: 1 }, details: [LINE] }, ])("%j shows SuccessTx with the line it can read", async (decoded) => { await receiptForSavedWait(decoded); expect(state.currentView).toBe("success-tx"); const html = getElement("success-tx-decoded").innerHTML; expect(html).toContain("V3_SWAP_EXACT_IN"); expect(html).not.toContain("Action"); expect(html).not.toContain("Description"); }); }); 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); }); });