Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile. The
write is the whole record, and updateState()'s header now names what that costs:
a popup write landing inside that one-round-trip window is reverted.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review, and it is enforced by
the bundler rather than by a guess at what the bundler does. The table of
modules an entry point's bundle may not contain lives in
script/lib/forbiddenBundleInputs.js — one copy, read by both layers that act on
it — and build.js's assertNoForbiddenInputs() fails the build when esbuild's
metafile reports src/shared/state.js as an input of a background bundle, naming
the import chain from the metafile's own graph. That is the resolution the
shipped bundle was built from, so no specifier syntax, no hop and no resolution
rule can slip past it; Dockerfile:42 runs make build, so it holds in CI.
A background entry point the table does not name fails the build as well. The
five defects were accidents, and so is adding a second worker entry point
without knowing that a table elsewhere needs a line for it: entry points under
src/background/ are prohibited by default and must be listed, rather than
protected only when someone remembers. That prefix is the build's only notion of
"the background", and eslint.config.js scopes the lint rule from the same
constant so the two layers cannot disagree about it.
Every way the table can rot is a failure rather than a quiet pass: a key no
bundled entry point matched, a listed module this build bundled nowhere, and an
entry that lists no modules. The second is what makes a rename of
src/shared/state.js loud instead of silently disarming the check, and it is
stronger than an existsSync() because it also fails when the module is still
there but has dropped out of every bundle. The third is refused at require time,
where the table is defined, because an empty list also empties the lint rule's
forbidden set — one character, and a plain require of the singleton in the
worker was green in make test, make lint and make build alike.
An entry is recorded as checked only once its bundle's inputs are in hand. It
used to be recorded before the output lookup that produces them, so an early
return past that point left both halves of the guarantee satisfied by a bundle
nothing had examined.
What the assertion does NOT cover is a COPY of the singleton at another path: it
is keyed by path, so a copy builds and lints clean. That is stated where the
table lives, with what the residual actually is — a copy carries the singleton's
own guard, so an unloaded read is a loud StateNotLoadedError and defects 1-3
cannot recur silently, but a copy carries loadState() too, so defects 4 and 5
(a stale read several awaits after a load, a load detaching objects an in-flight
handler is mutating) would recur over it in silence.
make check does not run make build, so the assertion is unit tested against
synthetic metafiles in tests/buildForbiddenInputs.test.js: build.js runs its
build() only as a program now and exports the checks. Executing a check in CI
is not testing it — without that file, inverting the condition leaves every
check in this repo green with the singleton back in the worker. Each vacuous
pass above has a case, including the output lookup that finds nothing, the empty
list, the unlisted second entry point, and recordBundledInputs() itself, which
every other case used to hand-seed.
A custom ESLint rule walks the CommonJS require graph from every src/background/
file and reports the same thing in the editor, before a full bundle. It reads
the same table, and it matches specifiers textually, so it is best-effort fast
feedback and not the guarantee — two earlier revisions of it shipped holes (a
template literal, a dynamic import(), a comment inside the call, a directory
resolved through package.json main). Those are covered now and pinned by
tests/backgroundStateLintRule.test.js. Two shapes it does not report are pinned
there as asserted non-reports, so the header's list of its bounds is measured
rather than claimed: a computed specifier (require("../shared/" + "state"),
which esbuild constant-folds into the bundle) and a symlink to the module
(esbuild reports the real path). Each is make lint exit 0 and make build exit 2.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.
closes #320
479 lines
19 KiB
JavaScript
479 lines
19 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: () => {},
|
|
},
|
|
debugFetch: jest.fn(),
|
|
setRuntimeDebug: () => {},
|
|
isDebug: () => false,
|
|
}));
|
|
|
|
const mockReceiptLookup = jest.fn();
|
|
jest.mock("../src/shared/balances", () => ({
|
|
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
|
|
refreshBalances: jest.fn(),
|
|
}));
|
|
|
|
global.fetch = jest.fn(() => {
|
|
throw new Error("tests must not perform network requests");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Minimal DOM. Every element is created on demand and remembered by id, so a
|
|
// test can read back what a view wrote into it.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const elements = new Map();
|
|
|
|
function makeElement(id) {
|
|
const classes = new Set(["view", "hidden"]);
|
|
const el = {
|
|
id,
|
|
textContent: "",
|
|
innerHTML: "",
|
|
style: {},
|
|
classList: {
|
|
add: (c) => classes.add(c),
|
|
remove: (c) => classes.delete(c),
|
|
contains: (c) => classes.has(c),
|
|
toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)),
|
|
},
|
|
addEventListener: () => {},
|
|
querySelectorAll: () => [],
|
|
remove: () => {},
|
|
prepend: () => {},
|
|
};
|
|
// Views reach for .parentElement to hide whole sections.
|
|
Object.defineProperty(el, "parentElement", {
|
|
get: () => getElement(id + "-parent"),
|
|
});
|
|
return el;
|
|
}
|
|
|
|
function getElement(id) {
|
|
if (!elements.has(id)) elements.set(id, makeElement(id));
|
|
return elements.get(id);
|
|
}
|
|
|
|
global.document = {
|
|
getElementById: (id) => getElement(id),
|
|
// escapeHtml() builds a detached div; textContent in, escaped HTML out.
|
|
createElement: () => {
|
|
const el = { innerHTML: "" };
|
|
Object.defineProperty(el, "textContent", {
|
|
set(v) {
|
|
el.innerHTML = String(v)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
},
|
|
});
|
|
return el;
|
|
},
|
|
body: { prepend: () => {} },
|
|
addEventListener: () => {},
|
|
};
|
|
|
|
global.window = { location: { search: "" } };
|
|
|
|
// 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 { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
|
|
|
const TX_HASH =
|
|
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
const TX_INFO = {
|
|
to: RECIPIENT,
|
|
amount: "0.0050",
|
|
token: "ETH",
|
|
tokenSymbol: null,
|
|
};
|
|
|
|
// True when a view element is not hidden.
|
|
function visible(view) {
|
|
return !getElement("view-" + view).classList.contains("hidden");
|
|
}
|
|
|
|
function waitStatusText() {
|
|
return getElement("wait-tx-status").textContent;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
|
|
elements.clear();
|
|
mockReceiptLookup.mockReset();
|
|
state.wallets = [];
|
|
state.viewData = {};
|
|
state.viewStack = [];
|
|
state.currentView = null;
|
|
txStatus.init({ doRefreshAndRender: jest.fn() });
|
|
});
|
|
|
|
afterEach(() => {
|
|
txStatus.endWait();
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
describe("WaitTx receipt/timeout race", () => {
|
|
test("a receipt arriving on the deadline tick leaves the user on SuccessTx", async () => {
|
|
// No receipt for the first five polls; the sixth — the tick at
|
|
// t=60s, which is also the timeout deadline — returns one.
|
|
mockReceiptLookup
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValue({ blockNumber: 21000000 });
|
|
|
|
txStatus.showWait(TX_INFO, TX_HASH);
|
|
expect(visible("wait-tx")).toBe(true);
|
|
|
|
await jest.advanceTimersByTimeAsync(60000);
|
|
|
|
expect(visible("success-tx")).toBe(true);
|
|
expect(visible("error-tx")).toBe(false);
|
|
expect(state.currentView).toBe("success-tx");
|
|
expect(state.viewData.blockNumber).toBe(21000000);
|
|
expect(state.viewData.message).toBeUndefined();
|
|
|
|
// And nothing is left running to undo it.
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
await jest.advanceTimersByTimeAsync(300000);
|
|
expect(state.currentView).toBe("success-tx");
|
|
expect(mockReceiptLookup).toHaveBeenCalledTimes(6);
|
|
});
|
|
|
|
test("a genuine timeout still shows ErrorTx with the hash", async () => {
|
|
mockReceiptLookup.mockResolvedValue(null);
|
|
|
|
txStatus.showWait(TX_INFO, TX_HASH);
|
|
await jest.advanceTimersByTimeAsync(60000);
|
|
|
|
expect(visible("error-tx")).toBe(true);
|
|
expect(state.currentView).toBe("error-tx");
|
|
expect(state.viewData.message).toMatch(
|
|
/not confirmed within 60 seconds/,
|
|
);
|
|
expect(state.viewData.hash).toBe(TX_HASH);
|
|
// The hash section carries the hash and the etherscan link.
|
|
expect(getElement("error-tx-hash").innerHTML).toContain(TX_HASH);
|
|
expect(getElement("error-tx-hash").innerHTML).toContain(
|
|
"/tx/" + TX_HASH,
|
|
);
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
test("a receipt still in flight when the view is left does not render over it", async () => {
|
|
let resolveReceipt;
|
|
mockReceiptLookup.mockReturnValue(
|
|
new Promise((r) => {
|
|
resolveReceipt = r;
|
|
}),
|
|
);
|
|
|
|
txStatus.showWait(TX_INFO, TX_HASH);
|
|
await jest.advanceTimersByTimeAsync(10000);
|
|
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
|
|
|
|
// User leaves the wait (popup navigation / teardown) while the
|
|
// lookup is outstanding, then the lookup finally answers.
|
|
txStatus.endWait();
|
|
state.currentView = "main";
|
|
resolveReceipt({ blockNumber: 21000000 });
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(state.currentView).toBe("main");
|
|
expect(visible("success-tx")).toBe(false);
|
|
});
|
|
|
|
test("no timer survives the view being left", async () => {
|
|
mockReceiptLookup.mockResolvedValue(null);
|
|
|
|
txStatus.showWait(TX_INFO, TX_HASH);
|
|
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
|
|
|
txStatus.endWait();
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
|
|
await jest.advanceTimersByTimeAsync(120000);
|
|
expect(mockReceiptLookup).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("WaitTx persistence across popup close", () => {
|
|
test("restoreWait resumes the poll with the deadline running from broadcast", async () => {
|
|
mockReceiptLookup.mockResolvedValue(null);
|
|
|
|
txStatus.showWait(TX_INFO, TX_HASH);
|
|
expect(state.viewData.pendingWait.hash).toBe(TX_HASH);
|
|
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
|
|
|
// Popup closes: timers die with the page.
|
|
txStatus.endWait();
|
|
|
|
// 45 seconds pass with the popup shut, then it is reopened.
|
|
jest.advanceTimersByTime(45000);
|
|
state.viewData = persisted;
|
|
expect(txStatus.restoreWait()).toBe(true);
|
|
|
|
expect(visible("wait-tx")).toBe(true);
|
|
// Elapsed is counted from the broadcast, not from the reopen.
|
|
expect(waitStatusText()).toBe("Waiting for confirmation... 45s");
|
|
// The immediate poll on resume has already run.
|
|
await Promise.resolve();
|
|
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
|
|
|
|
// The deadline is 15 seconds away, not 60.
|
|
await jest.advanceTimersByTimeAsync(20000);
|
|
expect(state.currentView).toBe("error-tx");
|
|
});
|
|
|
|
test("a rejected lookup on the resume poll keeps waiting instead of reporting failure", async () => {
|
|
// A wait resumed after the deadline has already passed: the first
|
|
// poll is immediate and past 60s, so a thrown lookup must not be
|
|
// read as "no receipt". It means "no answer this tick" — keep
|
|
// polling, because the transaction may well have confirmed.
|
|
mockReceiptLookup.mockResolvedValue(null);
|
|
txStatus.showWait(TX_INFO, TX_HASH);
|
|
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
|
txStatus.endWait();
|
|
|
|
// Ten minutes with the popup shut, then it is reopened and the
|
|
// first receipt lookup fails transiently.
|
|
jest.advanceTimersByTime(600000);
|
|
mockReceiptLookup.mockReset();
|
|
mockReceiptLookup
|
|
.mockRejectedValueOnce(new Error("rpc unavailable"))
|
|
.mockResolvedValue({ blockNumber: 21000000 });
|
|
|
|
state.viewData = persisted;
|
|
expect(txStatus.restoreWait()).toBe(true);
|
|
await jest.advanceTimersByTimeAsync(0);
|
|
|
|
// The wait is still alive: no timeout was declared off one error.
|
|
expect(visible("wait-tx")).toBe(true);
|
|
expect(visible("error-tx")).toBe(false);
|
|
expect(state.currentView).toBe("wait-tx");
|
|
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
|
|
|
// And the next tick answers, so the confirmed transaction is
|
|
// reported as confirmed.
|
|
await jest.advanceTimersByTimeAsync(10000);
|
|
expect(state.currentView).toBe("success-tx");
|
|
expect(state.viewData.blockNumber).toBe(21000000);
|
|
});
|
|
|
|
test("a lookup returning null past the deadline still times out", async () => {
|
|
// The counterpart to the test above: the deadline must still fire
|
|
// when the lookup actually answers "no receipt".
|
|
mockReceiptLookup.mockResolvedValue(null);
|
|
txStatus.showWait(TX_INFO, TX_HASH);
|
|
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
|
txStatus.endWait();
|
|
|
|
jest.advanceTimersByTime(600000);
|
|
state.viewData = persisted;
|
|
expect(txStatus.restoreWait()).toBe(true);
|
|
await jest.advanceTimersByTimeAsync(0);
|
|
|
|
expect(state.currentView).toBe("error-tx");
|
|
expect(state.viewData.message).toMatch(
|
|
/not confirmed within 60 seconds/,
|
|
);
|
|
});
|
|
|
|
test("restoreWait reports nothing to resume when no wait is persisted", () => {
|
|
state.viewData = {};
|
|
expect(txStatus.restoreWait()).toBe(false);
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
test("restoreWait rejects a persisted wait missing its txInfo or broadcast time", () => {
|
|
for (const bad of [
|
|
{ hash: TX_HASH, broadcastTime: Date.now() },
|
|
{ hash: TX_HASH, txInfo: TX_INFO },
|
|
{ hash: TX_HASH, txInfo: TX_INFO, broadcastTime: "soon" },
|
|
{ hash: TX_HASH, txInfo: TX_INFO, broadcastTime: NaN },
|
|
{ hash: TX_HASH, txInfo: "nope", broadcastTime: Date.now() },
|
|
// An object that merely lacks a field startWait() dereferences
|
|
// is the shape that actually escaped: txInfo.to reaches
|
|
// addressTitle(), which calls address.toLowerCase(). typeof []
|
|
// is "object", so an array passes an object check.
|
|
{ hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() },
|
|
{ hash: TX_HASH, txInfo: [], broadcastTime: Date.now() },
|
|
{ hash: TX_HASH, txInfo: { to: 42 }, broadcastTime: Date.now() },
|
|
// Otherwise complete but for a non-string `to`: only the `to`
|
|
// check rejects this one, and without it addressTitle() throws
|
|
// out of restoreView().
|
|
{
|
|
hash: TX_HASH,
|
|
txInfo: { to: 42, amount: "0.0050" },
|
|
broadcastTime: Date.now(),
|
|
},
|
|
// Otherwise complete but an array: only Array.isArray() rejects
|
|
// it, since typeof [] is "object" and the fields are present.
|
|
{
|
|
hash: TX_HASH,
|
|
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
|
|
broadcastTime: Date.now(),
|
|
},
|
|
{
|
|
hash: TX_HASH,
|
|
txInfo: { to: RECIPIENT },
|
|
broadcastTime: Date.now(),
|
|
},
|
|
]) {
|
|
state.viewData = { pendingWait: bad };
|
|
expect(txStatus.restoreWait()).toBe(false);
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
}
|
|
});
|
|
|
|
test("restoreWait resumes a wait whose recipient is the empty string", () => {
|
|
// The shape a contract-deployment approval persists: approval.js
|
|
// writes `to: toAddr || ""`, and showWait() renders it without
|
|
// complaint. Validation must not be stricter than the live path, or
|
|
// that wait is silently abandoned on every popup open.
|
|
mockReceiptLookup.mockResolvedValue(null);
|
|
state.viewData = {
|
|
pendingWait: {
|
|
hash: TX_HASH,
|
|
txInfo: { ...TX_INFO, to: "" },
|
|
broadcastTime: Date.now(),
|
|
},
|
|
};
|
|
expect(txStatus.restoreWait()).toBe(true);
|
|
expect(visible("wait-tx")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("WaitTx against an RPC that never answers", () => {
|
|
test("a permanently failing lookup ends the wait instead of polling forever", async () => {
|
|
mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable"));
|
|
|
|
txStatus.showWait(TX_INFO, TX_HASH);
|
|
|
|
// Six consecutive failures is 60 seconds at the 10s cadence — the
|
|
// same patience as the confirmation deadline.
|
|
await jest.advanceTimersByTimeAsync(60000);
|
|
|
|
expect(state.currentView).toBe("error-tx");
|
|
expect(visible("wait-tx")).toBe(false);
|
|
// The user is told what actually happened: the lookup failed. It is
|
|
// not the same fact as "the transaction did not confirm".
|
|
expect(state.viewData.message).toMatch(/could not be reached/i);
|
|
expect(state.viewData.message).not.toMatch(/not confirmed within/);
|
|
expect(state.viewData.hash).toBe(TX_HASH);
|
|
|
|
// Nothing is left running, and nothing is left to resume onto.
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
expect(state.viewData.pendingWait).toBeUndefined();
|
|
|
|
const calls = mockReceiptLookup.mock.calls.length;
|
|
await jest.advanceTimersByTimeAsync(3600000);
|
|
expect(mockReceiptLookup).toHaveBeenCalledTimes(calls);
|
|
expect(state.currentView).toBe("error-tx");
|
|
});
|
|
|
|
test("an answered lookup clears the failure count, so the bound is on consecutive failures", async () => {
|
|
// The bound counts failures in a row, not failures in total: a
|
|
// flaky RPC that keeps answering in between must not accumulate its
|
|
// way to a false "network unreachable".
|
|
//
|
|
// Polls 1-5 (t=10s..50s) alternate reject / null, so three fail and
|
|
// the last answer resets the count at poll 4. From poll 6 on every
|
|
// lookup fails. Six in a row is then poll 10, at t=100s. A counter
|
|
// that never reset would have reached six at poll 8, t=80s, so the
|
|
// window between those two is what this test occupies.
|
|
mockReceiptLookup.mockImplementation(() => {
|
|
const n = mockReceiptLookup.mock.calls.length;
|
|
if (n <= 5 && n % 2 === 0) return Promise.resolve(null);
|
|
return Promise.reject(new Error("flaky"));
|
|
});
|
|
|
|
txStatus.showWait(TX_INFO, TX_HASH);
|
|
|
|
// t=90s: eight failures in total, five of them in a row. A
|
|
// cumulative counter has long since fired; a consecutive one has not.
|
|
await jest.advanceTimersByTimeAsync(90000);
|
|
expect(state.currentView).toBe("wait-tx");
|
|
expect(visible("wait-tx")).toBe(true);
|
|
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
|
|
|
// t=100s: the sixth in a row.
|
|
await jest.advanceTimersByTimeAsync(10000);
|
|
expect(state.currentView).toBe("error-tx");
|
|
expect(state.viewData.message).toMatch(/could not be reached/i);
|
|
// No lookup ever answered "no receipt" past the deadline, so this
|
|
// is not the timeout and must not be reported as one.
|
|
expect(state.viewData.message).not.toMatch(/not confirmed within/);
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
test("a resumed wait against a dead RPC also terminates", async () => {
|
|
// The reopen path is the one that made this unbounded: the wait is
|
|
// persisted, so without a bound every popup open resumes it forever.
|
|
mockReceiptLookup.mockResolvedValue(null);
|
|
txStatus.showWait(TX_INFO, TX_HASH);
|
|
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
|
txStatus.endWait();
|
|
|
|
jest.advanceTimersByTime(3600000);
|
|
mockReceiptLookup.mockReset();
|
|
mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable"));
|
|
|
|
state.viewData = persisted;
|
|
expect(txStatus.restoreWait()).toBe(true);
|
|
await jest.advanceTimersByTimeAsync(60000);
|
|
|
|
expect(state.currentView).toBe("error-tx");
|
|
expect(state.viewData.message).toMatch(/could not be reached/i);
|
|
expect(jest.getTimerCount()).toBe(0);
|
|
expect(state.viewData.pendingWait).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("wait-tx is a view the popup may reopen onto", () => {
|
|
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
|
|
// refuses any view not in the set, so dropping "wait-tx" from it kills
|
|
// the resume silently — the tests above call restoreWait() directly and
|
|
// would all still pass. This pins the membership. Mirrors the exclusion
|
|
// assertions in tests/showPhrase.test.js.
|
|
test("wait-tx is restorable", () => {
|
|
expect(RESTORABLE_VIEWS.has("wait-tx")).toBe(true);
|
|
});
|
|
});
|