TxApproval and TransactionDetail cut off a long symbol, amount or description at the edge of the screen. These lines now wrap. SuccessTx hid its Amount and To lines whenever it had a decoded action, even one that showed neither. Each line is now hidden only when a readable decoded line shows the same thing. A swap with no Amount line now shows its amount on SuccessTx, as WaitTx and ErrorTx do. Model: opus-5-5
1097 lines
42 KiB
JavaScript
1097 lines
42 KiB
JavaScript
// 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, "<")
|
|
.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 { CONTRACT_CREATION_TEXT } = require("../src/popup/views/helpers");
|
|
|
|
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(
|
|
'<div class="font-bold break-words">TDOL</div>',
|
|
);
|
|
}
|
|
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("<b>400.0000</b> 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("<b>400.0000</b> 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('<div class="font-bold break-words">USDC</div>');
|
|
expect(html).toContain(`data-copy="${USDC}"`);
|
|
expect(html).toContain(`href="https://etherscan.io/address/${USDC}"`);
|
|
expect(html).toMatch(/<div class="text-xs">.*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, ErrorTx and SuccessTx", () => {
|
|
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(
|
|
'<div class="font-bold break-words">1.0000 ETH<',
|
|
);
|
|
expect(html).toContain('<div class="font-bold break-words">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"/,
|
|
);
|
|
expect(html).toMatch(
|
|
/id="success-tx-summary"\s+class="font-bold break-words"/,
|
|
);
|
|
});
|
|
|
|
test("each amount of SuccessTx's decoded action wraps", () => {
|
|
state.viewData = {
|
|
hash: TX_HASH,
|
|
to: RECIPIENT,
|
|
chainId: TX_INFO.chainId,
|
|
decoded: { details: [{ label: "Amount", value: "1.0000 ETH" }] },
|
|
};
|
|
txStatus.renderSuccess();
|
|
expect(getElement("success-tx-decoded").innerHTML).toContain(
|
|
'<div class="font-bold break-words">1.0000 ETH<',
|
|
);
|
|
});
|
|
|
|
test("the name and description of SuccessTx's decoded action wrap", () => {
|
|
state.viewData = {
|
|
hash: TX_HASH,
|
|
to: RECIPIENT,
|
|
chainId: TX_INFO.chainId,
|
|
decoded: {
|
|
name: "Transfer LONGSYMBOL",
|
|
description: "Transfer LONGSYMBOL to a recipient",
|
|
details: [{ label: "Amount", value: "1.0000 ETH" }],
|
|
},
|
|
};
|
|
txStatus.renderSuccess();
|
|
const html = getElement("success-tx-decoded").innerHTML;
|
|
expect(html).toContain(
|
|
'<div class="font-bold break-words">Transfer LONGSYMBOL<',
|
|
);
|
|
expect(html).toContain(
|
|
'<div class="break-words">Transfer LONGSYMBOL to a recipient<',
|
|
);
|
|
});
|
|
});
|
|
|
|
// 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");
|
|
});
|
|
});
|
|
|
|
// The decoded action stands in for the Amount and To lines only where it shows
|
|
// the same thing. A saved record with unreadable lines can leave out either.
|
|
describe("SuccessTx's Amount and To lines beside a decoded action", () => {
|
|
const OTHER = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
|
|
const STEPS = { label: "Steps", value: "V3_SWAP_EXACT_IN" };
|
|
const AMOUNT = { label: "Amount", value: "0.0050 ETH" };
|
|
const RECIPIENT_LINE = {
|
|
label: "Recipient",
|
|
value: RECIPIENT,
|
|
address: RECIPIENT,
|
|
};
|
|
const SPENDER = { label: "Spender", value: OTHER, address: OTHER };
|
|
|
|
function renderSuccessWith(details, to) {
|
|
state.viewData = {
|
|
amount: "0.0050",
|
|
symbol: "ETH",
|
|
to,
|
|
hash: TX_HASH,
|
|
chainId: TX_INFO.chainId,
|
|
decoded: { details },
|
|
};
|
|
txStatus.renderSuccess();
|
|
}
|
|
|
|
function lineHidden(id) {
|
|
return getElement(id + "-parent").classList.contains("hidden");
|
|
}
|
|
|
|
test.each([
|
|
["no line for either", [STEPS], false, false],
|
|
["a line of another address", [AMOUNT, SPENDER], true, false],
|
|
["an Amount line only", [AMOUNT], true, false],
|
|
["a Recipient line only", [RECIPIENT_LINE], false, true],
|
|
[
|
|
"an unreadable Amount line",
|
|
[{ label: "Amount" }, RECIPIENT_LINE],
|
|
false,
|
|
true,
|
|
],
|
|
[
|
|
"an unreadable Recipient line",
|
|
[AMOUNT, { address: RECIPIENT }],
|
|
true,
|
|
false,
|
|
],
|
|
["a line for each", [AMOUNT, RECIPIENT_LINE], true, true],
|
|
])("%s", (_name, details, amountHidden, toHidden) => {
|
|
renderSuccessWith(details, RECIPIENT);
|
|
expect(state.currentView).toBe("success-tx");
|
|
expect(lineHidden("success-tx-summary")).toBe(amountHidden);
|
|
expect(lineHidden("success-tx-to")).toBe(toHidden);
|
|
});
|
|
|
|
test("the lines read the amount and the recipient", () => {
|
|
renderSuccessWith([STEPS], RECIPIENT);
|
|
expect(getElement("success-tx-summary").textContent).toBe("0.0050 ETH");
|
|
expect(getElement("success-tx-to").innerHTML).toContain(RECIPIENT);
|
|
});
|
|
|
|
// A contract creation's data can decode as an ERC-20 call, with an empty
|
|
// token address.
|
|
test("a contract creation keeps its To line", () => {
|
|
renderSuccessWith(
|
|
[{ label: "Token", value: "", address: "", isToken: true }],
|
|
"",
|
|
);
|
|
expect(lineHidden("success-tx-to")).toBe(false);
|
|
expect(getElement("success-tx-to").innerHTML).toBe(
|
|
CONTRACT_CREATION_TEXT,
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|