// Boot the REAL popup entry point over a stored record, exactly as the browser // does: storage already holds the record, the page loads, DOMContentLoaded // fires. // // Written for https://git.eeqj.de/sneak/AutistMask/issues/311 inside // tests/stateRecovery.test.js and lifted here unchanged in substance when // https://git.eeqj.de/sneak/AutistMask/issues/362 needed the same boot for a // second field. Assertions about a corrupt stored profile have to be made // through the entry point rather than against a view module: a screen that // renders perfectly when something calls it, and that nothing calls, IS the // defect. // // The DOM stub is built FROM src/popup/index.html — every id in the markup, // with the classes the markup gives it — so "which views are visible" is // answered against the real element set, and a screen with no markup behind it // cannot pass. const fs = require("fs"); const path = require("path"); const { makeStorageStub } = require("./storageStub"); const POPUP_HTML = fs.readFileSync( path.join(__dirname, "..", "..", "src", "popup", "index.html"), "utf8", ); // Fixed addresses, never used for anything but these tests. const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; // A profile in the shape every install in the field has it: complete, valid, // and carrying no version field, because no build ever wrote one. The starting // point for "and now corrupt exactly one field of it". function unversionedValidProfile(extra) { return { hasWallet: true, wallets: [ { type: "hd", name: "Wallet 1", xpub: "xpub-wallet-1", encryptedSecret: "encrypted-secret-1", nextIndex: 1, addresses: [ { address: ADDRESS, balance: "1.5", tokenBalances: [] }, ], }, ], activeAddress: ADDRESS, networkId: "mainnet", rpcUrl: "https://ethereum-rpc.publicnode.com", blockscoutUrl: "https://eth.blockscout.com/api/v2", allowedSites: { [ADDRESS]: ["dapp.example"] }, deniedSites: {}, trackedTokens: [], theme: "system", ...(extra || {}), }; } function makeElement(id, className) { const classes = new Set( (className || "").split(/\s+/).filter((name) => name !== ""), ); const el = { id, tagName: "DIV", textContent: "", value: "", innerHTML: "", href: "", download: "", disabled: false, style: {}, dataset: {}, listeners: {}, clicked: 0, classList: { add: (...names) => names.forEach((n) => classes.add(n)), remove: (...names) => names.forEach((n) => classes.delete(n)), contains: (n) => classes.has(n), toggle: (n, force) => { const on = force === undefined ? !classes.has(n) : force; if (on) classes.add(n); else classes.delete(n); return on; }, }, addEventListener: (name, fn) => { el.listeners[name] = el.listeners[name] || []; el.listeners[name].push(fn); }, removeEventListener: () => {}, appendChild: () => {}, remove: () => {}, focus: () => {}, select: () => {}, setAttribute: (name, value) => { el[name] = value; }, querySelector: () => null, querySelectorAll: () => [], // The Receive view draws its QR onto #receive-qr through the qrcode // package, which calls getContext("2d") and then createImageData/ // putImageData on the result. Without this the render throws from // inside a promise the view does not await, which takes the whole node // process down rather than failing a test — so a suite that boots onto // Receive could not report anything. getContext: () => ({ createImageData: (w, h) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4), }), putImageData: () => {}, clearRect: () => {}, }), click: () => { el.clicked += 1; }, }; // src/ reaches parentElement only to hide or unhide the wrapper a field // sits in (txStatus.js renderSuccess(), transactionDetail.js render()). // The stub is flat — it is built from the ids in the markup, not from its // tree — so each element gets a wrapper of its own, made on demand so this // does not recurse. It is never registered by id, so nothing can mistake // it for a view. Without it, success-tx and transaction throw on the first // line that touches a wrapper and cannot be booted onto at all. let parent = null; Object.defineProperty(el, "parentElement", { get() { if (!parent) parent = makeElement(id + "-parent", ""); return parent; }, }); return el; } // Every id in the markup, with the classes the markup gives it. A view the // popup is supposed to reveal has to exist here, which means it has to exist // in src/popup/index.html. function idsFromHtml(html) { const out = new Map(); const tags = html.match(/<[a-zA-Z][^>]*>/g) || []; for (const tag of tags) { const id = /\bid="([^"]+)"/.exec(tag); if (!id) continue; const cls = /\bclass="([^"]*)"/.exec(tag); out.set(id[1], cls ? cls[1] : ""); } return out; } // Ids the popup creates at runtime rather than authoring in the markup, and // that must therefore read as ABSENT until something creates them. Answering // with a fresh element instead would make "is the banner up?" always true. const RUNTIME_IDS = new Set(["debug-banner", "save-failure-banner"]); function makeDocument(html) { const authored = idsFromHtml(html); const els = new Map(); for (const [id, className] of authored) { els.set(id, makeElement(id, className)); } const created = []; const prepended = []; const doc = { listeners: {}, getElementById(id) { if (RUNTIME_IDS.has(id) && !els.has(id)) return null; if (!els.has(id)) els.set(id, makeElement(id, "")); return els.get(id); }, createElement(tag) { const el = makeElement("created-" + tag, ""); el.tagName = String(tag).toUpperCase(); created.push(el); return el; }, addEventListener(name, fn) { doc.listeners[name] = doc.listeners[name] || []; doc.listeners[name].push(fn); }, querySelectorAll: () => [], documentElement: makeElement("html", ""), body: { // Recorded, and registered under its id: a banner the popup // prepends is on the page from then on, and a test asking for it // by id has to find it. prepend: (el) => { prepended.push(el); if (el && el.id) els.set(el.id, el); }, appendChild: () => {}, removeChild: () => {}, }, elements: els, authoredIds: authored, created, prepended, }; return doc; } async function settle() { for (let i = 0; i < 50; i++) await Promise.resolve(); } /** * Boot the popup over `stored`. * * @param {*} stored the record storage holds, or undefined for a first run. * @param {object} [options] * @param {object} [options.storage] a storage stub from makeStorageStub(), for * a test that needs to make writes fail or to watch the round trips. * @returns {Promise} handles onto the booted page. */ async function bootPopup(stored, options) { jest.resetModules(); // The three modules that reach the network. None is on the path under // test; all would make the suite hit the internet. jest.doMock("../../src/shared/prices", () => ({ prices: {}, refreshPrices: jest.fn(async () => {}), clearPrices: jest.fn(), getPrice: () => null, formatUsd: () => "", formatAddressTotal: () => "", getAddressValue: () => ({ usd: null, partial: false }), getWalletValue: () => ({ usd: null, partial: false }), getTotalValue: () => ({ usd: null, partial: false }), })); jest.doMock("../../src/shared/balances", () => ({ fetchTokenBalances: jest.fn(async () => []), refreshBalances: jest.fn(async () => {}), lookupTokenInfo: jest.fn(async () => null), getProvider: () => ({}), scanForAddresses: jest.fn(async () => []), })); jest.doMock("../../src/shared/transactions", () => ({ fetchRecentTransactions: jest.fn(async () => []), filterTransactions: () => [], })); const storage = (options && options.storage) || makeStorageStub(stored === undefined ? {} : { autistmask: stored }); const document = makeDocument(POPUP_HTML); const reloads = []; globalThis.chrome = { storage: { local: storage.local }, runtime: { sendMessage: jest.fn(async () => ({})), getURL: (p) => "chrome-extension://autistmask/" + p, onMessage: { addListener: () => {} }, }, }; globalThis.document = document; globalThis.window = { location: { search: "", href: "chrome-extension://autistmask/src/popup/index.html", reload: () => reloads.push(Date.now()), }, matchMedia: () => ({ matches: false, addEventListener: () => {}, removeEventListener: () => {}, }), addEventListener: () => {}, }; // The 10s refresh loop init() starts would outlive the test. const realSetInterval = globalThis.setInterval; globalThis.setInterval = () => 0; require("../../src/popup/index"); const booted = []; for (const fn of document.listeners.DOMContentLoaded || []) { booted.push(fn()); } // What the browser console would have shown. A throw out of init() is the // blank popup issue 311 is about, so it is captured rather than thrown: // the assertion that matters is what ended up on screen. const pageErrors = []; for (const p of booted) { try { await p; } catch (e) { pageErrors.push(String((e && e.message) || e)); } } await settle(); globalThis.setInterval = realSetInterval; return { storage, document, pageErrors, reloaded: () => reloads.length, node: (id) => document.getElementById(id), text: (id) => { const el = document.getElementById(id); return el ? el.textContent : null; }, value: (id) => document.getElementById(id).value, hidden: (id) => document.getElementById(id).classList.contains("hidden"), click: async (id) => { const el = document.getElementById(id); const fns = el.listeners.click || []; for (const fn of fns) await fn(); await settle(); }, settle, // The view ids whose section is not hidden, as the audit measured them. visibleViews: () => { const out = []; for (const [id, el] of document.elements) { if (!id.startsWith("view-")) continue; if (!el.classList.contains("hidden")) out.push(id.slice(5)); } return out; }, }; } function cleanupPopup() { delete globalThis.chrome; delete globalThis.document; delete globalThis.window; } module.exports = { bootPopup, cleanupPopup, settle, unversionedValidProfile, ADDRESS, TOKEN_ADDRESS, POPUP_HTML, };