2 Commits
Author SHA1 Message Date
sneak c32e44d6c6 test: a failing e2e test leaves no fixture switch or send screen behind (closes #318)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
A test that turns a fixture switch on for itself alone turns it off in a
finally, so a failure no longer reddens the tests after it. The two tests
that drive the popup's own send also return it to the address screen,
reopening the popup to leave a wait for a receipt. The lying-decimals()
test asserts that nothing was broadcast as soon as the send ends, before
waiting for the failure screen. ethCallResult() answers an override of 0
instead of falling back to the explorer's scale.

Model: opus-5-5
2026-10-05 07:26:49 +00:00
clawbot 6fece80afd chore: remove dead exports and share copied view helpers (closes #168)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
AddressDetail and AddressToken defined their own isoDate() and
timeAgo(), hiding the shared pair in helpers.js, so a fix there would
not have reached them. The copies were identical and are deleted;
blockieHtml() and tokenLabel(), each defined twice, move to helpers.js.
A new test shows the history rows and the transaction detail view
write the time with the shared pair.

Deleted as never called: explorerLink(), ETHEREUM_SEPOLIA_CHAIN_ID,
getWalletValue() and getTotalValue() with their tests. Home's "Total:"
is the active address's total, as README.md already says.
addressColor() and etherscanAddressUrl() are no longer exported.

Model: opus-5-5
2026-10-05 09:09:06 +02:00
18 changed files with 634 additions and 544 deletions
+6 -4
View File
@@ -840,10 +840,12 @@ something when you click it.
The same data must be formatted identically everywhere it appears. Token and ETH The same data must be formatted identically everywhere it appears. Token and ETH
amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900 amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900
USDT") in balance lists, transaction lists, send confirmations, and approval USDT") in balance lists, transaction lists, send confirmations, and approval
screens. Timestamps include both an ISO datetime and a humanized relative age screens. A transaction's time includes both an ISO datetime and a humanized
wherever shown. If a formatting rule applies in one place, it applies in every relative age, written by `isoDate()` and `timeAgo()` in
place. Users should never see the same value rendered differently on two `src/popup/views/helpers.js` on every screen that shows one; the ISO datetime is
screens. in UTC when the UTC Timestamps setting is on. If a formatting rule applies in
one place, it applies in every place. Users should never see the same value
rendered differently on two screens.
The native token's label is a network's `nativeCurrency` in The native token's label is a network's `nativeCurrency` in
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The `src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
+25
View File
@@ -45,6 +45,31 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-10-05: A Chrome end-to-end test that fails no longer takes later tests
down with it ([#318](https://git.eeqj.de/sneak/AutistMask/issues/318)). Each
test that turns a fixture switch on for itself alone (a held or failing gas
estimate, a seeded native transfer or receipt, a token's lying `decimals()` or
markup symbol) turns it off again in a `finally`, and the two tests that drive
the popup's own send end on the address screen whether they pass or not,
reopening the popup to leave a wait for a receipt. The lying-`decimals()` test
checks that nothing was broadcast as soon as the send ends, before it waits
for the failure screen, so a broadcast fails it in seconds rather than after a
60-second wait. The fixture's `decimals()` override tells 0 from no override,
so a token with no decimal places can be fixtured.
- 2026-10-05: Dead code removed and copied view helpers shared
([#168](https://git.eeqj.de/sneak/AutistMask/issues/168)). AddressDetail and
AddressToken each defined their own `isoDate()` and `timeAgo()` in place of
the ones in `src/popup/views/helpers.js`, so a fix to the shared pair would
not have reached them. The copies were identical; every screen now uses the
shared pair. `blockieHtml()` and `tokenLabel()`, each defined twice, live in
`helpers.js` too. Removed as never called: `explorerLink()` (the views build
explorer links with `explorerUrl()`), `ETHEREUM_SEPOLIA_CHAIN_ID` (the chain
id lives in `src/shared/networks.js`), and `getWalletValue()` and
`getTotalValue()`: Home's "Total:" is the active address's total, as
`README.md` says. `addressColor()` and `etherscanAddressUrl()` are no longer
exported. Nothing the user sees changed.
- 2026-10-05: A prompt raised while another approval window has focus opens a - 2026-10-05: A prompt raised while another approval window has focus opens a
window of its own ([#290](https://git.eeqj.de/sneak/AutistMask/issues/290)). window of its own ([#290](https://git.eeqj.de/sneak/AutistMask/issues/290)).
The background centred each approval window on the last focused window, which The background centred each approval window on the last focused window, which
+2 -56
View File
@@ -11,6 +11,8 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices"); const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
@@ -88,62 +90,6 @@ function show() {
loadTransactions(addr.address); loadTransactions(addr.address);
} }
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
let loadedTxs = []; let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
+2 -56
View File
@@ -17,6 +17,8 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
@@ -37,62 +39,6 @@ const { walletDefect } = require("../../shared/walletDefects");
let ctx; let ctx;
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
if (state.utcTimestamps) {
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
"T" +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
"Z"
);
}
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const absOff = Math.abs(offsetMin);
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
"T" +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds()) +
tzStr
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
let loadedTxs = []; let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
let currentSymbol = null; let currentSymbol = null;
+1 -11
View File
@@ -10,6 +10,7 @@ const {
attachCopyHandlers, attachCopyHandlers,
onViewLeave, onViewLeave,
formatFee, formatFee,
tokenLabel,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { const {
@@ -73,17 +74,6 @@ function tokenAmountText(rawAmount, decimals, symbol) {
}; };
} }
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// Try to decode calldata using known ABIs. // Try to decode calldata using known ABIs.
// Returns { name, description, details } or null. // Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) { function decodeCalldata(data, toAddress) {
+1 -6
View File
@@ -13,6 +13,7 @@ const {
displaySymbol, displaySymbol,
nativeCurrency, nativeCurrency,
renderAddressHtml, renderAddressHtml,
blockieHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
onViewLeave, onViewLeave,
@@ -47,7 +48,6 @@ const {
validateTransfer, validateTransfer,
} = require("../../shared/txValidation"); } = require("../../shared/txValidation");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
let pendingTx = null; let pendingTx = null;
@@ -67,11 +67,6 @@ function restore() {
} }
} }
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function confirmAddressHtml(address, ensName, title) { function confirmAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
return ( return (
+23 -2
View File
@@ -13,10 +13,12 @@
// reasoning behind it are; it is re-exported below so views keep importing // reasoning behind it are; it is re-exported below so views keep importing
// it from here. // it from here.
const { formatEther } = require("ethers"); const { formatEther } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { const {
truncateAmountNeverZero, truncateAmountNeverZero,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { DEBUG } = require("../../shared/constants"); const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
@@ -278,6 +280,17 @@ function nativeCurrency() {
return currentNetwork().nativeCurrency; return currentNetwork().nativeCurrency;
} }
// The symbol shown for a token line, resolved from the bundled list, the
// tokens the user tracks, and the explorer's report — the same chain the
// amount line's scale comes from. Null when no source names one, so the token
// lines keep saying `Unknown token` for a token nothing knows.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
// A network fee in wei as the confirmation and approval screens both show it: // A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the // the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
// native currency of the network the fee is paid on, then its USD value when // native currency of the network the fee is paid on, then its USD value when
@@ -446,6 +459,11 @@ function addressDotHtml(address) {
return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`; return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`;
} }
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
// Look up an address across all wallets and return its title // Look up an address across all wallets and return its title
// (e.g. "Address 1.2") or null if it's not one of ours. // (e.g. "Address 1.2") or null if it's not one of ours.
function addressTitle(address, wallets) { function addressTitle(address, wallets) {
@@ -492,6 +510,9 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen }); return renderAddressHtml(address, { title, ensName, maxLen });
} }
// A transaction's time as every screen shows it (README, Display
// Consistency): the ISO datetime, in UTC when the UTC Timestamps setting is
// on, and the relative age. Views import these two; they keep no copies.
function isoDate(timestamp) { function isoDate(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
@@ -691,9 +712,10 @@ module.exports = {
addressHoldsFunds, addressHoldsFunds,
unknownableAmount, unknownableAmount,
nativeCurrency, nativeCurrency,
tokenLabel,
formatFee, formatFee,
addressColor,
addressDotHtml, addressDotHtml,
blockieHtml,
escapeHtml, escapeHtml,
displaySymbol, displaySymbol,
addressTitle, addressTitle,
@@ -703,7 +725,6 @@ module.exports = {
renderAddressHtml, renderAddressHtml,
copyableHtml, copyableHtml,
attachCopyHandlers, attachCopyHandlers,
etherscanAddressUrl,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
EXT_ICON, EXT_ICON,
+2 -2
View File
@@ -12,7 +12,7 @@ function isTracked(address) {
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower); return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
} }
function tokenLabel(t) { function nameAndSymbol(t) {
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol; return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
} }
@@ -60,7 +60,7 @@ function renderDropdown() {
let html = '<option value="">-- select --</option>'; let html = '<option value="">-- select --</option>';
for (const t of tokens) { for (const t of tokens) {
const tracked = isTracked(t.address); const tracked = isTracked(t.address);
const label = tokenLabel(t) + (tracked ? " (tracked)" : ""); const label = nameAndSymbol(t) + (tracked ? " (tracked)" : "");
html += html +=
`<option value="${escapeHtml(t.address)}"` + `<option value="${escapeHtml(t.address)}"` +
` data-symbol="${escapeHtml(t.symbol)}"` + ` data-symbol="${escapeHtml(t.symbol)}"` +
+1 -6
View File
@@ -13,6 +13,7 @@ const {
isoDate, isoDate,
timeAgo, timeAgo,
renderAddressHtml, renderAddressHtml,
blockieHtml,
attachCopyHandlers, attachCopyHandlers,
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
@@ -23,7 +24,6 @@ const {
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
@@ -48,11 +48,6 @@ function getTransactionType(tx) {
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer"; return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
} }
function blockieHtml(address) {
const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
}
function txAddressHtml(address, ensName, title) { function txAddressHtml(address, ensName, title) {
const blockie = blockieHtml(address); const blockie = blockieHtml(address);
return ( return (
+1 -12
View File
@@ -13,8 +13,8 @@ const {
explorerUrl, explorerUrl,
displaySymbol, displaySymbol,
clearViewStack, clearViewStack,
tokenLabel,
} = require("./helpers"); } = require("./helpers");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
@@ -243,17 +243,6 @@ function showSuccess(txInfo, txHash, blockNumber) {
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} }
// The symbol shown for a decoded token line, resolved from the bundled list,
// the tokens the user tracks, and the explorer's report — the same chain the
// approval screen uses. Null when no source names one, so the line keeps
// saying `Unknown token`.
function tokenLabel(address) {
return resolveTokenSymbol(address, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
}
function decodedDetailsHtml(decoded) { function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return ""; if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`; let html = `<div class="border border-border border-dashed p-2 mb-3">`;
-2
View File
@@ -33,7 +33,6 @@ const DEBUG_MNEMONIC = DEBUG
: null; : null;
const ETHEREUM_MAINNET_CHAIN_ID = "0x1"; const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com"; const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
@@ -69,7 +68,6 @@ module.exports = {
BUILD_DEBUG_MARKER, BUILD_DEBUG_MARKER,
DEBUG_MNEMONIC, DEBUG_MNEMONIC,
ETHEREUM_MAINNET_CHAIN_ID, ETHEREUM_MAINNET_CHAIN_ID,
ETHEREUM_SEPOLIA_CHAIN_ID,
DEFAULT_RPC_URL, DEFAULT_RPC_URL,
DEFAULT_BLOCKSCOUT_URL, DEFAULT_BLOCKSCOUT_URL,
BIP44_ETH_PATH, BIP44_ETH_PATH,
-7
View File
@@ -85,12 +85,6 @@ function nativeCurrencyByChainId(chainId) {
return network ? network.nativeCurrency : "ETH"; return network ? network.nativeCurrency : "ETH";
} }
// Build a block explorer link for the given path type and value.
// type: "address" | "tx" | "token" | "block"
function explorerLink(network, type, value) {
return `${network.explorerUrl}/${type}/${value}`;
}
module.exports = { module.exports = {
NETWORKS, NETWORKS,
SUPPORTED_CHAIN_IDS, SUPPORTED_CHAIN_IDS,
@@ -99,5 +93,4 @@ module.exports = {
networkById, networkById,
networkByChainId, networkByChainId,
nativeCurrencyByChainId, nativeCurrencyByChainId,
explorerLink,
}; };
-23
View File
@@ -104,27 +104,6 @@ function getAddressValue(addr) {
return { usd, partial }; return { usd, partial };
} }
// The same pair for a whole wallet, and for every wallet at once. One
// unpriced holding anywhere makes the sum a floor, so partial carries up.
function getWalletValue(wallet) {
return sumValues(wallet.addresses.map(getAddressValue));
}
function getTotalValue(wallets) {
return sumValues(wallets.map(getWalletValue));
}
function sumValues(values) {
let usd = null;
let partial = false;
for (const value of values) {
if (value.usd === null) continue;
usd = (usd === null ? 0 : usd) + value.usd;
partial = partial || value.partial;
}
return { usd, partial };
}
// The one rendering of an address total, so no screen says it differently. // The one rendering of an address total, so no screen says it differently.
// //
// A partial total is shown and named as partial: the figure is the ETH and // A partial total is shown and named as partial: the figure is the ETH and
@@ -149,6 +128,4 @@ module.exports = {
formatUsd, formatUsd,
formatAddressTotal, formatAddressTotal,
getAddressValue, getAddressValue,
getWalletValue,
getTotalValue,
}; };
-13
View File
@@ -22,8 +22,6 @@ const {
prices, prices,
clearPrices, clearPrices,
getAddressValue, getAddressValue,
getWalletValue,
getTotalValue,
formatAddressTotal, formatAddressTotal,
} = require("../src/shared/prices"); } = require("../src/shared/prices");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
@@ -136,17 +134,6 @@ describe("the value of an address, and whether it is the whole value", () => {
partial: false, partial: false,
}); });
}); });
test("one unpriced holding makes a wallet and the grand total partial", () => {
const wallet = { addresses: [FULLY_PRICED, UNPRICED_ONLY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: true });
expect(getTotalValue([wallet])).toEqual({ usd: 5500, partial: true });
});
test("a wallet of fully priced addresses stays complete", () => {
const wallet = { addresses: [FULLY_PRICED, EMPTY] };
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: false });
});
}); });
describe("how that value is written on screen", () => { describe("how that value is written on screen", () => {
+7 -5
View File
@@ -256,14 +256,15 @@ const SELECTOR_DECIMALS = "0x313ce567";
// Blockscout, which is exactly the disagreement the wallet must refuse to // Blockscout, which is exactly the disagreement the wallet must refuse to
// sign over. It is read at request time, so a test flips it on the options // sign over. It is read at request time, so a test flips it on the options
// object the route was registered with — after the confirmation screen has // object the route was registered with — after the confirmation screen has
// been built — without re-registering anything. // been built — without re-registering anything. Only null or undefined means
// no override: 0 is a token with no decimal places, and is answered as one.
function ethCallResult(req, opts) { function ethCallResult(req, opts) {
const call = Array.isArray(req.params) ? req.params[0] : null; const call = Array.isArray(req.params) ? req.params[0] : null;
if (!call || typeof call !== "object") return ZERO_WORD; if (!call || typeof call !== "object") return ZERO_WORD;
const data = String(call.data || call.input || "").toLowerCase(); const data = String(call.data || call.input || "").toLowerCase();
const to = String(call.to || "").toLowerCase(); const to = String(call.to || "").toLowerCase();
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) { if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals); return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
} }
return ZERO_WORD; return ZERO_WORD;
} }
@@ -597,9 +598,10 @@ function traceEnabled(raw) {
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {string[]} [opts.broadcastTransactions] every raw signed * @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order. * transaction handed to eth_sendRawTransaction, appended in order.
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for * @param {number|string|null} [opts.tokenDecimalsOverride] the scale
* the stub token, in place of the value Blockscout reports for it. This is * decimals() answers for the stub token, in place of the value Blockscout
* the token that lies about its scale; read at request time. * reports for it; null for none, while 0 is a scale like any other. This
* is the token that lies about its scale; read at request time.
* @param {string} [opts.tokenSymbolOverride] what the explorer reports as * @param {string} [opts.tokenSymbolOverride] what the explorer reports as
* the stub token's symbol, in place of "E2E". This is the token whose * the stub token's symbol, in place of "E2E". This is the token whose
* symbol is markup; read at request time. * symbol is markup; read at request time.
+100 -33
View File
@@ -352,6 +352,7 @@ test("the native ETH transaction detail still renders (#151)", async (env) => {
// the assertions below are as much about that row staying hidden as // the assertions below are as much about that row staying hidden as
// about the screen coming up. // about the screen coming up.
env.routeOpts.seedNativeTransfer = true; env.routeOpts.seedNativeTransfer = true;
try {
await env.page.reload(); await env.page.reload();
await openAddressDetail(env.page); await openAddressDetail(env.page);
@@ -368,7 +369,9 @@ test("the native ETH transaction detail still renders (#151)", async (env) => {
"the native transaction detail shows the wrong hash: " + hash, "the native transaction detail shows the wrong hash: " + hash,
); );
const type = (await env.page.locator("#tx-detail-type").innerText()).trim(); const type = (
await env.page.locator("#tx-detail-type").innerText()
).trim();
assert( assert(
type === "Native ETH Transfer", type === "Native ETH Transfer",
"the native transaction was classified " + JSON.stringify(type), "the native transaction was classified " + JSON.stringify(type),
@@ -393,13 +396,15 @@ test("the native ETH transaction detail still renders (#151)", async (env) => {
assert( assert(
!(await env.page.isVisible("#tx-detail-token-contract-section")), !(await env.page.isVisible("#tx-detail-token-contract-section")),
"the token contract row is showing on a transfer that has no token " + "the token contract row is showing on a transfer that has no " +
"contract", "token contract",
); );
} finally {
// Back to one seeded transaction for everything after this: the tests // Back to one seeded transaction for everything after this: the
// below were written against a list holding the token transfer alone. // tests below were written against a list holding the token transfer
// alone.
env.routeOpts.seedNativeTransfer = false; env.routeOpts.seedNativeTransfer = false;
}
}); });
test("tap-to-copy on the transaction detail screen copies the address (#151)", async (env) => { test("tap-to-copy on the transaction detail screen copies the address (#151)", async (env) => {
@@ -1771,6 +1776,7 @@ test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async
expectToken: true, expectToken: true,
}); });
// Released by the next test, which watches the estimate land.
env.routeOpts.holdGasEstimate = true; env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, { await goToConfirm(env.page, {
token: "ETH", token: "ETH",
@@ -1933,6 +1939,7 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
); );
env.routeOpts.failGasEstimate = true; env.routeOpts.failGasEstimate = true;
env.routeOpts.holdGasEstimate = true; env.routeOpts.holdGasEstimate = true;
try {
await goToConfirm(env.page, { await goToConfirm(env.page, {
token: "ETH", token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH", balance: FUNDED_ETH_TEXT + " ETH",
@@ -1949,10 +1956,10 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
await waitForEstimate(env.page); await waitForEstimate(env.page);
const st = await confirmState(env.page); const st = await confirmState(env.page);
env.routeOpts.failGasEstimate = false;
assert( assert(
st.fee === "Unable to estimate", st.fee === "Unable to estimate",
"the fee line does not report the failure: " + JSON.stringify(st.fee), "the fee line does not report the failure: " +
JSON.stringify(st.fee),
); );
assert( assert(
!st.reserveShown, !st.reserveShown,
@@ -1978,10 +1985,15 @@ test("ConfirmTx refuses to send when the fee estimate fails, with its own messag
st.height + st.height +
"px", "px",
); );
} finally {
env.routeOpts.failGasEstimate = false;
env.routeOpts.holdGasEstimate = false;
}
}); });
test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (env) => { test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (env) => {
env.routeOpts.holdGasEstimate = true; env.routeOpts.holdGasEstimate = true;
try {
await goToConfirm(env.page, { await goToConfirm(env.page, {
token: STUB_TOKEN.address, token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol, balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
@@ -1990,9 +2002,12 @@ test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (en
const pending = await confirmState(env.page); const pending = await confirmState(env.page);
env.erc20PendingHeight = pending.height; env.erc20PendingHeight = pending.height;
console.log("# confirm-tx ERC-20 view height: " + pending.height + "px"); console.log(
"# confirm-tx ERC-20 view height: " + pending.height + "px",
);
assert( assert(
pending.type === "ERC-20 token transfer (" + STUB_TOKEN.symbol + ")", pending.type ===
"ERC-20 token transfer (" + STUB_TOKEN.symbol + ")",
"unexpected transaction type: " + JSON.stringify(pending.type), "unexpected transaction type: " + JSON.stringify(pending.type),
); );
assert( assert(
@@ -2020,7 +2035,10 @@ test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (en
"the ERC-20 fee block does not quote the reserve: " + "the ERC-20 fee block does not quote the reserve: " +
JSON.stringify(st.reserve), JSON.stringify(st.reserve),
); );
assert(!st.sendDisabled, "Send is disabled for a funded ERC-20 transfer"); assert(
!st.sendDisabled,
"Send is disabled for a funded ERC-20 transfer",
);
assert( assert(
st.height === pending.height, st.height === pending.height,
"the ERC-20 view changed height when the estimate landed: " + "the ERC-20 view changed height when the estimate landed: " +
@@ -2029,6 +2047,9 @@ test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (en
st.height + st.height +
"px", "px",
); );
} finally {
env.routeOpts.holdGasEstimate = false;
}
}); });
test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", async (env) => { test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", async (env) => {
@@ -2130,6 +2151,7 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
/gas estimation failed/, /gas estimation failed/,
); );
env.routeOpts.failGasEstimate = true; env.routeOpts.failGasEstimate = true;
try {
await goToConfirm(env.page, { await goToConfirm(env.page, {
token: STUB_TOKEN.address, token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol, balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
@@ -2138,7 +2160,6 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
await waitForEstimate(env.page); await waitForEstimate(env.page);
const st = await confirmState(env.page); const st = await confirmState(env.page);
env.routeOpts.failGasEstimate = false;
assert( assert(
st.fee === "Unable to estimate", st.fee === "Unable to estimate",
"the ERC-20 fee line does not report the failure: " + "the ERC-20 fee line does not report the failure: " +
@@ -2164,6 +2185,9 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble
st.height + st.height +
"px", "px",
); );
} finally {
env.routeOpts.failGasEstimate = false;
}
}); });
// ------------------------- the popup's own send, end to end (#305) // ------------------------- the popup's own send, end to end (#305)
@@ -2220,6 +2244,34 @@ async function fillPasswordAndSend(page) {
await page.click("#btn-confirm-send"); await page.click("#btn-confirm-send");
} }
// Back to the address screen from wherever a send stopped, which is where a
// passing send test leaves the popup for the next one. The success and failure
// screens are left by their Done button. The wait for a receipt has no button
// and asks only every ten seconds; a reopened popup resumes it and asks at
// once, and seedReceipt has the stub node confirm the transaction.
async function backToAddressAfterSend(env) {
if (await env.page.isVisible("#view-wait-tx")) {
env.routeOpts.seedReceipt = true;
await waitForPersisted(
env.page,
"currentView",
"wait-tx",
"before closing the popup",
);
await env.page.close();
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-success-tx");
env.routeOpts.seedReceipt = false;
}
for (const done of ["#btn-success-tx-done", "#btn-error-tx-done"]) {
if (await env.page.isVisible(done)) {
await env.page.click(done);
await visible(env.page, "#view-address");
}
}
await backToAddress(env.page);
}
async function goToTokenConfirm(env) { async function goToTokenConfirm(env) {
await goToConfirm(env.page, { await goToConfirm(env.page, {
token: STUB_TOKEN.address, token: STUB_TOKEN.address,
@@ -2240,13 +2292,14 @@ test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", as
// The previous test left the ETH balance at the fee-only fixture, which // The previous test left the ETH balance at the fee-only fixture, which
// blocks sending outright; this one has to be able to press Send. // blocks sending outright; this one has to be able to press Send.
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI); env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
// Confirm the transaction once it is broadcast, so the wait screen
// resolves to the success view instead of polling for the rest of the run.
env.routeOpts.seedReceipt = true;
try {
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true }); await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
const shown = await goToTokenConfirm(env); const shown = await goToTokenConfirm(env);
const before = env.routeOpts.broadcastTransactions.length; const before = env.routeOpts.broadcastTransactions.length;
// Confirm the transaction once it is broadcast, so the wait screen
// resolves to the success view instead of polling for the rest of the run.
env.routeOpts.seedReceipt = true;
await fillPasswordAndSend(env.page); await fillPasswordAndSend(env.page);
await visible(env.page, "#view-wait-tx", 60000); await visible(env.page, "#view-wait-tx", 60000);
@@ -2260,9 +2313,9 @@ test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", as
broadcast[broadcast.length - 1], broadcast[broadcast.length - 1],
); );
// The measurement, printed on every run: the amount the user read, and // The measurement, printed on every run: the amount the user read,
// what the signed bytes mean at each of the two candidate scales. Under // and what the signed bytes mean at each of the two candidate scales.
// the defect these three lines disagree. // Under the defect these three lines disagree.
console.log( console.log(
"# erc-20 send artifact: displayed=" + "# erc-20 send artifact: displayed=" +
JSON.stringify(shown) + JSON.stringify(shown) +
@@ -2318,29 +2371,38 @@ test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", as
); );
await visible(env.page, "#view-success-tx", 60000); await visible(env.page, "#view-success-tx", 60000);
await env.page.click("#btn-success-tx-done"); } finally {
await visible(env.page, "#view-address");
env.routeOpts.seedReceipt = false; env.routeOpts.seedReceipt = false;
await backToAddressAfterSend(env);
}
}); });
test("a token that lies about decimals() at signing time broadcasts nothing (#305)", async (env) => { test("a token that lies about decimals() at signing time broadcasts nothing (#305)", async (env) => {
try {
const shown = await goToTokenConfirm(env); const shown = await goToTokenConfirm(env);
// Only now, with the screen already built and its estimate already taken // Only now, with the screen already built and its estimate already
// at the explorer's scale, does the contract start answering differently. // taken at the explorer's scale, does the contract start answering
// This is the whole shape of the defect: a value read at signing time that // differently. This is the whole shape of the defect: a value read at
// nothing on screen was ever derived from. // signing time that nothing on screen was ever derived from.
env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS; env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS;
const before = env.routeOpts.broadcastTransactions.length; const before = env.routeOpts.broadcastTransactions.length;
await fillPasswordAndSend(env.page); await fillPasswordAndSend(env.page);
await visible(env.page, "#view-error-tx", 60000);
env.routeOpts.tokenDecimalsOverride = null;
// The count is asserted as soon as the send has ended either way, and
// the screen only after it: a transfer that got through shows as the
// wait for its receipt, never as the failure screen expected here.
await visible(
env.page,
"#view-wait-tx:not(.hidden), #view-error-tx:not(.hidden)",
60000,
);
assert( assert(
env.routeOpts.broadcastTransactions.length === before, env.routeOpts.broadcastTransactions.length === before,
"a transfer encoded against a contract that contradicts the " + "a transfer encoded against a contract that contradicts the " +
"confirmation screen still reached the RPC (#305)", "confirmation screen still reached the RPC (#305)",
); );
await visible(env.page, "#view-error-tx");
const message = ( const message = (
await env.page.locator("#error-tx-message").innerText() await env.page.locator("#error-tx-message").innerText()
@@ -2363,9 +2425,10 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
/^[A-Z].*\.$/s.test(message), /^[A-Z].*\.$/s.test(message),
"the refusal is not a full sentence: " + JSON.stringify(message), "the refusal is not a full sentence: " + JSON.stringify(message),
); );
} finally {
await env.page.click("#btn-error-tx-done"); env.routeOpts.tokenDecimalsOverride = null;
await visible(env.page, "#view-address"); await backToAddressAfterSend(env);
}
}); });
// ------------------------------------------- hostile token symbol (#307) // ------------------------------------------- hostile token symbol (#307)
@@ -2437,6 +2500,7 @@ test("a token whose symbol() returns markup renders as text (#307)", async (env)
"# stub token symbol() now returns: " + JSON.stringify(HOSTILE_SYMBOL), "# stub token symbol() now returns: " + JSON.stringify(HOSTILE_SYMBOL),
); );
try {
// Close and reopen so the refresh that runs on open fetches balances // Close and reopen so the refresh that runs on open fetches balances
// with the hostile symbol in them. // with the hostile symbol in them.
await reopenPopup(env, "address"); await reopenPopup(env, "address");
@@ -2499,12 +2563,15 @@ test("a token whose symbol() returns markup renders as text (#307)", async (env)
); );
assert( assert(
!st.rowText.includes("z-index"), !st.rowText.includes("z-index"),
"the uncapped symbol reached the screen: " + JSON.stringify(st.rowText), "the uncapped symbol reached the screen: " +
JSON.stringify(st.rowText),
); );
} finally {
// Put the fixture back before the next test reads it, and let the // Put the fixture back before the next test reads it.
// stored balances be rewritten with the honest symbol.
env.routeOpts.tokenSymbolOverride = null; env.routeOpts.tokenSymbolOverride = null;
}
// Let the stored balances be rewritten with the honest symbol.
await reopenPopup(env, "main"); await reopenPopup(env, "main");
await env.page.waitForFunction( await env.page.waitForFunction(
(addr) => { (addr) => {
-2
View File
@@ -253,8 +253,6 @@ async function bootPopup(stored, options) {
formatUsd: () => "", formatUsd: () => "",
formatAddressTotal: () => "", formatAddressTotal: () => "",
getAddressValue: () => ({ usd: null, partial: false }), getAddressValue: () => ({ usd: null, partial: false }),
getWalletValue: () => ({ usd: null, partial: false }),
getTotalValue: () => ({ usd: null, partial: false }),
})); }));
jest.doMock("../../src/shared/balances", () => ({ jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []), fetchTokenBalances: jest.fn(async () => []),
+159
View File
@@ -0,0 +1,159 @@
// A transaction's time is written by isoDate() and timeAgo() in
// src/popup/views/helpers.js on every screen that shows one (README, Display
// Consistency; https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail
// and AddressToken used to define their own copies, so a fix to the shared pair
// would not have reached them.
//
// The pair is replaced before the views are loaded, because a view takes it
// when it loads. A view that writes the time with a copy of its own shows the
// real time instead of the replacement.
//
// Driven against a minimal DOM stub in the shape
// tests/contractCreation.test.js uses.
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The transaction detail view fetches on-chain details after drawing; an
// answer that is not ok leaves the drawn lines as they are.
debugFetch: async () => ({ ok: false }),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The history lists ask the explorer for their transactions and resolve ENS
// names for them; here the explorer answers with mockHistory and no name
// resolves.
let mockHistory = [];
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: async () => mockHistory,
}));
jest.mock("../src/shared/ens", () => ({
...jest.requireActual("../src/shared/ens"),
resolveEnsNames: async () => new Map(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const helpers = require("../src/popup/views/helpers");
jest.spyOn(helpers, "isoDate").mockReturnValue("SHARED-ISO-DATE");
jest.spyOn(helpers, "timeAgo").mockReturnValue("SHARED-TIME-AGO");
const { state } = require("../src/shared/state");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const transactionDetail = require("../src/popup/views/transactionDetail");
const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// A transaction FROM sent, as the history lists hold it.
function historyTx() {
return {
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
from: FROM,
to: RECIPIENT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
};
}
beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.wallets = [
{
name: "Main",
type: "key",
addresses: [{ address: FROM, balance: "0.0000" }],
},
];
state.trackedTokens = [];
state.viewData = {};
state.viewStack = [];
state.currentView = null;
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
});
describe.each([
["AddressDetail", "tx-list", () => addressDetail.show()],
["AddressToken", "address-token-tx-list", () => addressToken.show()],
])("a transaction history row on %s", (_name, listId, open) => {
test("shows the time written by the shared isoDate() and timeAgo()", async () => {
mockHistory = [historyTx()];
open();
// The list is drawn once the history has been fetched.
await new Promise((resolve) => setTimeout(resolve, 0));
const html = node(listId).innerHTML;
expect(html).toContain('title="SHARED-ISO-DATE"');
expect(html).toContain(">SHARED-TIME-AGO<");
});
});
test("the transaction detail view shows the time written by the shared isoDate() and timeAgo()", () => {
transactionDetail.show(historyTx());
const html = node("tx-detail-time").innerHTML;
expect(html).toContain("SHARED-ISO-DATE");
expect(html).toContain("(SHARED-TIME-AGO)");
});