fix: floor the entries of the site maps and the fraud list, and stop a failed save from failing silently (closes #362)
All checks were successful
check / check (push) Successful in 32s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 33s

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.
This commit is contained in:
2026-08-23 18:22:01 +00:00
parent ad6aa7b20d
commit 44b0a153f0
11 changed files with 954 additions and 304 deletions

View File

@@ -0,0 +1,377 @@
// A persisted container that is checked while its ENTRIES are dereferenced
// unchecked (https://git.eeqj.de/sneak/AutistMask/issues/362).
//
// https://git.eeqj.de/sneak/AutistMask/issues/311 settled the idiom — floor the
// container AND its entries, dropping anything that cannot be safely
// dereferenced — and applied it to trackedTokens and tokenBalances. These are
// the fields it did not reach.
//
// allowedSites is the worst shape in the codebase, and it is what the boot
// tests below measure: a stored `{"0x…": "notalist"}` passes the gate, renders
// a WORKING popup, and then throws `base.map is not a function` inside
// saveState()'s merge — so every save from then on fails while the UI looks
// entirely healthy and the user goes on operating a wallet that is persisting
// nothing. A blank popup is at least visibly broken; this is not. So the
// assertion here is never merely "the popup rendered": it is "the popup
// rendered AND the write actually landed in storage".
//
// Observed at ad6aa7b, with the floors below removed:
// allowedSites: {"0x…": "notalist"} -> views=["main"], errors=[], and
// storage.set NEVER called: the stored record kept no schemaVersion, so
// nothing the user did was persisted.
// fraudContracts: "0x…" -> renderSendTokenSelect() threw
// "(state.fraudContracts || []).map is not a function"
// fraudContracts: [42] -> threw "a.toLowerCase is not a
// function"
// selectedToken: 42 (restoring onto address-token) -> views=[], errors=
// ["tokenId.toLowerCase is not a function"] — a blank popup.
const { normalizePersisted } = require("../src/shared/persistedState");
const { makeStorageStub } = require("./support/storageStub");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
// One extension page: a fresh module registry over the given storage. state.js
// resolves the storage API at require time, so the stub has to be installed
// before the module is loaded.
function loadStateModule(storage) {
jest.resetModules();
globalThis.chrome = { storage: { local: storage.local } };
return require("../src/shared/state");
}
afterEach(() => {
cleanupPopup();
});
// ------------------------------------------------------- the floor itself
describe("the floor under allowedSites and deniedSites", () => {
for (const field of ["allowedSites", "deniedSites"]) {
test(`${field} that is not a record becomes an empty record`, () => {
for (const bad of ["nope", 42, true, [ADDRESS], null]) {
expect(normalizePersisted({ [field]: bad })[field]).toEqual({});
}
});
test(`an ${field} entry whose value is not a hostname list is dropped`, () => {
for (const bad of ["dapp.example", 42, null, { a: 1 }, true]) {
expect(
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
).toEqual({});
}
});
test(`a hostname that is not text is dropped from an ${field} entry`, () => {
expect(
normalizePersisted({
[field]: { [ADDRESS]: [42, null, "dapp.example", {}] },
})[field],
).toEqual({ [ADDRESS]: ["dapp.example"] });
});
test(`a real ${field} map survives, copied not shared`, () => {
const saved = { [field]: { [ADDRESS]: ["dapp.example"] } };
const out = normalizePersisted(saved);
expect(out[field]).toEqual(saved[field]);
expect(out[field]).not.toBe(saved[field]);
expect(out[field][ADDRESS]).not.toBe(saved[field][ADDRESS]);
});
test(`a good ${field} entry beside a malformed one survives`, () => {
const out = normalizePersisted({
[field]: { [ADDRESS]: ["dapp.example"], [TOKEN_ADDRESS]: 42 },
});
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
});
test(`a stored own "__proto__" key in ${field} does not become a prototype`, () => {
// JSON can carry the key, and plain assignment would hand it to
// the prototype setter — recording no entry and, worse, moving the
// map's prototype. Same reason networkEndpoints uses
// defineProperty.
const saved = JSON.parse(
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
);
const out = normalizePersisted(saved);
expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype);
expect(Object.keys(out[field])).toEqual(["__proto__"]);
expect({}.length).toBeUndefined();
});
}
});
describe("the floor under fraudContracts", () => {
test("fraudContracts that is not a list becomes an empty list", () => {
for (const bad of ["nope", 42, true, { a: 1 }]) {
expect(
normalizePersisted({ fraudContracts: bad }).fraudContracts,
).toEqual([]);
}
});
test("a fraudContracts entry that is not text is dropped", () => {
expect(
normalizePersisted({
fraudContracts: [42, null, TOKEN_ADDRESS, {}, []],
}).fraudContracts,
).toEqual([TOKEN_ADDRESS]);
});
test("a real fraudContracts list survives, copied not shared", () => {
const saved = { fraudContracts: [TOKEN_ADDRESS] };
const out = normalizePersisted(saved);
expect(out.fraudContracts).toEqual(saved.fraudContracts);
expect(out.fraudContracts).not.toBe(saved.fraudContracts);
});
});
describe("the floor under selectedToken", () => {
// Found by the sweep for this defect class, not named in the issue: the
// restore gate in src/popup/viewRouter.js checks truthiness only, and both
// src/popup/views/addressToken.js and src/popup/views/receive.js then
// dereference it as text.
test("a selectedToken that is not text becomes null", () => {
for (const bad of [42, true, { a: 1 }, [TOKEN_ADDRESS]]) {
expect(
normalizePersisted({ selectedToken: bad }).selectedToken,
).toBeNull();
}
});
test("a real selectedToken survives; the empty string becomes null", () => {
expect(
normalizePersisted({ selectedToken: TOKEN_ADDRESS }).selectedToken,
).toBe(TOKEN_ADDRESS);
expect(normalizePersisted({ selectedToken: "ETH" }).selectedToken).toBe(
"ETH",
);
expect(
normalizePersisted({ selectedToken: "" }).selectedToken,
).toBeNull();
});
});
// ----------------------------------------- what the user actually gets
describe("a malformed allowedSites entry", () => {
const MALFORMED = [
{ name: "a string", value: "notalist" },
{ name: "a number", value: 42 },
{ name: "a record", value: { hostnames: ["dapp.example"] } },
];
for (const { name, value } of MALFORMED) {
test(`whose value is ${name}: a working popup whose writes persist`, async () => {
const env = await bootPopup(
unversionedValidProfile({
allowedSites: { [ADDRESS]: value },
}),
);
expect({
visibleViews: env.visibleViews(),
errors: env.pageErrors,
}).toEqual({ visibleViews: ["main"], errors: [] });
// The half that matters. A popup that renders and never persists
// again is worse than one that renders nothing, because nothing
// tells the user. The version stamp is proof a write landed: it
// is absent from the stored record until saveState() writes one.
expect(env.storage.set).toHaveBeenCalled();
const stored = env.storage.read("autistmask");
expect(stored.schemaVersion).toBe(1);
expect(stored.wallets[0].encryptedSecret).toBe(
"encrypted-secret-1",
);
expect(stored.allowedSites).toEqual({});
});
}
test("the well-formed entries beside it keep working", async () => {
const env = await bootPopup(
unversionedValidProfile({
allowedSites: {
[ADDRESS]: ["dapp.example"],
[TOKEN_ADDRESS]: "notalist",
},
}),
);
expect(env.pageErrors).toEqual([]);
expect(env.storage.read("autistmask").allowedSites).toEqual({
[ADDRESS]: ["dapp.example"],
});
});
test("a later save still lands, not just the first", async () => {
// The failure this closes was in the MERGE, which runs on every save
// against whatever is in storage at the time. One write landing is not
// enough: the field has to stay mergeable.
const storage = makeStorageStub({
autistmask: unversionedValidProfile({
allowedSites: { [ADDRESS]: "notalist" },
}),
});
const { state, loadState, saveState } = loadStateModule(storage);
await loadState();
state.theme = "dark";
await saveState();
state.utcTimestamps = true;
await saveState();
const stored = storage.read("autistmask");
expect(stored.theme).toBe("dark");
expect(stored.utcTimestamps).toBe(true);
expect(stored.allowedSites).toEqual({});
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
});
});
describe("a malformed fraudContracts", () => {
// The send screen, which is where this one lands: the boot path only
// reaches fraudContracts through loadHomeTxs(), which catches, so the
// consequence is an unusable send screen rather than silent data loss.
function stubSendDocument() {
const select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child);
globalThis.document = {
getElementById: (id) => (id === "send-token" ? select : null),
createElement: () => ({ value: "", textContent: "" }),
};
return select;
}
const HELD = {
address: TOKEN_ADDRESS,
symbol: "AAA",
decimals: 18,
balance: "12.5",
holders: 50000,
};
async function sendScreenTokens(fraudContracts) {
const storage = makeStorageStub({
autistmask: unversionedValidProfile({ fraudContracts }),
});
const { loadState } = loadStateModule(storage);
await loadState();
const select = stubSendDocument();
const { renderSendTokenSelect } = require("../src/popup/views/send");
renderSendTokenSelect({ address: ADDRESS, tokenBalances: [HELD] });
return select.children.map((opt) => opt.value);
}
for (const bad of ["notalist", 42, { a: 1 }, [42], [null], [{}]]) {
test(`${JSON.stringify(bad)}: a usable send screen`, async () => {
await expect(sendScreenTokens(bad)).resolves.toEqual([
TOKEN_ADDRESS,
]);
});
}
test("a real fraud entry beside a malformed one still hides its token", async () => {
await expect(
sendScreenTokens([42, TOKEN_ADDRESS.toLowerCase()]),
).resolves.toEqual([]);
});
});
describe("a malformed selectedToken", () => {
test("does not blank the popup on restore", async () => {
const env = await bootPopup(
unversionedValidProfile({
currentView: "address-token",
selectedWallet: 0,
selectedAddress: 0,
selectedToken: 42,
viewStack: ["main", "address"],
}),
);
expect({
visibleViews: env.visibleViews(),
errors: env.pageErrors,
}).toEqual({ visibleViews: ["main"], errors: [] });
});
});
// --------------------------------------------- a save that fails is told
describe("a save that fails", () => {
function failingStorage(profile) {
const storage = makeStorageStub({ autistmask: profile });
const realSet = storage.local.set;
storage.local.set = jest.fn(async () => {
throw new Error("QUOTA_BYTES quota exceeded");
});
storage.restoreWrites = () => {
storage.local.set = realSet;
};
return storage;
}
test("is reported, not swallowed by the save queue", async () => {
const storage = failingStorage(unversionedValidProfile());
const { state, loadState, saveState, onSaveFailure } =
loadStateModule(storage);
const failures = [];
onSaveFailure((e) => failures.push(String(e && e.message)));
await loadState();
state.theme = "dark";
// Not awaited, which is how showView() saves on every navigation and
// how the failure used to disappear entirely.
saveState();
for (let i = 0; i < 50; i++) await Promise.resolve();
expect(failures).toEqual(["QUOTA_BYTES quota exceeded"]);
});
test("still rejects for a caller that awaits it", async () => {
const storage = failingStorage(unversionedValidProfile());
const { state, loadState, saveState, onSaveFailure } =
loadStateModule(storage);
onSaveFailure(() => {});
await loadState();
state.theme = "dark";
await expect(saveState()).rejects.toThrow("QUOTA_BYTES");
});
test("puts a banner on the popup saying nothing is being saved", async () => {
const env = await bootPopup(undefined, {
storage: failingStorage(unversionedValidProfile()),
});
// The popup is still usable — the point is that it no longer looks
// healthy while silently persisting nothing.
expect(env.visibleViews()).toEqual(["main"]);
const banner = env.node("save-failure-banner");
expect(banner).not.toBeNull();
expect(banner.textContent).toContain("NOT SAVED");
expect(banner.textContent).toContain("QUOTA_BYTES quota exceeded");
});
test("no banner appears on a popup whose saves work", async () => {
const env = await bootPopup(unversionedValidProfile());
expect(env.node("save-failure-banner")).toBeNull();
});
});

