Compare commits
2 Commits
2049b7c815
...
5191737afe
| Author | SHA1 | Date | |
|---|---|---|---|
| 5191737afe | |||
| b882cede9f |
@@ -504,10 +504,14 @@ transitions.
|
||||
- To: color dot + full address + etherscan link
|
||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||
- Count-up timer: "Waiting for confirmation... Ns"
|
||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds.
|
||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
||||
persisted: closing and reopening the popup resumes the poll, with the elapsed
|
||||
counter and the timeout deadline still measured from the original broadcast.
|
||||
- **Transitions**:
|
||||
- Receipt found → **SuccessTx**
|
||||
- 60 seconds without confirmation → **ErrorTx** (timeout message)
|
||||
- Exactly one of the two: a receipt found on the tick that crosses the
|
||||
deadline wins, and neither outcome can be rendered over the other
|
||||
|
||||
#### SuccessTx
|
||||
|
||||
@@ -980,7 +984,7 @@ Currently supported:
|
||||
|
||||
### Wallet Management
|
||||
|
||||
- [ ] Delete wallet (with confirmation)
|
||||
- [x] Delete wallet (with confirmation)
|
||||
- [ ] Delete address from HD wallet (with confirmation)
|
||||
- [ ] Show wallet's recovery phrase (requires password)
|
||||
|
||||
|
||||
8
TODO.md
8
TODO.md
@@ -44,6 +44,14 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-11: WaitTx lifecycle: a receipt and the 60-second timeout can no
|
||||
longer both render on one tick, no timer or in-flight lookup outlives its
|
||||
wait, and the wait now resumes after a popup close
|
||||
([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)).
|
||||
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
|
||||
remaining wallets, the selection only moves when it was deleted, and the
|
||||
active-address change is broadcast to connected sites
|
||||
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
|
||||
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
|
||||
on `next`, with Status and Next Step refreshed
|
||||
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).
|
||||
|
||||
@@ -110,6 +110,7 @@ const RESTORABLE_VIEWS = new Set([
|
||||
"settings-addtoken",
|
||||
"confirm-tx",
|
||||
"transaction",
|
||||
"wait-tx",
|
||||
"success-tx",
|
||||
"error-tx",
|
||||
]);
|
||||
@@ -176,6 +177,12 @@ function restoreView() {
|
||||
fallbackView();
|
||||
}
|
||||
break;
|
||||
case "wait-tx":
|
||||
// Resumes the receipt poll from the persisted broadcast time.
|
||||
if (!txStatus.restoreWait()) {
|
||||
fallbackView();
|
||||
}
|
||||
break;
|
||||
case "success-tx":
|
||||
if (state.viewData && state.viewData.hash) {
|
||||
txStatus.renderSuccess();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const {
|
||||
removeWalletFromState,
|
||||
broadcastActiveChanged,
|
||||
} = require("../../shared/walletDelete");
|
||||
|
||||
let deleteWalletIndex = null;
|
||||
let ctx = null;
|
||||
@@ -58,35 +62,24 @@ function init(_ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect addresses to clean up from allowedSites/deniedSites
|
||||
const addresses = (wallet.addresses || []).map((a) => a.address);
|
||||
|
||||
// Remove wallet
|
||||
state.wallets.splice(walletIdx, 1);
|
||||
|
||||
// Clean up site permissions for deleted addresses
|
||||
for (const addr of addresses) {
|
||||
delete state.allowedSites[addr];
|
||||
delete state.deniedSites[addr];
|
||||
}
|
||||
// Remove the wallet and repair selection, permissions and hasWallet
|
||||
const { activeAddressChanged } = removeWalletFromState(
|
||||
state,
|
||||
walletIdx,
|
||||
);
|
||||
|
||||
deleteWalletIndex = null;
|
||||
|
||||
if (state.wallets.length === 0) {
|
||||
// No wallets left — reset selection and show welcome
|
||||
state.selectedWallet = null;
|
||||
state.selectedAddress = null;
|
||||
state.activeAddress = null;
|
||||
if (!state.hasWallet) {
|
||||
clearViewStack();
|
||||
await saveState();
|
||||
// Save before broadcasting: the background reads the active
|
||||
// address back out of storage to build accountsChanged.
|
||||
if (activeAddressChanged) broadcastActiveChanged();
|
||||
showView("welcome");
|
||||
} else {
|
||||
// Switch to first wallet if deleted wallet was active
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
state.activeAddress =
|
||||
state.wallets[0].addresses[0]?.address || null;
|
||||
await saveState();
|
||||
if (activeAddressChanged) broadcastActiveChanged();
|
||||
// Reset stack to [main] so Settings back goes home.
|
||||
// Use require() lazily to avoid circular dependency
|
||||
// (settings.js requires deleteWallet.js).
|
||||
|
||||
@@ -16,11 +16,26 @@ const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { log } = require("../../shared/log");
|
||||
|
||||
// Receipt poll cadence and the deadline after which the wait is reported as
|
||||
// a timeout. Both are documented in the WaitTx section of README.md.
|
||||
const POLL_INTERVAL_MS = 10000;
|
||||
const TIMEOUT_MS = 60000;
|
||||
|
||||
let ctx;
|
||||
let elapsedTimer = null;
|
||||
let pollTimer = null;
|
||||
|
||||
function clearTimers() {
|
||||
// Identifies the wait currently on screen. Bumped by endWait(), so a timer
|
||||
// callback or an in-flight receipt lookup that outlives its wait can tell
|
||||
// that it is stale and leave the current view alone. Without it, a receipt
|
||||
// resolving after the wait has ended renders over whatever view replaced it.
|
||||
let waitId = 0;
|
||||
|
||||
// End the wait on screen: stop its timers and invalidate its pending async
|
||||
// work. Called on receipt, on timeout, when a new wait starts, and when the
|
||||
// user navigates away.
|
||||
function endWait() {
|
||||
waitId++;
|
||||
if (elapsedTimer) {
|
||||
clearInterval(elapsedTimer);
|
||||
elapsedTimer = null;
|
||||
@@ -47,8 +62,13 @@ function blockNumberHtml(blockNumber) {
|
||||
return copyableHtml(num) + etherscanLinkHtml(link);
|
||||
}
|
||||
|
||||
function showWait(txInfo, txHash) {
|
||||
clearTimers();
|
||||
// Render the wait view and start polling for the receipt. broadcastTime is
|
||||
// when the transaction was broadcast, which is what the elapsed counter and
|
||||
// the timeout deadline are both measured from; pollNow runs one lookup
|
||||
// immediately instead of waiting a full poll interval.
|
||||
function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
endWait();
|
||||
const id = waitId;
|
||||
|
||||
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
||||
@@ -56,41 +76,81 @@ function showWait(txInfo, txHash) {
|
||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
|
||||
attachCopyHandlers("view-wait-tx");
|
||||
|
||||
const broadcastTime = Date.now();
|
||||
$("wait-tx-status").textContent = "Waiting for confirmation... 0s";
|
||||
// Persisted so closing and reopening the popup resumes this wait
|
||||
// instead of silently abandoning it.
|
||||
state.viewData = {
|
||||
pendingWait: {
|
||||
txInfo: txInfo,
|
||||
hash: txHash,
|
||||
broadcastTime: broadcastTime,
|
||||
},
|
||||
};
|
||||
|
||||
elapsedTimer = setInterval(() => {
|
||||
function renderElapsed() {
|
||||
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
|
||||
$("wait-tx-status").textContent =
|
||||
"Waiting for confirmation... " + elapsed + "s";
|
||||
}
|
||||
renderElapsed();
|
||||
|
||||
elapsedTimer = setInterval(() => {
|
||||
if (id !== waitId) return;
|
||||
renderElapsed();
|
||||
}, 1000);
|
||||
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
pollTimer = setInterval(async () => {
|
||||
|
||||
async function poll() {
|
||||
if (id !== waitId) return;
|
||||
let receipt = null;
|
||||
try {
|
||||
const receipt = await provider.getTransactionReceipt(txHash);
|
||||
if (receipt) {
|
||||
showSuccess(txInfo, txHash, receipt.blockNumber);
|
||||
}
|
||||
receipt = await provider.getTransactionReceipt(txHash);
|
||||
} catch (e) {
|
||||
log.errorf("poll receipt failed:", e.message);
|
||||
}
|
||||
|
||||
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
|
||||
if (elapsed >= 60) {
|
||||
// The lookup is async: the wait may have ended while it was in
|
||||
// flight, in which case this result must not touch the view.
|
||||
if (id !== waitId) return;
|
||||
// Exactly one outcome per wait. A receipt wins even on the tick
|
||||
// that crosses the deadline, because the transaction did confirm.
|
||||
if (receipt) {
|
||||
showSuccess(txInfo, txHash, receipt.blockNumber);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - broadcastTime >= TIMEOUT_MS) {
|
||||
showError(
|
||||
txInfo,
|
||||
txHash,
|
||||
"Transaction was not confirmed within 60 seconds. It may still confirm later \u2014 check Etherscan.",
|
||||
);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
pollTimer = setInterval(poll, POLL_INTERVAL_MS);
|
||||
|
||||
showView("wait-tx");
|
||||
|
||||
if (pollNow) poll();
|
||||
}
|
||||
|
||||
function showWait(txInfo, txHash) {
|
||||
startWait(txInfo, txHash, Date.now(), false);
|
||||
}
|
||||
|
||||
// Resume a wait persisted by a previous popup session. The deadline still
|
||||
// runs from the original broadcast, so a wait that has already outlived it
|
||||
// resolves on the immediate first poll rather than restarting the clock.
|
||||
// Returns false when there is nothing to resume.
|
||||
function restoreWait() {
|
||||
const d = state.viewData;
|
||||
if (!d || !d.pendingWait || !d.pendingWait.hash) return false;
|
||||
const w = d.pendingWait;
|
||||
startWait(w.txInfo, w.hash, w.broadcastTime, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function showSuccess(txInfo, txHash, blockNumber) {
|
||||
clearTimers();
|
||||
endWait();
|
||||
|
||||
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||
state.viewData = {
|
||||
@@ -182,7 +242,7 @@ function renderSuccess() {
|
||||
}
|
||||
|
||||
function showError(txInfo, txHash, message) {
|
||||
clearTimers();
|
||||
endWait();
|
||||
|
||||
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||
state.viewData = {
|
||||
@@ -218,6 +278,9 @@ function isApprovalPopup() {
|
||||
}
|
||||
|
||||
function navigateBack() {
|
||||
// Nothing should still be polling by now, but leaving a view is the
|
||||
// point at which its timers must be gone.
|
||||
endWait();
|
||||
if (isApprovalPopup()) {
|
||||
window.close();
|
||||
return;
|
||||
@@ -242,4 +305,12 @@ function init(_ctx) {
|
||||
$("btn-error-tx-done").addEventListener("click", navigateBack);
|
||||
}
|
||||
|
||||
module.exports = { init, showWait, showError, renderSuccess, renderError };
|
||||
module.exports = {
|
||||
init,
|
||||
showWait,
|
||||
restoreWait,
|
||||
endWait,
|
||||
showError,
|
||||
renderSuccess,
|
||||
renderError,
|
||||
};
|
||||
|
||||
70
src/shared/walletDelete.js
Normal file
70
src/shared/walletDelete.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// Wallet deletion state transition, kept out of the view so the selection
|
||||
// and broadcast rules are testable without a DOM.
|
||||
|
||||
// Remove wallet `walletIdx` from `state` and repair the derived state.
|
||||
//
|
||||
// Rules:
|
||||
// - `hasWallet` tracks whether any wallet remains.
|
||||
// - Site permissions are dropped for every address of the deleted wallet.
|
||||
// - `selectedWallet` follows the splice: it is decremented when a wallet
|
||||
// before it was removed, and falls back to the first remaining wallet's
|
||||
// first address only when the selection itself was deleted.
|
||||
// - `activeAddress` is only moved when it belonged to the deleted wallet;
|
||||
// the fallback is the first remaining wallet's first address, or null
|
||||
// when no wallet remains.
|
||||
//
|
||||
// Returns whether `activeAddress` changed, so the caller can broadcast it.
|
||||
function removeWalletFromState(state, walletIdx) {
|
||||
const wallet = state.wallets[walletIdx];
|
||||
const addresses = (wallet.addresses || []).map((a) => a.address);
|
||||
const previousActive = state.activeAddress;
|
||||
const activeWasDeleted =
|
||||
previousActive !== null &&
|
||||
previousActive !== undefined &&
|
||||
addresses.some(
|
||||
(a) => a.toLowerCase() === String(previousActive).toLowerCase(),
|
||||
);
|
||||
|
||||
state.wallets.splice(walletIdx, 1);
|
||||
|
||||
for (const addr of addresses) {
|
||||
delete state.allowedSites[addr];
|
||||
delete state.deniedSites[addr];
|
||||
}
|
||||
|
||||
state.hasWallet = state.wallets.length > 0;
|
||||
|
||||
const fallbackAddress = state.hasWallet
|
||||
? state.wallets[0].addresses[0]?.address || null
|
||||
: null;
|
||||
|
||||
if (!state.hasWallet) {
|
||||
state.selectedWallet = null;
|
||||
state.selectedAddress = null;
|
||||
} else if (state.selectedWallet === walletIdx) {
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
} else if (
|
||||
typeof state.selectedWallet === "number" &&
|
||||
state.selectedWallet > walletIdx
|
||||
) {
|
||||
state.selectedWallet -= 1;
|
||||
}
|
||||
|
||||
if (activeWasDeleted || !state.hasWallet) {
|
||||
state.activeAddress = fallbackAddress;
|
||||
}
|
||||
|
||||
return { activeAddressChanged: state.activeAddress !== previousActive };
|
||||
}
|
||||
|
||||
// Tell the background the active address changed, so it re-emits
|
||||
// accountsChanged to connected sites. Same call shape as the address
|
||||
// switch in the home view.
|
||||
function broadcastActiveChanged() {
|
||||
const runtime =
|
||||
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
|
||||
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
||||
}
|
||||
|
||||
module.exports = { removeWalletFromState, broadcastActiveChanged };
|
||||
272
tests/txStatus.test.js
Normal file
272
tests/txStatus.test.js
Normal file
@@ -0,0 +1,272 @@
|
||||
// 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: "" } };
|
||||
|
||||
const stored = {};
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
set: (obj) => {
|
||||
Object.assign(stored, obj);
|
||||
return Promise.resolve();
|
||||
},
|
||||
get: () => Promise.resolve(stored),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
const { state } = require("../src/shared/state");
|
||||
|
||||
const 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("restoreWait reports nothing to resume when no wait is persisted", () => {
|
||||
state.viewData = {};
|
||||
expect(txStatus.restoreWait()).toBe(false);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
129
tests/walletDelete.test.js
Normal file
129
tests/walletDelete.test.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const {
|
||||
removeWalletFromState,
|
||||
broadcastActiveChanged,
|
||||
} = require("../src/shared/walletDelete");
|
||||
|
||||
// Fixed addresses — never used for anything but these tests.
|
||||
const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
|
||||
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
|
||||
function wallet(name, addresses) {
|
||||
return {
|
||||
name,
|
||||
addresses: addresses.map((address) => ({ address })),
|
||||
};
|
||||
}
|
||||
|
||||
// A three-wallet state; wallet A is an HD wallet with two addresses.
|
||||
function makeState(overrides = {}) {
|
||||
return {
|
||||
hasWallet: true,
|
||||
wallets: [wallet("A", [A0, A1]), wallet("B", [B0]), wallet("C", [C0])],
|
||||
selectedWallet: 0,
|
||||
selectedAddress: 0,
|
||||
activeAddress: A0,
|
||||
allowedSites: {
|
||||
[A0]: ["a.example"],
|
||||
[A1]: ["b.example"],
|
||||
[B0]: ["c.example"],
|
||||
},
|
||||
deniedSites: { [A1]: ["d.example"], [C0]: ["e.example"] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("removeWalletFromState", () => {
|
||||
test("deleting the last wallet clears hasWallet", () => {
|
||||
const state = makeState({
|
||||
wallets: [wallet("A", [A0])],
|
||||
allowedSites: { [A0]: ["a.example"] },
|
||||
deniedSites: {},
|
||||
});
|
||||
|
||||
const { activeAddressChanged } = removeWalletFromState(state, 0);
|
||||
|
||||
expect(state.hasWallet).toBe(false);
|
||||
expect(state.wallets).toEqual([]);
|
||||
expect(state.selectedWallet).toBeNull();
|
||||
expect(state.selectedAddress).toBeNull();
|
||||
expect(state.activeAddress).toBeNull();
|
||||
expect(activeAddressChanged).toBe(true);
|
||||
});
|
||||
|
||||
test("deleting a non-selected wallet leaves the selection intact", () => {
|
||||
const state = makeState({
|
||||
selectedWallet: 2,
|
||||
selectedAddress: 0,
|
||||
activeAddress: C0,
|
||||
});
|
||||
|
||||
const { activeAddressChanged } = removeWalletFromState(state, 1);
|
||||
|
||||
// Wallet C moved from index 2 to index 1 by the splice.
|
||||
expect(state.wallets.map((w) => w.name)).toEqual(["A", "C"]);
|
||||
expect(state.selectedWallet).toBe(1);
|
||||
expect(state.selectedAddress).toBe(0);
|
||||
expect(state.activeAddress).toBe(C0);
|
||||
expect(activeAddressChanged).toBe(false);
|
||||
expect(state.hasWallet).toBe(true);
|
||||
});
|
||||
|
||||
test("deleting a wallet after the selection does not shift it", () => {
|
||||
const state = makeState({
|
||||
selectedWallet: 1,
|
||||
selectedAddress: 0,
|
||||
activeAddress: B0,
|
||||
});
|
||||
|
||||
const { activeAddressChanged } = removeWalletFromState(state, 2);
|
||||
|
||||
expect(state.selectedWallet).toBe(1);
|
||||
expect(state.activeAddress).toBe(B0);
|
||||
expect(activeAddressChanged).toBe(false);
|
||||
});
|
||||
|
||||
test("deleting the active wallet falls back to the first remaining address", () => {
|
||||
const state = makeState({
|
||||
selectedWallet: 0,
|
||||
selectedAddress: 1,
|
||||
activeAddress: A1,
|
||||
});
|
||||
|
||||
const { activeAddressChanged } = removeWalletFromState(state, 0);
|
||||
|
||||
expect(state.wallets.map((w) => w.name)).toEqual(["B", "C"]);
|
||||
expect(state.selectedWallet).toBe(0);
|
||||
expect(state.selectedAddress).toBe(0);
|
||||
expect(state.activeAddress).toBe(B0);
|
||||
expect(activeAddressChanged).toBe(true);
|
||||
expect(state.hasWallet).toBe(true);
|
||||
});
|
||||
|
||||
test("site permissions are dropped for every address of the wallet", () => {
|
||||
const state = makeState();
|
||||
|
||||
removeWalletFromState(state, 0);
|
||||
|
||||
expect(state.allowedSites).toEqual({ [B0]: ["c.example"] });
|
||||
expect(state.deniedSites).toEqual({ [C0]: ["e.example"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("broadcastActiveChanged", () => {
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
test("sends AUTISTMASK_ACTIVE_CHANGED to the background", () => {
|
||||
const sendMessage = jest.fn();
|
||||
global.chrome = { runtime: { sendMessage } };
|
||||
|
||||
broadcastActiveChanged();
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
type: "AUTISTMASK_ACTIVE_CHANGED",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user