2 Commits
Author SHA1 Message Date
clawbot 3a8b309d7e test: replace jest workers past 200 MB so they exit in time (closes #766)
check / check (push) Successful in 6m7s
e2e / e2e-chrome (push) Successful in 14m38s
e2e / e2e-firefox (push) Failing after 14m19s
On a starved CPU, jest force-stopped worker processes that could not exit
within the 500 ms it allows, and script/lib/jest.js failed the run although
every test passed. No test leaks a handle; exit time grows with what a
worker holds, 380 to 970 MB at the end of the run.

workerIdleMemoryLimit in package.json replaces a worker whose heap is over
200 MB after a test file with a fresh one.

tests/jestRunner.test.js runs script/lib/jest.js on a fixture that leaves
an interval running and expects the force-stop to fail the run.

Model: opus-5-5
2026-11-11 09:37:12 +01:00
clawbot b77b04b714 fix: history rows show the ISO datetime and the age (closes #760)
check / check (push) Successful in 6m53s
e2e / e2e-chrome (push) Successful in 15m18s
e2e / e2e-firefox (push) Successful in 2m8s
Home, AddressDetail and AddressToken showed only the relative age, with
the datetime as a tooltip. Each row now shows the datetime and then the
age in parentheses, on a line of its own, through transactionTimeHtml()
in helpers.js, which TransactionDetail also uses. The direction label
moves to its own right-aligned line below. README.md and docs/README.md
describe the new row.

Model: opus-5-5
2026-11-11 07:53:41 +01:00
13 changed files with 253 additions and 134 deletions

No files matched your search

+5 -4
View File
@@ -18,8 +18,9 @@ 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, rerun # Test phase, same shape and for the same reason: the jest suite (its worker
# verbose on failure, then script/test-verify-build. # cap is in package.json), rerun verbose on failure, then
# 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
@@ -32,9 +33,9 @@ RUN script/bootstrap
COPY . . COPY . .
RUN yarn run test || \ RUN timeout 90 yarn run test || \
{ echo "--- Rerunning with --verbose for details ---"; \ { echo "--- Rerunning with --verbose for details ---"; \
yarn run test:verbose; exit 1; } timeout 90 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
+11 -13
View File
@@ -563,9 +563,10 @@ of a wallet is a block:
"Recent Transactions" lists up to 25 transactions across every address of every "Recent Transactions" lists up to 25 transactions across every address of every
wallet, deduplicated by hash and filtered (see Scam protection). Each row has wallet, deduplicated by hash and filtered (see Scam protection). Each row has
three lines: four lines:
- the age and the direction; - the ISO datetime and the relative age;
- the direction, on the right;
- the counterparty's colour dot and label, with the amount on the right; - the counterparty's colour dot and label, with the amount on the right;
- the counterparty's full address, as plain text. - the counterparty's full address, as plain text.
@@ -1185,8 +1186,7 @@ popup closes.
These helpers write one kind of value on every screen that shows it: These helpers write one kind of value on every screen that shows it:
- `formatUsd()` in `src/shared/prices.js`: USD values; - `formatUsd()` in `src/shared/prices.js`: USD values;
- `isoDate()` and `timeAgo()` in `src/popup/views/helpers.js`: transaction - `transactionTimeHtml()` in `src/popup/views/helpers.js`: transaction times;
times;
- `renderAddressHtml()` in the same file: most addresses. - `renderAddressHtml()` in the same file: most addresses.
Most screens show token and ETH amounts with four decimal places. History rows Most screens show token and ETH amounts with four decimal places. History rows
@@ -1274,9 +1274,8 @@ 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 shows the time as an ISO datetime and a relative age. A TransactionDetail and every history row show the time as an ISO datetime and
history row shows the age, with the datetime as its tooltip. With "UTC then a relative age. With "UTC Timestamps" on, the datetime is in 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:
@@ -1761,8 +1760,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, rerun verbose on failure, then The stage runs the jest suite, stopped at 90 seconds and rerun verbose on
`script/test-verify-build`. failure, then `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.
@@ -1861,10 +1860,9 @@ have left a complete, loadable debug bundle behind. `make build-debug` keeps its
### Tests ### Tests
`make test` runs the jest suite in Docker, in one process. The suite must finish `make test` runs the jest suite in Docker. The suite must finish within 60
within 60 seconds. A test that leaves a timer or another open handle running seconds; the test stage stops it at 90. `make check` runs the tests, the lint
fails the run. `make check` runs the tests, the lint stage and the formatting stage and the formatting check.
check.
Some unit tests pin facts stated elsewhere in this file: Some unit tests pin facts stated elsewhere in this file:
+4 -3
View File
@@ -415,9 +415,10 @@ the setting is off, each history list shows one line saying so.
Each row shows: Each row shows:
- the age and the direction, "Sent" or "Received". A transaction can also ask a - the time, as a date and time and as an age;
contract to do something; that is a contract call. A row for a contract call - the direction, "Sent" or "Received", on the line below. A transaction can also
shows the call's name instead, such as "Approve" or "Swap"; ask a contract to do something; that is a contract call. A row for a contract
call shows the call's name instead, such as "Approve" or "Swap";
- the amount. A contract call row shows an amount only when the call moved a - the amount. A contract call row shows an amount only when the call moved a
token. A swap row shows the amount received, or the amount paid in when no token. A swap row shows the amount received, or the amount paid in when no
token came back. A row with no amount shows only a symbol, such as `USDC` or token came back. A row with no amount shows only a symbol, such as `USDC` or
+4 -3
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 --runInBand", "test": "node script/lib/jest.js --maxWorkers=3",
"test:verbose": "node script/lib/jest.js --runInBand --verbose", "test:verbose": "node script/lib/jest.js --maxWorkers=3 --verbose",
"build": "node build.js", "build": "node build.js",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fmt": "prettier --write .", "fmt": "prettier --write .",
@@ -16,7 +16,8 @@
"jest": { "jest": {
"setupFiles": [ "setupFiles": [
"<rootDir>/tests/support/navigatorLocks.js" "<rootDir>/tests/support/navigatorLocks.js"
] ],
"workerIdleMemoryLimit": "200MB"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
+25 -17
View File
@@ -1,8 +1,8 @@
// Runs jest with the arguments it is given, and fails the run at once when a // Runs jest with the arguments it is given, and fails the run when jest had to
// test left a timer or another open handle running. Such a handle keeps this // force-stop the worker process that ran a test file. Jest itself only prints a
// process alive after the last test, so the run would hang. Jest itself only // warning when that happens, and its run still passes. The warning means a test
// prints a warning one second after the run, and then keeps waiting. The test // left a timer or another open handle running. The test scripts in package.json
// scripts in package.json run jest through this file, in this one process. // run jest through this file.
// //
// 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,23 +10,31 @@
"use strict"; "use strict";
// The start of the warning that jest prints on stderr when the process has not // The start of the warning that jest prints on stderr after it force-stops a
// exited one second after the test run. // worker process.
const OPEN_HANDLE_WARNING = const WORKER_WARNING = "A worker process has failed to exit gracefully";
"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) => {
const written = writeStderr(chunk, ...rest); if (String(chunk).includes(WORKER_WARNING)) {
if (String(chunk).includes(OPEN_HANDLE_WARNING)) { forceStopped = true;
writeStderr(
"script/lib/jest.js: failing the run, because a test left a " +
"timer or another open handle running.\n",
);
process.exit(1);
} }
return written; return writeStderr(chunk, ...rest);
}; };
// 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;
}
});
// 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,8 +12,7 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate, transactionTimeHtml,
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");
@@ -236,10 +235,11 @@ 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}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; // The time has a line of its own, so a long time or direction label
// 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,8 +18,7 @@ const {
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
pushCurrentView, pushCurrentView,
isoDate, transactionTimeHtml,
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");
@@ -329,10 +328,11 @@ 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}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; // The time has a line of its own, so a long time or direction label
// 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++;
+12 -5
View File
@@ -615,9 +615,8 @@ 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, Amounts): the ISO // The ISO datetime of a transaction's time, in UTC when the UTC Timestamps
// datetime, in UTC when the UTC Timestamps setting is on, and the relative age. // setting is on. Views show it through transactionTimeHtml().
// 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");
@@ -674,6 +673,15 @@ 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">` +
@@ -869,6 +877,5 @@ module.exports = {
reviewedNetworkProvider, reviewedNetworkProvider,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
isoDate, transactionTimeHtml,
timeAgo,
}; };
+5 -5
View File
@@ -5,8 +5,7 @@ const {
balanceLinesForAddress, balanceLinesForAddress,
unknownableAmount, unknownableAmount,
markTrackedTokensNotFetched, markTrackedTokensNotFetched,
isoDate, transactionTimeHtml,
timeAgo,
addressDotHtml, addressDotHtml,
txCounterpartyHtml, txCounterpartyHtml,
HISTORY_OFF_TEXT, HISTORY_OFF_TEXT,
@@ -150,10 +149,11 @@ 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}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`; // The time has a line of its own, so a long time or direction label
// 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++;
+4 -5
View File
@@ -10,8 +10,7 @@ const {
CONTRACT_CREATION_TEXT, CONTRACT_CREATION_TEXT,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
isoDate, transactionTimeHtml,
timeAgo,
renderAddressHtml, renderAddressHtml,
blockieHtml, blockieHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -204,9 +203,9 @@ function render() {
); );
loadFullTxDetails(tx.hash, tx.chainId); loadFullTxDetails(tx.hash, tx.chainId);
const isoStr = isoDate(tx.timestamp); $("tx-detail-time").innerHTML = transactionTimeHtml(tx.timestamp, {
$("tx-detail-time").innerHTML = copyable: true,
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,6 +951,71 @@ 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 -7
View File
@@ -1,5 +1,6 @@
// script/lib/jest.js, which package.json's test scripts run, fails a run in // 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. // which jest force-stopped a worker process because a test left an open
// handle.
const { spawnSync } = require("child_process"); const { spawnSync } = require("child_process");
const path = require("path"); const path = require("path");
@@ -7,13 +8,14 @@ const path = require("path");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
test("a test that leaves an interval running fails the run", () => { 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 // The memory limit in package.json makes jest run even this one file in a
// script/lib/jest.js it would never end, and the timeout stops it. // worker process. The interval keeps that worker from exiting, so jest
// force-stops it after the run.
const result = spawnSync( const result = spawnSync(
process.execPath, process.execPath,
[ [
"script/lib/jest.js", "script/lib/jest.js",
"--runInBand", "--maxWorkers=1",
"--testMatch", "--testMatch",
"<rootDir>/tests/fixtures/leavesIntervalRunning.js", "<rootDir>/tests/fixtures/leavesIntervalRunning.js",
], ],
@@ -23,7 +25,5 @@ test("a test that leaves an interval running fails the run", () => {
expect(result.signal).toBeNull(); expect(result.signal).toBeNull();
expect(result.status).toBe(1); expect(result.status).toBe(1);
expect(result.stderr).toContain("1 passed"); expect(result.stderr).toContain("1 passed");
expect(result.stderr).toContain( expect(result.stderr).toContain("jest force-stopped a worker process");
"a test left a timer or another open handle running",
);
}); });
+101 -62
View File
@@ -1,15 +1,14 @@
// A transaction's time is written by isoDate() and timeAgo() in // A transaction's time shows the ISO datetime and the relative age on every
// src/popup/views/helpers.js on every screen that shows one (README, Amounts; // screen that shows one (README, Amounts). Home, AddressDetail and AddressToken
// https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail and // used to show only the age, with the datetime as a tooltip
// AddressToken used to define their own copies, so a fix to the shared pair // (https://git.eeqj.de/sneak/AutistMask/issues/760).
// would not have reached them.
// //
// The pair is replaced before the views are loaded, because a view takes it // The views run against a minimal DOM stub, as in
// when it loads. A view that writes the time with a copy of its own shows the // tests/historyAfterNetworkSwitch.test.js, and over
// real time instead of the replacement. // tests/support/storageStub.js.
//
// Driven against a minimal DOM stub in the shape const { makeStorageStub } = require("./support/storageStub");
// tests/contractCreation.test.js uses. const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/log", () => ({ jest.mock("../src/shared/log", () => ({
log: { log: {
@@ -26,33 +25,78 @@ 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(),
})); }));
globalThis.chrome = { const { state, loadState } = require("../src/shared/state");
storage: { local: { get: async () => ({}), set: async () => {} } }, const home = require("../src/popup/views/home");
};
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 = {
@@ -90,6 +134,7 @@ function makeDocument() {
return els.get(id); return els.get(id);
}, },
createElement: () => makeElement("created"), createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} }, body: { prepend: () => {} },
}; };
} }
@@ -98,64 +143,58 @@ function node(id) {
return globalThis.document.getElementById(id); return globalThis.document.getElementById(id);
} }
// A transaction FROM sent, as the history lists hold it. // Lets every promise that is not held open settle.
function historyTx() { function settle() {
return { return new Promise((resolve) => setTimeout(resolve, 0));
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(() => { beforeEach(async () => {
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: "" } };
state.wallets = [ jest.spyOn(Date, "now").mockReturnValue((TIMESTAMP + 3 * 3600) * 1000);
{ 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";
// The history is Blockscout's. const ctx = { pageClosed: new AbortController().signal };
state.useBlockscout = true; addressDetail.init(ctx);
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 time written by the shared isoDate() and timeAgo()", async () => { test("shows the ISO datetime and then the relative age", 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.
await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 10; i++) await settle();
const html = node(listId).innerHTML; const html = node(listId).innerHTML;
expect(html).toContain('title="SHARED-ISO-DATE"'); expect(html).toContain(">" + SHOWN_TIME + "<");
expect(html).toContain(">SHARED-TIME-AGO<"); expect(html).not.toContain("title=");
}); });
}); });
test("the transaction detail view shows the time written by the shared isoDate() and timeAgo()", () => { test("the transaction detail view shows the ISO datetime, copyable, and then the relative age", () => {
transactionDetail.show(historyTx()); transactionDetail.show(mockHistory()[0]);
const html = node("tx-detail-time").innerHTML; const html = node("tx-detail-time").innerHTML;
expect(html).toContain("SHARED-ISO-DATE"); expect(html).toContain('data-copy="2026-09-21T14:13:20Z"');
expect(html).toContain("(SHARED-TIME-AGO)"); expect(html).toContain(">2026-09-21T14:13:20Z</span> (3 hours ago)");
}); });