Author SHA1 Message Date
sneak 4cb61d1a10 test: run jest in one process, fail on an open handle (closes #766)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Jest force-stopped worker processes that could not exit within 500 ms on
a starved CPU, failing runs where every test passed. The test scripts now
pass --runInBand. script/lib/jest.js fails the run at once when jest
warns that it did not exit one second after the run, instead of
hanging. The Dockerfile test stage drops its 90-second timeout.

tests/jestRunner.test.js runs the runner on a fixture that leaves an
interval running and expects exit status 1.

Model: opus-5-5
2026-11-11 05:21:01 +00:00
14 changed files with 162 additions and 238 deletions

No files matched your search

+4 -5
View File
@@ -18,9 +18,8 @@ COPY . .
RUN yarn run lint RUN yarn run lint
RUN script/check-censored RUN script/check-censored
# Test phase, same shape and for the same reason: the jest suite (its worker # Test phase, same shape and for the same reason: the jest suite, rerun
# cap is in package.json), rerun verbose on failure, then # verbose on failure, then script/test-verify-build.
# script/test-verify-build.
# #
# node:22-slim (22.x LTS), 2026-02-24 # node:22-slim (22.x LTS), 2026-02-24
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS test FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS test
@@ -33,9 +32,9 @@ RUN script/bootstrap
COPY . . COPY . .
RUN timeout 90 yarn run test || \ RUN yarn run test || \
{ echo "--- Rerunning with --verbose for details ---"; \ { echo "--- Rerunning with --verbose for details ---"; \
timeout 90 yarn run test:verbose; exit 1; } yarn run test:verbose; exit 1; }
RUN script/test-verify-build RUN script/test-verify-build
# Development environment with the extension built, and the last stage: a # Development environment with the extension built, and the last stage: a
+9 -7
View File
@@ -1274,8 +1274,9 @@ A native amount is labelled with the native currency of the transaction's chain:
`src/shared/networks.js`). The balance lists and Send use the active network's. `src/shared/networks.js`). The balance lists and Send use the active network's.
The decoded swap lines call the native currency `ETH` on both networks. The decoded swap lines call the native currency `ETH` on both networks.
TransactionDetail and every history row show the time as an ISO datetime and TransactionDetail shows the time as an ISO datetime and a relative age. A
then a relative age. With "UTC Timestamps" on, the datetime is in UTC. history row shows the age, with the datetime as its tooltip. With "UTC
Timestamps" on, the datetime is in UTC.
Every nonzero holding is stored exactly. A token holding below 0.000001 is left Every nonzero holding is stored exactly. A token holding below 0.000001 is left
out of: out of:
@@ -1760,8 +1761,8 @@ The `Makefile` targets are thin shims over `script/`:
`package.json`. `package.json`.
- `script/setup`: `script/bootstrap`, then the git pre-commit hook. - `script/setup`: `script/bootstrap`, then the git pre-commit hook.
- `script/test`: build the `test` stage of the `Dockerfile` with `--no-cache`. - `script/test`: build the `test` stage of the `Dockerfile` with `--no-cache`.
The stage runs the jest suite, stopped at 90 seconds and rerun verbose on The stage runs the jest suite, rerun verbose on failure, then
failure, then `script/test-verify-build`. `script/test-verify-build`.
- `script/lint`: build the `lint` stage with `--no-cache`. The stage runs - `script/lint`: build the `lint` stage with `--no-cache`. The stage runs
ESLint, the prettier check and `script/check-censored`. Linting runs only in ESLint, the prettier check and `script/check-censored`. Linting runs only in
Docker. Docker.
@@ -1860,9 +1861,10 @@ have left a complete, loadable debug bundle behind. `make build-debug` keeps its
### Tests ### Tests
`make test` runs the jest suite in Docker. The suite must finish within 60 `make test` runs the jest suite in Docker, in one process. The suite must finish
seconds; the test stage stops it at 90. `make check` runs the tests, the lint within 60 seconds. A test that leaves a timer or another open handle running
stage and the formatting check. fails the run. `make check` runs the tests, the lint stage and the formatting
check.
Some unit tests pin facts stated elsewhere in this file: Some unit tests pin facts stated elsewhere in this file:
+7 -2
View File
@@ -135,9 +135,14 @@ module.exports = [
}, },
}, },
// Unit tests, and the helpers they require: jest on node. // Unit tests, the helpers they require, and the test files they hand to
// jest: jest on node.
{ {
files: ["tests/**/*.test.js", "tests/support/**/*.js"], files: [
"tests/**/*.test.js",
"tests/support/**/*.js",
"tests/fixtures/**/*.js",
],
languageOptions: { languageOptions: {
...commonjs, ...commonjs,
globals: { ...globals.node, ...globals.jest }, globals: { ...globals.node, ...globals.jest },
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "node script/lib/jest.js --maxWorkers=3", "test": "node script/lib/jest.js --runInBand",
"test:verbose": "node script/lib/jest.js --maxWorkers=3 --verbose", "test:verbose": "node script/lib/jest.js --runInBand --verbose",
"build": "node build.js", "build": "node build.js",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fmt": "prettier --write .", "fmt": "prettier --write .",
+17 -25
View File
@@ -1,8 +1,8 @@
// Runs jest with the arguments it is given, and fails the run when jest had to // Runs jest with the arguments it is given, and fails the run at once when a
// force-stop the worker process that ran a test file. Jest itself only prints a // test left a timer or another open handle running. Such a handle keeps this
// warning when that happens, and its run still passes. The warning means a test // process alive after the last test, so the run would hang. Jest itself only
// left a timer or another open handle running. The test scripts in package.json // prints a warning one second after the run, and then keeps waiting. The test
// run jest through this file. // scripts in package.json run jest through this file, in this one process.
// //
// Jest runs inside this process and writes to the terminal directly, so its // Jest runs inside this process and writes to the terminal directly, so its
// output looks the same as a plain jest run. This file only checks each write // output looks the same as a plain jest run. This file only checks each write
@@ -10,31 +10,23 @@
"use strict"; "use strict";
// The start of the warning that jest prints on stderr after it force-stops a // The start of the warning that jest prints on stderr when the process has not
// worker process. // exited one second after the test run.
const WORKER_WARNING = "A worker process has failed to exit gracefully"; const OPEN_HANDLE_WARNING =
"Jest did not exit one second after the test run has completed.";
let forceStopped = false;
const writeStderr = process.stderr.write.bind(process.stderr); const writeStderr = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...rest) => { process.stderr.write = (chunk, ...rest) => {
if (String(chunk).includes(WORKER_WARNING)) { const written = writeStderr(chunk, ...rest);
forceStopped = true; if (String(chunk).includes(OPEN_HANDLE_WARNING)) {
} writeStderr(
return writeStderr(chunk, ...rest); "script/lib/jest.js: failing the run, because a test left a " +
}; "timer or another open handle running.\n",
// Runs after jest has finished, as the process exits.
process.on("exit", () => {
if (forceStopped) {
console.error(
"script/lib/jest.js: failing the run, because jest force-stopped " +
"a worker process. A test left a timer or another open " +
"handle running.",
); );
process.exitCode = 1; process.exit(1);
} }
}); return written;
};
// Jest's own command-line program. It reads its arguments from process.argv. // Jest's own command-line program. It reads its arguments from process.argv.
require("jest/bin/jest"); require("jest/bin/jest");
+5 -5
View File
@@ -12,7 +12,8 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
transactionTimeHtml, 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");
@@ -235,11 +236,10 @@ function renderTransactions(txs) {
const nameStr = escapeHtml(title || ensName || ""); const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
// The time has a line of its own, so a long time or direction label html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`; html += `</div>`;
i++; i++;
+5 -5
View File
@@ -18,7 +18,8 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
transactionTimeHtml, isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { bundledToken, resolveSymbol } = require("../../shared/tokenList"); const { bundledToken, resolveSymbol } = require("../../shared/tokenList");
@@ -328,11 +329,10 @@ function renderTransactions(txs) {
const nameStr = escapeHtml(title || ensName || ""); const nameStr = escapeHtml(title || ensName || "");
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
// The time has a line of its own, so a long time or direction label html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr); html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`; html += `</div>`;
i++; i++;
+5 -12
View File
@@ -615,8 +615,9 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen }); return renderAddressHtml(address, { title, ensName, maxLen });
} }
// The ISO datetime of a transaction's time, in UTC when the UTC Timestamps // A transaction's time as every screen shows it (README, Amounts): the ISO
// setting is on. Views show it through transactionTimeHtml(). // 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");
@@ -673,15 +674,6 @@ function timeAgo(timestamp) {
return years + " year" + (years !== 1 ? "s" : "") + " ago"; return years + " year" + (years !== 1 ? "s" : "") + " ago";
} }
// A transaction's time as every screen shows it (README, Amounts): the ISO
// datetime, then the relative age in parentheses. With copyable set, the
// datetime is click-to-copy, and the caller attaches the copy handlers.
function transactionTimeHtml(timestamp, { copyable = false } = {}) {
const iso = isoDate(timestamp);
const isoHtml = copyable ? copyableHtml(iso) : escapeHtml(iso);
return isoHtml + " (" + escapeHtml(timeAgo(timestamp)) + ")";
}
// Shared external-link icon SVG used across all views. // Shared external-link icon SVG used across all views.
const EXT_ICON = const EXT_ICON =
`<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` + `<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` +
@@ -877,5 +869,6 @@ module.exports = {
reviewedNetworkProvider, reviewedNetworkProvider,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
transactionTimeHtml, isoDate,
timeAgo,
}; };
+5 -5
View File
@@ -5,7 +5,8 @@ const {
balanceLinesForAddress, balanceLinesForAddress,
unknownableAmount, unknownableAmount,
markTrackedTokensNotFetched, markTrackedTokensNotFetched,
transactionTimeHtml, isoDate,
timeAgo,
addressDotHtml, addressDotHtml,
txCounterpartyHtml, txCounterpartyHtml,
HISTORY_OFF_TEXT, HISTORY_OFF_TEXT,
@@ -149,11 +150,10 @@ function renderHomeTxList(ctx) {
const titleStr = title ? escapeHtml(title) : ""; const titleStr = title ? escapeHtml(title) : "";
const err = tx.isError ? " (failed)" : ""; const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity-50" : ""; const opacity = tx.isError ? " opacity-50" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`; html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
// The time has a line of its own, so a long time or direction label html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
// never pushes the other onto a second line.
html += `<div class="text-muted">${transactionTimeHtml(tx.timestamp)}</div>`;
html += `<div class="text-right">${dirLabel}${err}</div>`;
html += txCounterpartyHtml(counterparty, titleStr, amountStr); html += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `</div>`; html += `</div>`;
i++; i++;
+5 -4
View File
@@ -10,7 +10,8 @@ const {
CONTRACT_CREATION_TEXT, CONTRACT_CREATION_TEXT,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
transactionTimeHtml, isoDate,
timeAgo,
renderAddressHtml, renderAddressHtml,
blockieHtml, blockieHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -203,9 +204,9 @@ function render() {
); );
loadFullTxDetails(tx.hash, tx.chainId); loadFullTxDetails(tx.hash, tx.chainId);
$("tx-detail-time").innerHTML = transactionTimeHtml(tx.timestamp, { const isoStr = isoDate(tx.timestamp);
copyable: true, $("tx-detail-time").innerHTML =
}); copyableHtml(isoStr) + " (" + escapeHtml(timeAgo(tx.timestamp)) + ")";
$("tx-detail-status").textContent = tx.isError ? "Failed" : "Success"; $("tx-detail-status").textContent = tx.isError ? "Failed" : "Success";
showView("transaction"); showView("transaction");
attachCopyHandlers("view-transaction"); attachCopyHandlers("view-transaction");
-65
View File
@@ -951,71 +951,6 @@ test("tap-to-copy on the transaction detail screen copies the address (#151)", a
); );
}); });
// ------------------------------------------ history row time (#760)
// A Home history row shows the ISO datetime and then the relative age, on a
// line of its own. Measured at the popup's size: the time line is one line
// high, and no line of a row is wider than the row or the popup.
test("a Home history row shows the datetime and the age, one line high, without overflow (#760)", async (env) => {
await goHome(env.page);
env.routeOpts.seedNativeTransfer = true;
const page = await openPopup(env.ctx, env.popupUrl);
try {
await page.setViewportSize(POPUP_VIEWPORT);
await visible(page, "#view-main");
await page.waitForFunction(
() =>
document.querySelectorAll("#home-tx-list .home-tx-row")
.length === 2,
null,
{ timeout: 30000 },
);
const rows = await page.evaluate(() => {
const popupRight = document.body.getBoundingClientRect().right;
return [
...document.querySelectorAll("#home-tx-list .home-tx-row"),
].map((row) => {
const time = row.firstElementChild;
return {
time: time.textContent,
timeHeight: time.getBoundingClientRect().height,
lineHeight: parseFloat(getComputedStyle(time).lineHeight),
overflowingLines: [...row.children].filter(
(line) =>
line.scrollWidth > line.clientWidth ||
line.getBoundingClientRect().right > popupRight,
).length,
};
});
});
console.log("# Home history rows: " + JSON.stringify(rows));
for (const row of rows) {
assert(
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(Z|[+-]\d\d:\d\d) \(\d+ \w+ ago\)$/.test(
row.time,
),
"the time line is not the ISO datetime and then the age: " +
JSON.stringify(row.time),
);
assert(
row.timeHeight === row.lineHeight,
"the time line is " +
row.timeHeight +
"px high, not one line of " +
row.lineHeight +
"px",
);
assert(
row.overflowingLines === 0,
row.overflowingLines + " line(s) of a row overflow it",
);
}
} finally {
env.routeOpts.seedNativeTransfer = false;
await page.close();
}
});
// ------------------------- the last of the #150 and #151 items (#295) // ------------------------- the last of the #150 and #151 items (#295)
// //
// Add Token's confirm button, TransactionDetail opened from the token screen // Add Token's confirm button, TransactionDetail opened from the token screen
+7
View File
@@ -0,0 +1,7 @@
// A test file that starts an interval and never clears it.
// tests/jestRunner.test.js runs script/lib/jest.js on this file alone. The
// name does not end in .test.js, so the normal run never picks it up.
test("starts an interval and never clears it", () => {
setInterval(() => {}, 1000);
});
+29
View File
@@ -0,0 +1,29 @@
// script/lib/jest.js, which package.json's test scripts run, fails a run in
// which a test left an open handle, instead of hanging on it.
const { spawnSync } = require("child_process");
const path = require("path");
const root = path.join(__dirname, "..");
test("a test that leaves an interval running fails the run", () => {
// The run ends about one second after its last test. Without the check in
// script/lib/jest.js it would never end, and the timeout stops it.
const result = spawnSync(
process.execPath,
[
"script/lib/jest.js",
"--runInBand",
"--testMatch",
"<rootDir>/tests/fixtures/leavesIntervalRunning.js",
],
{ cwd: root, encoding: "utf8", timeout: 20000 },
);
expect(result.signal).toBeNull();
expect(result.status).toBe(1);
expect(result.stderr).toContain("1 passed");
expect(result.stderr).toContain(
"a test left a timer or another open handle running",
);
});
+62 -101
View File
@@ -1,14 +1,15 @@
// A transaction's time shows the ISO datetime and the relative age on every // A transaction's time is written by isoDate() and timeAgo() in
// screen that shows one (README, Amounts). Home, AddressDetail and AddressToken // src/popup/views/helpers.js on every screen that shows one (README, Amounts;
// used to show only the age, with the datetime as a tooltip // https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail and
// (https://git.eeqj.de/sneak/AutistMask/issues/760). // AddressToken used to define their own copies, so a fix to the shared pair
// would not have reached them.
// //
// The views run against a minimal DOM stub, as in // The pair is replaced before the views are loaded, because a view takes it
// tests/historyAfterNetworkSwitch.test.js, and over // when it loads. A view that writes the time with a copy of its own shows the
// tests/support/storageStub.js. // real time instead of the replacement.
//
const { makeStorageStub } = require("./support/storageStub"); // Driven against a minimal DOM stub in the shape
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema"); // tests/contractCreation.test.js uses.
jest.mock("../src/shared/log", () => ({ jest.mock("../src/shared/log", () => ({
log: { log: {
@@ -25,78 +26,33 @@ jest.mock("../src/shared/log", () => ({
})); }));
// The history lists ask the explorer for their transactions and resolve ENS // The history lists ask the explorer for their transactions and resolve ENS
// names for them; here the explorer answers with mockHistory() and no name // names for them; here the explorer answers with mockHistory and no name
// resolves. // resolves.
let mockHistory = [];
jest.mock("../src/shared/transactions", () => ({ jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"), ...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: async () => mockHistory(), fetchRecentTransactions: async () => mockHistory,
})); }));
jest.mock("../src/shared/ens", () => ({ jest.mock("../src/shared/ens", () => ({
...jest.requireActual("../src/shared/ens"), ...jest.requireActual("../src/shared/ens"),
resolveEnsNames: async () => new Map(), resolveEnsNames: async () => new Map(),
})); }));
const { state, loadState } = require("../src/shared/state"); globalThis.chrome = {
const home = require("../src/popup/views/home"); 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 addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken"); const addressToken = require("../src/popup/views/addressToken");
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
const FROM = "0x0000000000000000000000000000000000000a11"; const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TIMESTAMP = 1790000000;
// TIMESTAMP as shown with "UTC Timestamps" on, three hours after it.
const SHOWN_TIME = "2026-09-21T14:13:20Z (3 hours ago)";
// A profile on Ethereum Mainnet with one address, "Use Blockscout" and "UTC
// Timestamps" on.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
hasWallet: true,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-main",
encryptedSecret: "encrypted-secret",
nextIndex: 1,
addresses: [
{ address: FROM, balance: "1.0", tokenBalances: [] },
],
},
],
activeAddress: FROM,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
utcTimestamps: true,
};
}
// A transfer of ETH from FROM, as Blockscout answers it.
function mockHistory() {
return [
{
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
blockNumber: 1,
from: FROM,
to: RECIPIENT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: TIMESTAMP,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
chainId: "0x1",
},
];
}
function makeElement(id) { function makeElement(id) {
const el = { const el = {
@@ -134,7 +90,6 @@ function makeDocument() {
return els.get(id); return els.get(id);
}, },
createElement: () => makeElement("created"), createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} }, body: { prepend: () => {} },
}; };
} }
@@ -143,58 +98,64 @@ function node(id) {
return globalThis.document.getElementById(id); return globalThis.document.getElementById(id);
} }
// Lets every promise that is not held open settle. // A transaction FROM sent, as the history lists hold it.
function settle() { function historyTx() {
return new Promise((resolve) => setTimeout(resolve, 0)); 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(async () => { beforeEach(() => {
const storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
jest.spyOn(Date, "now").mockReturnValue((TIMESTAMP + 3 * 3600) * 1000); state.wallets = [
await loadState(); {
name: "Main",
type: "key",
addresses: [{ address: FROM, balance: "0.0000" }],
},
];
state.trackedTokens = [];
state.viewData = {};
state.viewStack = []; state.viewStack = [];
state.currentView = null;
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
state.selectedToken = "ETH"; state.selectedToken = "ETH";
const ctx = { pageClosed: new AbortController().signal }; // The history is Blockscout's.
addressDetail.init(ctx); state.useBlockscout = true;
addressToken.init(ctx);
});
afterEach(() => {
jest.restoreAllMocks();
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
}); });
describe.each([ describe.each([
[
"Home",
"home-tx-list",
() => home.render({ pageClosed: new AbortController().signal }),
],
["AddressDetail", "tx-list", () => addressDetail.show()], ["AddressDetail", "tx-list", () => addressDetail.show()],
["AddressToken", "address-token-tx-list", () => addressToken.show()], ["AddressToken", "address-token-tx-list", () => addressToken.show()],
])("a transaction history row on %s", (_name, listId, open) => { ])("a transaction history row on %s", (_name, listId, open) => {
test("shows the ISO datetime and then the relative age", async () => { test("shows the time written by the shared isoDate() and timeAgo()", async () => {
mockHistory = [historyTx()];
open(); open();
// The list is drawn once the history has been fetched. // The list is drawn once the history has been fetched.
for (let i = 0; i < 10; i++) await settle(); await new Promise((resolve) => setTimeout(resolve, 0));
const html = node(listId).innerHTML; const html = node(listId).innerHTML;
expect(html).toContain(">" + SHOWN_TIME + "<"); expect(html).toContain('title="SHARED-ISO-DATE"');
expect(html).not.toContain("title="); expect(html).toContain(">SHARED-TIME-AGO<");
}); });
}); });
test("the transaction detail view shows the ISO datetime, copyable, and then the relative age", () => { test("the transaction detail view shows the time written by the shared isoDate() and timeAgo()", () => {
transactionDetail.show(mockHistory()[0]); transactionDetail.show(historyTx());
const html = node("tx-detail-time").innerHTML; const html = node("tx-detail-time").innerHTML;
expect(html).toContain('data-copy="2026-09-21T14:13:20Z"'); expect(html).toContain("SHARED-ISO-DATE");
expect(html).toContain(">2026-09-21T14:13:20Z</span> (3 hours ago)"); expect(html).toContain("(SHARED-TIME-AGO)");
}); });