fix: floor the entries of the site maps and the fraud list, and stop a failed save from failing silently (closes #362)
allowedSites was checked as a container while its entries were dereferenced unchecked. A stored {"0x...": "notalist"} passed the state gate and rendered a completely healthy popup, then threw "base.map is not a function" inside saveState()'s per-hostname merge, so every save from that moment on failed and the user went on operating a wallet that was persisting nothing. Measured against the previous head: the popup showed the main view with no page errors, and chrome.storage.local.set was never called at all. deniedSites has the identical shape; fraudContracts the same class with a milder consequence, throwing "(state.fraudContracts || []).map is not a function" on the send screen; and the sweep for the class turned up selectedToken, which is truthiness-gated on restore and then dereferenced as text, blanking the popup outright with "tokenId.toLowerCase is not a function".
All four now get the floor issue 311 settled -- the container AND its entries, with a malformed entry dropped -- through textList() and siteMap() beside the existing tokenRefs() in persistedState.js, rather than a third mechanism. Site-map keys are written with defineProperty for the same reason networkEndpoints' keys are: a stored own "__proto__" key would otherwise be handed to the prototype setter. The background's allowed.includes(hostname) gate is covered by the same floor, where a stored string would have answered a substring match rather than merely throwing.
A save that fails is no longer swallowed. onSaveFailure() in state.js reports every failed save, awaited or not -- the save queue has to attach a rejection handler to keep advancing, which is what made a failure disappear entirely -- and the popup raises a persistent "NOT SAVED" banner naming the reason. The popup's background refresh loop no longer turns a save failure into an unhandled rejection instead of a report. Both halves are needed: the floor only covers the causes it knows about, and storage can still fail for a quota or a revoked permission.
The field-by-field categorisation in the header of stateSchema.js, and its mirror in README.md, were re-verified against the code and moved with the change; the fields left on a loose floor now carry the reason each one is still safe. The popup boot harness moved to tests/support/popupBoot.js so the new tests drive the real entry point rather than duplicating it.
This commit is contained in:
28
README.md
28
README.md
@@ -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
25
TODO.md
@@ -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)`
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
//
|
//
|
||||||
|
|||||||
377
tests/persistedEntryFloors.test.js
Normal file
377
tests/persistedEntryFloors.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
318
tests/support/popupBoot.js
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user