View File

@@ -35,6 +35,11 @@ const POPUP_HTML_PATH = path.join(POPUP_DIR, "index.html");
const RUNTIME_CREATED_IDS = new Set([
// Created by updateDebugBanner() in src/popup/views/helpers.js.
"debug-banner",
// Created by showSaveFailureBanner() in the same file, on the first save
// that fails. Absent from the markup on purpose: a popup where nothing has
// failed must not have to carry an empty banner
// (https://git.eeqj.de/sneak/AutistMask/issues/362).
"save-failure-banner",
]);
// Every id lookup the popup performs with a literal argument, as

View File

@@ -13,61 +13,26 @@
// calls, is exactly the defect: what has to be true is that BOOTING the popup
// on a bad blob lands on it.
//
// 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 recovery screen with no markup
// behind it cannot pass.
// The boot harness and its DOM stub built FROM src/popup/index.html, so
// "which views are visible" is answered against the real element set — live in
// tests/support/popupBoot.js, since tests/persistedEntryFloors.test.js needs
// the same boot.
//
// The fourth case is the upgrade one, and it is the case that must NOT reach
// the recovery screen: every install in the field has a valid profile with no
// version field, and showing those users a wipe prompt would be a worse defect
// than the one being fixed. It is migrated in place and keeps working.
const fs = require("fs");
const path = require("path");
const { makeStorageStub } = require("./support/storageStub");
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
// Fixed address, never used for anything but these tests.
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A fixed ERC-20 contract address, same rule.
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
// ------------------------------------------------------------- fixtures
// 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.
function unversionedValidProfile() {
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",
};
}
// The three blobs from the issue, each with the error it produced.
const CORRUPT_BLOBS = [
{
@@ -103,235 +68,7 @@ const CORRUPT_BLOBS = [
},
];
// ------------------------------------------------------------- DOM stub
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;
}
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 doc = {
listeners: {},
getElementById(id) {
// Created on demand by updateDebugBanner(); absent is the state a
// non-debug, non-testnet popup is in.
if (id === "debug-banner") 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: {
prepend: () => {},
appendChild: () => {},
removeChild: () => {},
},
elements: els,
authoredIds: authored,
created,
};
return doc;
}
// ------------------------------------------------------------- harness
// Boot the real popup entry point over `stored`, exactly as the browser does:
// storage already holds the record, the page loads, DOMContentLoaded fires.
async function bootPopup(stored) {
jest.resetModules();
// The two modules that reach the network. Neither is on the path under
// test; both would make this 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 = 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 this issue 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) => document.getElementById(id).textContent,
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();
},
// 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;
},
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
});
afterEach(cleanupPopup);
// --------------------------------------------------------------- tests

318
tests/support/popupBoot.js Normal file
View File

@@ -0,0 +1,318 @@
// 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,
};