Files
AutistMask/tests/support/popupBoot.js
clawbot 44b0a153f0
All checks were successful
check / check (push) Successful in 32s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 33s
fix: floor the entries of the site maps and the fraud list, and stop a failed save from failing silently (closes #362)
allowedSites was checked as a container while its entries were dereferenced unchecked. A stored {"0x...": "notalist"} passed the state gate and rendered a completely healthy popup, then threw "base.map is not a function" inside saveState()'s per-hostname merge, so every save from that moment on failed and the user went on operating a wallet that was persisting nothing. Measured against the previous head: the popup showed the main view with no page errors, and chrome.storage.local.set was never called at all. deniedSites has the identical shape; fraudContracts the same class with a milder consequence, throwing "(state.fraudContracts || []).map is not a function" on the send screen; and the sweep for the class turned up selectedToken, which is truthiness-gated on restore and then dereferenced as text, blanking the popup outright with "tokenId.toLowerCase is not a function".

All four now get the floor issue 311 settled -- the container AND its entries, with a malformed entry dropped -- through textList() and siteMap() beside the existing tokenRefs() in persistedState.js, rather than a third mechanism. Site-map keys are written with defineProperty for the same reason networkEndpoints' keys are: a stored own "__proto__" key would otherwise be handed to the prototype setter. The background's allowed.includes(hostname) gate is covered by the same floor, where a stored string would have answered a substring match rather than merely throwing.

A save that fails is no longer swallowed. onSaveFailure() in state.js reports every failed save, awaited or not -- the save queue has to attach a rejection handler to keep advancing, which is what made a failure disappear entirely -- and the popup raises a persistent "NOT SAVED" banner naming the reason. The popup's background refresh loop no longer turns a save failure into an unhandled rejection instead of a report. Both halves are needed: the floor only covers the causes it knows about, and storage can still fail for a quota or a revoked permission.

The field-by-field categorisation in the header of stateSchema.js, and its mirror in README.md, were re-verified against the code and moved with the change; the fields left on a loose floor now carry the reason each one is still safe. The popup boot harness moved to tests/support/popupBoot.js so the new tests drive the real entry point rather than duplicating it.
2026-08-23 18:22:01 +00:00

319 lines
10 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");
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: () => [],
click: () => {
el.clicked += 1;
},
};
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.
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,
};