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

@@ -1016,17 +1016,33 @@ generic `-32603` every request used to answer.
Every other field of the record is floored in `normalizePersisted()` rather than Every other field of the record is floored in `normalizePersisted()` rather than
gated. That floor is a type check for the fields something dereferences gated. That floor is a type check for the fields something dereferences
structurally — `trackedTokens`, each address's `tokenBalances`, `networkId`, structurally — `trackedTokens`, each address's `tokenBalances`, `allowedSites`,
`networkEndpoints`, `activeAddress`, `viewStack` — and it checks the ENTRIES as `deniedSites`, `fraudContracts`, `viewStack`, `networkEndpoints`, plus the
well as the container, because `[1, 2]` is a list and `t.address` is one level scalars `networkId`, `activeAddress` and `selectedToken` — and it checks the
below an `Array.isArray()`. The remaining fields get a `saved.x || default` or a ENTRIES as well as the container, because `[1, 2]` is a list,
present-or-default passthrough that takes the stored value verbatim, with no `{"0x…": "notalist"}` is an object, and the dereference is one level below the
type check at all; which field is in which category is listed in the header of container check. A malformed entry is dropped. The remaining fields get a
`saved.x || default` or a present-or-default passthrough that takes the stored
value verbatim, with no type check at all; which field is in which category, and
why the loose floor is still enough for each of them, is listed in the header of
`src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS `src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS
dereferenced walks through truthiness and throws on the first read, which is the dereferenced walks through truthiness and throws on the first read, which is the
blank popup again by a longer route — so adding a field means choosing between blank popup again by a longer route — so adding a field means choosing between
the two by what reads it. the two by what reads it.
The `allowedSites` case is why the entry check is not optional. A stored
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
passed the gate, rendered a completely healthy popup, and then threw 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
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
now also reported rather than swallowed: `onSaveFailure()` in
`src/shared/state.js` is called for every failed save, awaited or not, and the
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
covers — a quota, a revoked permission, a record a newer build wrote — and the
wallet must never look healthy while that is true.
The `networkId` check is not cosmetic: that value is an object KEY into The `networkId` check is not cosmetic: that value is an object KEY into
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's `state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
prototype instead of an own key and the user's endpoint would silently not be prototype instead of an own key and the user's endpoint would silently not be

25
TODO.md
View File

