A popup already open when the stored profile became unreadable stayed on the last good profile until reopened. Every save already runs the check loadState() runs at open; a save refused by it now stops the ten-second refresh, runs the leave cleanup of the current screen, and raises the recovery screen. From then on showView() shows nothing else in that popup, so a transaction wait or a later save cannot take the user off it or clear an export or a typed confirmation. That is held in memory, never as the saved current view, so a popup opened after the record is erased elsewhere opens normally. Any other failed save keeps the "NOT SAVED" banner. The popup test harness now honours clearInterval(). Model: opus-5-5
389 lines
14 KiB
JavaScript
389 lines
14 KiB
JavaScript
// 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");
|
|
|
|
// The four libraries the popup loads from node_modules, loaded once per test
|
|
// file and handed to every boot. jest.resetModules() in bootPopup() empties the
|
|
// module cache but keeps what jest.doMock() registered, so these registrations
|
|
// hold for every boot in the file and the libraries are not loaded again. None
|
|
// of them holds popup state; everything under src/ is still loaded fresh on
|
|
// each boot.
|
|
//
|
|
// A test's own mock of one of them: a jest.doMock() made inside the test
|
|
// replaces the registration here, as it would for any module. A top-of-file
|
|
// jest.mock() is what the require() below gets, so it is kept, but its factory
|
|
// runs once per file and every boot shares the same mock object.
|
|
const ethers = require("ethers");
|
|
const sodium = require("libsodium-wrappers-sumo");
|
|
const QRCode = require("qrcode");
|
|
const makeBlockie = require("ethereum-blockies-base64");
|
|
jest.doMock("ethers", () => ethers);
|
|
jest.doMock("libsodium-wrappers-sumo", () => sodium);
|
|
jest.doMock("qrcode", () => QRCode);
|
|
jest.doMock("ethereum-blockies-base64", () => makeBlockie);
|
|
|
|
// Taken before any boot replaces them; see bootPopup().
|
|
const realSetInterval = globalThis.setInterval;
|
|
const realClearInterval = globalThis.clearInterval;
|
|
|
|
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]: ["https://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<object>} 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 ten-second refresh init() starts, and a transaction wait's timers,
|
|
// would outlive the test. So every interval is recorded rather than
|
|
// started, clearInterval() removes it as a browser would, and tick() below
|
|
// runs the ones still set. Put back by cleanupPopup().
|
|
const intervals = new Map();
|
|
let lastId = 0;
|
|
globalThis.setInterval = (fn) => {
|
|
lastId += 1;
|
|
intervals.set(lastId, fn);
|
|
return lastId;
|
|
};
|
|
globalThis.clearInterval = (id) => {
|
|
intervals.delete(id);
|
|
};
|
|
|
|
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();
|
|
|
|
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();
|
|
},
|
|
// Every interval still set runs once: the ten-second refresh, and a
|
|
// transaction wait's receipt poll and elapsed counter while one runs.
|
|
tick: async () => {
|
|
for (const fn of intervals.values()) 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;
|
|
globalThis.setInterval = realSetInterval;
|
|
globalThis.clearInterval = realClearInterval;
|
|
}
|
|
|
|
module.exports = {
|
|
bootPopup,
|
|
cleanupPopup,
|
|
settle,
|
|
unversionedValidProfile,
|
|
ADDRESS,
|
|
TOKEN_ADDRESS,
|
|
POPUP_HTML,
|
|
};
|