A saved wait whose decoded record is in another shape threw when its receipt arrived. WaitTx then stayed on screen with the counter stopped. decodedDetailsHtml() now shows only the lines whose label and value are text. SuccessTx shows the amount and recipient when no line can be read. WaitTx and ErrorTx now show each allowance's Allowance token line. Long amounts use break-words, so words stay whole. ErrorTx sets up copying when it has no hash, so the token's address can be copied. Model: opus-5-5
985 lines
38 KiB
JavaScript
985 lines
38 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 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">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">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 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(
|
|
'<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"/,
|
|
);
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
});
|