// 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 (3 hours ago)"); });