When the background saves a network switch after the user approves a site's request, or another popup page saves one, an open popup takes the new network from the stored record inside a save, holding the lock, and checks the record once at start-up for a switch saved before it listened. It then leaves the page as a switch in Settings does: the cut and the redraw are two Settings functions both paths call, and both reset the popup's caches through one function. A switch in Settings is now made inside its own save too, so one picked while a follow waits for the lock is made after it. The popup never switches the network itself. Model: opus-5-5
463 lines
18 KiB
JavaScript
463 lines
18 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 realSetTimeout = globalThis.setTimeout;
|
|
const realClearTimeout = globalThis.clearTimeout;
|
|
const realFetch = globalThis.fetch;
|
|
|
|
// Every timeout set since the last cleanupPopup() that has neither fired nor
|
|
// been cleared. cleanupPopup() clears them all.
|
|
const pendingTimeouts = new Set();
|
|
|
|
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.
|
|
* @param {string} [options.search] the page URL's query string, such as
|
|
* "?approval=" and an id for the popup opened as an approval window.
|
|
* @param {object} [options.runtime] members of chrome.runtime that replace the
|
|
* stub's own, for a test that has to answer the background's messages.
|
|
* @param {object} [options.windows] chrome.windows, for a test of the approval
|
|
* window resizing itself.
|
|
* @param {object} [options.tabs] chrome.tabs, for the same test: the page's
|
|
* tab and its zoom.
|
|
* @param {object} [options.documentElement] members of document.documentElement
|
|
* that replace the stub's own: scrollWidth and clientWidth, which are equal
|
|
* unless a test says otherwise, so the page does not scroll sideways.
|
|
* @returns {Promise<object>} handles onto the booted page.
|
|
*/
|
|
async function bootPopup(stored, options) {
|
|
jest.resetModules();
|
|
|
|
// The RPC requests made through src/shared/balances.js go through Node's
|
|
// own http and https modules, not fetch(), so the fetch() stub below would
|
|
// not stop them, which is why that module stays mocked.
|
|
// src/shared/prices.js and src/shared/transactions.js send their requests
|
|
// through fetch(), and their mocks answer with no prices and no
|
|
// transactions. Every request the page still sends, such as the
|
|
// transaction screen's Blockscout lookup and the Etherscan lookup in
|
|
// src/shared/etherscanLabels.js, meets the fetch() stub.
|
|
jest.doMock("../../src/shared/prices", () => ({
|
|
prices: {},
|
|
refreshPrices: jest.fn(async () => {}),
|
|
clearPrices: jest.fn(),
|
|
getPrice: () => null,
|
|
formatUsd: () => "",
|
|
formatAddressTotal: () => "",
|
|
getAddressValue: () => ({ usd: null, partial: false }),
|
|
}));
|
|
// scanForAddresses() answers in the real one's shape, a scan that found
|
|
// no used address: AddWallet reads `addresses` from it.
|
|
jest.doMock("../../src/shared/balances", () => ({
|
|
fetchTokenBalances: jest.fn(async () => []),
|
|
refreshBalances: jest.fn(async () => {}),
|
|
lookupTokenInfo: jest.fn(async () => null),
|
|
getProvider: () => ({}),
|
|
scanForAddresses: jest.fn(async () => ({
|
|
addresses: [],
|
|
nextIndex: 1,
|
|
})),
|
|
}));
|
|
// filterTransactions() answers in the real one's shape: Home,
|
|
// AddressDetail and AddressToken read both fields, and a bare list makes
|
|
// their transaction loading throw into a catch that only logs.
|
|
jest.doMock("../../src/shared/transactions", () => ({
|
|
fetchRecentTransactions: jest.fn(async () => []),
|
|
filterTransactions: () => ({ transactions: [], newFraudContracts: [] }),
|
|
}));
|
|
|
|
const storage =
|
|
(options && options.storage) ||
|
|
makeStorageStub(stored === undefined ? {} : { autistmask: stored });
|
|
const document = makeDocument(POPUP_HTML);
|
|
Object.assign(
|
|
document.documentElement,
|
|
{ scrollWidth: 396, clientWidth: 396 },
|
|
options && options.documentElement,
|
|
);
|
|
const reloads = [];
|
|
const windowListeners = {};
|
|
|
|
globalThis.chrome = {
|
|
storage: { local: storage.local, onChanged: storage.onChanged },
|
|
runtime: {
|
|
sendMessage: jest.fn(async () => ({})),
|
|
getURL: (p) => "chrome-extension://autistmask/" + p,
|
|
onMessage: { addListener: () => {} },
|
|
...(options && options.runtime),
|
|
},
|
|
windows: options && options.windows,
|
|
tabs: options && options.tabs,
|
|
};
|
|
globalThis.document = document;
|
|
globalThis.window = {
|
|
location: {
|
|
search: (options && options.search) || "",
|
|
href: "chrome-extension://autistmask/src/popup/index.html",
|
|
reload: () => reloads.push(Date.now()),
|
|
},
|
|
matchMedia: () => ({
|
|
matches: false,
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
}),
|
|
// Recorded, as the document's are, so a test that widens the approval
|
|
// window can call the page's resize listeners.
|
|
listeners: windowListeners,
|
|
addEventListener: (name, fn) => {
|
|
windowListeners[name] = windowListeners[name] || [];
|
|
windowListeners[name].push(fn);
|
|
},
|
|
};
|
|
// Every fetch() the page makes fails, as it does elsewhere in the suite,
|
|
// instead of reaching the internet. A new stub per boot, so the calls it
|
|
// records start with that boot. Put back by cleanupPopup().
|
|
globalThis.fetch = jest.fn(() => {
|
|
throw new Error("tests must not perform network requests");
|
|
});
|
|
// 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);
|
|
};
|
|
// Timeouts, such as the one that clears a flash message, run for real, but
|
|
// each is recorded until it fires or is cleared, so cleanupPopup() can
|
|
// clear the ones still pending. One that fired after the page was removed
|
|
// would throw "document is not defined" into whatever test the worker runs
|
|
// by then. jest.useFakeTimers() in a test does not reach them. Put back by
|
|
// cleanupPopup().
|
|
globalThis.setTimeout = (fn, ms, ...args) => {
|
|
const timer = realSetTimeout(() => {
|
|
pendingTimeouts.delete(timer);
|
|
fn(...args);
|
|
}, ms);
|
|
pendingTimeouts.add(timer);
|
|
return timer;
|
|
};
|
|
globalThis.clearTimeout = (timer) => {
|
|
pendingTimeouts.delete(timer);
|
|
realClearTimeout(timer);
|
|
};
|
|
|
|
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;
|
|
for (const timer of pendingTimeouts) realClearTimeout(timer);
|
|
pendingTimeouts.clear();
|
|
globalThis.setTimeout = realSetTimeout;
|
|
globalThis.clearTimeout = realClearTimeout;
|
|
globalThis.fetch = realFetch;
|
|
}
|
|
|
|
module.exports = {
|
|
bootPopup,
|
|
cleanupPopup,
|
|
settle,
|
|
unversionedValidProfile,
|
|
ADDRESS,
|
|
TOKEN_ADDRESS,
|
|
POPUP_HTML,
|
|
};
|