Author SHA1 Message Date
sneak 1a21bd254c fix: history rows show the ISO datetime and the age (closes #760)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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.

Model: opus-5-5
2026-11-11 04:53:15 +00:00
14 changed files with 238 additions and 162 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
+7 -9
View File
@@ -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:
+2 -7
View File
@@ -135,14 +135,9 @@ module.exports = [
}, },
}, },
// Unit tests, the helpers they require, and the test files they hand to // Unit tests, and the helpers they require: jest on node.
// jest: jest on node.
{ {
files: [ files: ["tests/**/*.test.js", "tests/support/**/*.js"],
"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 --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 .",
+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
View File
@@ -1,7 +0,0 @@
// 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
@@ -1,29 +0,0 @@
// 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",
);
});
+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)");
}); });