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
201 lines
6.5 KiB
JavaScript
201 lines
6.5 KiB
JavaScript
// A transaction's time shows the ISO datetime and the relative age on every
|
|
// screen that shows one (README, Amounts). Home, AddressDetail and AddressToken
|
|
// used to show only the age, with the datetime as a tooltip
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/760).
|
|
//
|
|
// The views run against a minimal DOM stub, as in
|
|
// tests/historyAfterNetworkSwitch.test.js, and over
|
|
// tests/support/storageStub.js.
|
|
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
|
|
|
|
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.
|
|
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(),
|
|
}));
|
|
|
|
const { state, loadState } = require("../src/shared/state");
|
|
const home = require("../src/popup/views/home");
|
|
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";
|
|
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) {
|
|
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"),
|
|
addEventListener: () => {},
|
|
body: { prepend: () => {} },
|
|
};
|
|
}
|
|
|
|
function node(id) {
|
|
return globalThis.document.getElementById(id);
|
|
}
|
|
|
|
// Lets every promise that is not held open settle.
|
|
function settle() {
|
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
const storage = makeStorageStub({ autistmask: profile() });
|
|
globalThis.chrome = {
|
|
storage: { local: storage.local, onChanged: storage.onChanged },
|
|
};
|
|
globalThis.document = makeDocument();
|
|
globalThis.window = { location: { search: "" } };
|
|
jest.spyOn(Date, "now").mockReturnValue((TIMESTAMP + 3 * 3600) * 1000);
|
|
await loadState();
|
|
state.viewStack = [];
|
|
state.selectedWallet = 0;
|
|
state.selectedAddress = 0;
|
|
state.selectedToken = "ETH";
|
|
const ctx = { pageClosed: new AbortController().signal };
|
|
addressDetail.init(ctx);
|
|
addressToken.init(ctx);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
delete globalThis.chrome;
|
|
delete globalThis.document;
|
|
delete globalThis.window;
|
|
});
|
|
|
|
describe.each([
|
|
[
|
|
"Home",
|
|
"home-tx-list",
|
|
() => home.render({ pageClosed: new AbortController().signal }),
|
|
],
|
|
["AddressDetail", "tx-list", () => addressDetail.show()],
|
|
["AddressToken", "address-token-tx-list", () => addressToken.show()],
|
|
])("a transaction history row on %s", (_name, listId, open) => {
|
|
test("shows the ISO datetime and then the relative age", async () => {
|
|
open();
|
|
// The list is drawn once the history has been fetched.
|
|
for (let i = 0; i < 10; i++) await settle();
|
|
const html = node(listId).innerHTML;
|
|
expect(html).toContain(">" + SHOWN_TIME + "<");
|
|
expect(html).not.toContain("title=");
|
|
});
|
|
});
|
|
|
|
test("the transaction detail view shows the ISO datetime, copyable, and then the relative age", () => {
|
|
transactionDetail.show(mockHistory()[0]);
|
|
const html = node("tx-detail-time").innerHTML;
|
|
expect(html).toContain('data-copy="2026-09-21T14:13:20Z"');
|
|
expect(html).toContain(">2026-09-21T14:13:20Z</span> (3 hours ago)");
|
|
});
|