A popup already open when the stored profile became unreadable stayed on the last good profile until reopened. Every save already reads the stored record and runs the same check loadState() runs at open; the popup now sends a save refused by that check to the recovery screen and stops its ten-second refresh, while any other failed save keeps the "NOT SAVED" banner and the screen it is on. The recovery screen ignores a second request to show it, so a save that was in flight does not clear an export or a typed confirmation. The test harness records the refresh loop so a test can run one tick of it. Model: opus-5-5
378 lines
13 KiB
JavaScript
378 lines
13 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);
|
|
|
|
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 10s refresh loop init() starts would outlive the test, so it is
|
|
// recorded rather than started, and refresh() below runs it once.
|
|
const realSetInterval = globalThis.setInterval;
|
|
const intervals = [];
|
|
globalThis.setInterval = (fn) => {
|
|
intervals.push(fn);
|
|
return 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();
|
|
},
|
|
// One tick of the refresh loop, as if ten seconds had passed.
|
|
refresh: async () => {
|
|
for (const fn of intervals) 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,
|
|
};
|