@@ -45,6 +45,31 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-08-23: A persisted container whose ENTRIES were dereferenced unchecked no
longer reaches a `.map()` or a `.toLowerCase()`
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). `allowedSites` was
the worst shape available: a stored `{"0x…": "notalist"}` passed the gate,
rendered a completely healthy popup, and then threw inside `saveState()`'s
per-hostname merge, so every save from that moment on failed silently and the
user went on operating a wallet that was persisting nothing — measured as
`chrome.storage.local.set` never being called at all. `deniedSites` has the
same shape, `fraudContracts` the same class with a milder consequence (a
broken send screen, since the boot path only reaches it through
`loadHomeTxs()`, which catches), and the sweep for the class turned up
`selectedToken`, which blanked the popup outright when restoring onto
address-token. All four now get the floor
[#311](https://git.eeqj.de/sneak/AutistMask/issues/311) settled — container
AND entries, malformed entries dropped — through `textList()` and `siteMap()`
beside the existing `tokenRefs()` in `src/shared/persistedState.js`. Site-map
keys are written with `defineProperty` for the same reason `networkEndpoints`'
are. Separately, a save that fails is no longer swallowed: `onSaveFailure()`
in `src/shared/state.js` reports every failed save, awaited or not, and the
popup raises a persistent "NOT SAVED" banner; the background refresh loop no
longer turns a failure into an unhandled rejection instead of a report. The
field-by-field categorisation in the header of `src/shared/stateSchema.js`,
and its mirror in `README.md`, were re-verified against the code and moved
with the change.
- 2026-08-23: A swap whose output token the calldata never named is said to be - 2026-08-23: A swap whose output token the calldata never named is said to be
unknown instead of being called ETH unknown instead of being called ETH
([#353](https://git.eeqj.de/sneak/AutistMask/issues/353)). `tokenInfo(null)` ([#353](https://git.eeqj.de/sneak/AutistMask/issues/353)). `tokenInfo(null)`

View File

@@ -1,9 +1,14 @@
// AutistMask popup entry point. // AutistMask popup entry point.
// Loads state, initializes views, triggers first render. // Loads state, initializes views, triggers first render.
const { state, saveState, loadState } = require("../shared/state"); const {
state,
saveState,
onSaveFailure,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema"); const { StateUnusableError } = require("../shared/stateSchema");
const { setRuntimeDebug } = require("../shared/log"); const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices"); const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances"); const { refreshBalances } = require("../shared/balances");
const { const {
@@ -11,6 +16,7 @@ const {
showView, showView,
updateDebugBanner, updateDebugBanner,
setBackRenderer, setBackRenderer,
showSaveFailureBanner,
pushCurrentView, pushCurrentView,
goBack, goBack,
} = require("./views/helpers"); } = require("./views/helpers");
@@ -61,6 +67,14 @@ async function doRefreshAndRender() {
state.lastBalanceRefresh = Date.now(); state.lastBalanceRefresh = Date.now();
await saveState(); await saveState();
renderWalletList(); renderWalletList();
} catch (e) {
// Every call site fires this and walks away — the boot below, the ten
// second interval, and eight views through ctx — so it must never
// reject: an unhandled rejection is not a report of anything. The save
// inside it reports its own failure through onSaveFailure() (see
// src/shared/state.js); what is left here is a failed network round
// trip, which the next tick retries.
log.errorf("popup: background refresh failed:", e);
} finally { } finally {
refreshInFlight = false; refreshInFlight = false;
} }
@@ -136,6 +150,12 @@ function fallbackView() {
} }
async function init() { async function init() {
// First, before anything can save: showView() saves on every navigation
// without awaiting, so a save that fails from here on has somewhere to be
// reported rather than being swallowed by the save queue
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// the approval-window branch below too, since that window saves as well.
onSaveFailure(showSaveFailureBanner);
try { try {
await loadState(); await loadState();
} catch (e) { } catch (e) {

View File

@@ -132,6 +132,38 @@ function updateDebugBanner(viewName) {
} }
} }
// The banner shown when a save has failed, registered as the save-failure
// reporter by src/popup/index.js.
//
// Persistent and not dismissable, unlike showFlash(): what it says is true
// until the popup is closed, and a message that clears itself after two seconds
// is how the user goes on operating a wallet that is persisting nothing
// (https://git.eeqj.de/sneak/AutistMask/issues/362). It survives navigation
// because it hangs off document.body rather than off a view.
//
// Created on demand rather than authored in index.html, the same way
// updateDebugBanner() creates its own: it is absent from a popup where nothing
// has failed, which is the state that must not need markup to be in.
//
// textContent, never innerHTML: `detail` carries an error message, which may
// come from the browser's storage layer.
function showSaveFailureBanner(detail) {
let banner = document.getElementById("save-failure-banner");
if (!banner) {
banner = document.createElement("div");
banner.id = "save-failure-banner";
banner.style.cssText =
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:2px 4px;font-family:monospace;position:sticky;top:0;z-index:10000;";
document.body.prepend(banner);
}
const message = (detail && (detail.message || detail.problem)) || detail;
banner.textContent =
"NOT SAVED — AutistMask could not write to storage, so recent" +
" changes are not stored. Close and reopen the popup; if this keeps" +
" happening, do not rely on anything you change now." +
(message ? " (" + String(message) + ")" : "");
}
// Callback that renders a view being navigated BACK onto. Set once by // Callback that renders a view being navigated BACK onto. Set once by
// index.js via setBackRenderer(), which routes the view through the same // index.js via setBackRenderer(), which routes the view through the same
// per-view render and data guards restoreView() uses. // per-view render and data guards restoreView() uses.
@@ -516,6 +548,7 @@ module.exports = {
showView, showView,
onViewLeave, onViewLeave,
updateDebugBanner, updateDebugBanner,
showSaveFailureBanner,
setBackRenderer, setBackRenderer,
pushCurrentView, pushCurrentView,
goBack, goBack,

View File

@@ -92,6 +92,57 @@ function tokenRefs(value) {
); );
} }
// A list of strings, for the fields whose entries are dereferenced as text:
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
// src/shared/transactions.js) and each address's hostname list in the site maps
// below (`h !== host` filters, `list.includes(hostname)` in the background).
//
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
// with a malformed entry DROPPED rather than repaired. A number in a hostname
// list names no site and a number in fraudContracts names no contract, so there
// is nothing to repair either to, and the empty list is a legitimate value that
// survives. The result is a fresh array of primitives, so it shares no
// structure with `saved`.
function textList(value) {
if (!Array.isArray(value)) return [];
return value.filter((entry) => typeof entry === "string");
}
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
//
// The container check these had (truthy and not an array) is not the floor:
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
// level below it. saveState() merges these maps per key and then per hostname
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
// rendered, which is why every save from then on failed while the UI looked
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
// revoke button (`list.filter()`), and the background's
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
// one a stored string would also answer a SUBSTRING match, so a corrupt map
// could widen a site permission rather than merely throw.
//
// An address key whose value is not a list of hostnames is dropped entirely: it
// grants and denies nothing, and dropping it fails closed. Keys are written
// with defineProperty for the same reason networkEndpoints' are — a stored own
// "__proto__" key, which JSON can carry, would otherwise be handed to the
// prototype setter and recorded nowhere.
function siteMap(value) {
const out = {};
if (!isRecord(value)) return out;
for (const address of Object.keys(value)) {
const hostnames = textList(value[address]);
if (hostnames.length === 0) continue;
Object.defineProperty(out, address, {
value: hostnames,
writable: true,
enumerable: true,
configurable: true,
});
}
return out;
}
// Keep only the leading run of stored views the popup is willing to render. // Keep only the leading run of stored views the popup is willing to render.
// //
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack // restoreView() refuses to reopen ONTO a non-restorable view, but the stack
@@ -239,14 +290,8 @@ function normalizePersisted(saved) {
typeof saved.activeAddress === "string" && saved.activeAddress !== "" typeof saved.activeAddress === "string" && saved.activeAddress !== ""
? saved.activeAddress ? saved.activeAddress
: null; : null;
out.allowedSites = out.allowedSites = siteMap(saved.allowedSites);
saved.allowedSites && !Array.isArray(saved.allowedSites) out.deniedSites = siteMap(saved.deniedSites);
? structuredClone(saved.allowedSites)
: {};
out.deniedSites =
saved.deniedSites && !Array.isArray(saved.deniedSites)
? structuredClone(saved.deniedSites)
: {};
out.rememberSiteChoice = out.rememberSiteChoice =
saved.rememberSiteChoice !== undefined saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice ? saved.rememberSiteChoice
@@ -279,7 +324,12 @@ function normalizePersisted(saved) {
: 100000; : 100000;
out.utcTimestamps = out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false; saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
out.fraudContracts = structuredClone(saved.fraudContracts || []); // A list of contract addresses, floored the same way: send.js builds its
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
// and filterTransactions() maps the same list through normalizeAddress(),
// so a stored string walks through the `|| []` and a stored number walks
// through an Array.isArray().
out.fraudContracts = textList(saved.fraudContracts);
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {}); out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
out.theme = saved.theme || "system"; out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false; out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
@@ -288,7 +338,20 @@ function normalizePersisted(saved) {
saved.selectedWallet !== undefined ? saved.selectedWallet : null; saved.selectedWallet !== undefined ? saved.selectedWallet : null;
out.selectedAddress = out.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null; saved.selectedAddress !== undefined ? saved.selectedAddress : null;
out.selectedToken = saved.selectedToken || null; // "ETH", or a contract address, or null — never anything else. The popup
// restores onto "address-token" behind a truthiness check on this field and
// then dereferences it as text (`tokenId.toLowerCase()` in
// src/popup/views/addressToken.js, `state.selectedToken.toLowerCase()` in
// src/popup/views/receive.js), so a stored number is truthy, passes the
// restore gate, and throws on the screen it restores onto. Found by the
// sweep for this same defect class in
// https://git.eeqj.de/sneak/AutistMask/issues/362; floored to null, which
// is what the restore gate already treats as "nothing selected". The empty
// string was already falsy here and stays null.
out.selectedToken =
typeof saved.selectedToken === "string" && saved.selectedToken !== ""
? saved.selectedToken
: null;
out.viewData = structuredClone(saved.viewData || {}); out.viewData = structuredClone(saved.viewData || {});
out.viewStack = restorableStack(saved.viewStack, out.currentView); out.viewStack = restorableStack(saved.viewStack, out.currentView);
return out; return out;

View File

@@ -522,11 +522,51 @@ async function saveStateOnce() {
// begins, so each one only ever sees the true live state at its turn. // begins, so each one only ever sees the true live state at its turn.
let saveQueue = Promise.resolve(); let saveQueue = Promise.resolve();
// Where a failed save is REPORTED, set once by the context that has a screen
// to say it on (src/popup/index.js).
//
// A save that fails must not fail silently. showView() fires saveState() on
// every navigation without awaiting it, and the queue below has to attach a
// rejection handler to keep advancing — so a failing save was swallowed
// entirely: no throw, no message, nothing on screen. The wallet kept running
// against storage that was rejecting every write, which is the data-loss half
// of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
// no better off: `await saveState()` inside an unguarded event handler surfaces
// in the console and nowhere the user looks.
//
// This is the "tell the user" half; the other half is the floor in
// normalizePersisted(), which stops the malformed-record cause from arising in
// the first place. Both, because a floor only covers the causes it knows about
// and storage can still fail for reasons of its own (quota, a revoked
// permission, a record a newer build wrote).
let saveFailureHandler = null;
function onSaveFailure(fn) {
saveFailureHandler = fn;
}
function reportSaveFailure(err) {
log.errorf("state: saving failed, changes were NOT persisted:", err);
if (!saveFailureHandler) return;
try {
saveFailureHandler(err);
} catch (e) {
// The reporter is the last thing standing between a failed save and
// silence; a reporter that throws must not become an unhandled
// rejection of its own on top of it.
log.errorf("state: the save-failure reporter itself failed:", e);
}
}
function saveState() { function saveState() {
const turn = saveQueue.then(saveStateOnce); const turn = saveQueue.then(saveStateOnce);
// The queue must advance even when a save rejects, or every save after // The queue must advance even when a save rejects, or every save after
// it queues behind a promise that never settles. // it queues behind a promise that never settles.
saveQueue = turn.catch(() => {}); saveQueue = turn.catch(() => {});
// Every failed save is reported, whether or not the caller awaited this
// one. The returned promise still rejects, so a caller that DOES await
// keeps its own error handling.
turn.catch(reportSaveFailure);
return turn; return turn;
} }
@@ -574,6 +614,7 @@ function currentAddress() {
module.exports = { module.exports = {
state, state,
saveState, saveState,
onSaveFailure,
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,

View File

@@ -31,18 +31,33 @@
// which one a given field has without reading it off: // which one a given field has without reading it off:
// //
// Type-checked, container AND entries: trackedTokens, each address's // Type-checked, container AND entries: trackedTokens, each address's
// tokenBalances, networkId, networkEndpoints, activeAddress, viewStack. // tokenBalances, allowedSites, deniedSites, fraudContracts, viewStack,
// These are the fields something dereferences structurally — iterated, // networkEndpoints. These are the fields something dereferences
// indexed, assigned into, or .toLowerCase()'d — where a truthy value of // structurally — iterated, indexed, assigned into, or .toLowerCase()'d —
// the wrong type throws on the first read. The entries matter as much as // where a truthy value of the wrong type throws on the first read. The
// the container: [1, 2] IS a list, and `t.address` is one level below the // entries matter as much as the container: [1, 2] IS a list, {"0x…":
// Array.isArray(). // "notalist"} IS an object, and the dereference is one level below the
// Container shape only: allowedSites, deniedSites. A falsy value or a list // container check. A malformed entry is dropped. networkEndpoints is the
// becomes {}; anything else is taken as stored and the entries are not // one whose entries are coerced rather than dropped: each value is spread
// checked. // into a fresh record, so a stored scalar becomes a record with no
// rpcUrl/blockscoutUrl and falls to the network defaults.
// Type-checked scalar: networkId, activeAddress, selectedToken. Each is
// dereferenced as text or used as an object key, so a truthy value of the
// wrong type throws or lands somewhere it should not; each falls back to
// its default or to null.
// `saved.x || default`, no type check: rpcUrl, blockscoutUrl, // `saved.x || default`, no type check: rpcUrl, blockscoutUrl,
// lastBalanceRefresh, fraudContracts, tokenHolderCache, theme, // lastBalanceRefresh, tokenHolderCache, theme, currentView, viewData.
// currentView, selectedToken, viewData. // None of these is dereferenced structurally, which is why the loose
// floor is still enough, and each reason is a fact about the readers, not
// a promise: the two URLs are only concatenated into a fetch URL and
// handed to ethers, where a bad value fails the request on a path that
// already catches; lastBalanceRefresh is only arithmetic; theme and
// currentView are only compared and concatenated (both fall through to a
// default branch, and currentView is additionally gated by
// RESTORABLE_VIEWS.has() before anything renders from it); viewData is
// only read field-by-field, and a field of a string or a number is
// undefined rather than a throw; nothing in src/ reads tokenHolderCache at
// all — it is only ever reset wholesale.
// Present-or-default, value taken verbatim: every boolean flag, // Present-or-default, value taken verbatim: every boolean flag,
// dustThresholdGwei, selectedWallet, selectedAddress. // dustThresholdGwei, selectedWallet, selectedAddress.
// //

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([ const RUNTIME_CREATED_IDS = new Set([
// Created by updateDebugBanner() in src/popup/views/helpers.js. // Created by updateDebugBanner() in src/popup/views/helpers.js.
"debug-banner", "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 // 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 // calls, is exactly the defect: what has to be true is that BOOTING the popup
// on a bad blob lands on it. // on a bad blob lands on it.
// //
// The DOM stub is built FROM src/popup/index.html — every id in the markup, // The boot harness and its DOM stub built FROM src/popup/index.html, so
// with the classes the markup gives it — so "which views are visible" is // "which views are visible" is answered against the real element set — live in
// answered against the real element set, and a recovery screen with no markup // tests/support/popupBoot.js, since tests/persistedEntryFloors.test.js needs
// behind it cannot pass. // the same boot.
// //
// The fourth case is the upgrade one, and it is the case that must NOT reach // 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 // 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 // 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. // than the one being fixed. It is migrated in place and keeps working.
const fs = require("fs"); const {
const path = require("path"); bootPopup,
cleanupPopup,
const { makeStorageStub } = require("./support/storageStub"); unversionedValidProfile,
ADDRESS,
const POPUP_HTML = fs.readFileSync( TOKEN_ADDRESS,
path.join(__dirname, "..", "src", "popup", "index.html"), } = require("./support/popupBoot");
"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";
// ------------------------------------------------------------- fixtures // ------------------------------------------------------------- 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. // The three blobs from the issue, each with the error it produced.
const CORRUPT_BLOBS = [ const CORRUPT_BLOBS = [
{ {
@@ -103,235 +68,7 @@ const CORRUPT_BLOBS = [
}, },
]; ];
// ------------------------------------------------------------- DOM stub afterEach(cleanupPopup);
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;
});
// --------------------------------------------------------------- tests // --------------------------------------------------------------- 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,
};