fix: floor the persisted fields a restore dereferences, and make each field's floor an executable claim (closes #362)
A persisted container was checked while its ENTRIES were dereferenced
unchecked. A stored `{"0x…": "notalist"}` in allowedSites passes the state
gate, renders a working popup, and then throws inside saveState()'s per-
hostname merge, so every save from that moment on fails while the UI looks
entirely healthy. deniedSites has the identical shape; fraudContracts is the
same class with a milder consequence.
The sweep for that class found four more:
- selectedToken, dereferenced as text behind a truthiness-only restore gate.
- rpcUrl, handed whole to `new JsonRpcProvider()` by getProvider(), which
throws SYNCHRONOUSLY for a non-string — from txStatus.js and addWallet.js,
neither inside a try, and the first reachable from a stored
`currentView: "wait-tx"` through the unguarded restoreView().
- The ENTRIES of viewData. Four restore branches gate on one truthy field and
hand the rest to a renderer that calls address.toLowerCase(): a stored
`{"currentView":"success-tx","viewData":{"hash":"0x1"}}` throws out of
restoreView(), skipping the rest of popup init.
- selectedWallet / selectedAddress. `wallets` is a real Array, so a stored
"map", "length", "constructor" or "__proto__" is TRUTHY: hasValidAddress()'s
`&&` does not short-circuit and `.addresses[…]` throws. A stale INTEGER index
is the safe case.
Floors, in src/shared/persistedState.js: allowedSites/deniedSites through
siteMap(), fraudContracts and each hostname list through textList(),
selectedToken and activeAddress as text-or-null, rpcUrl and blockscoutUrl as
non-empty text, selectedWallet and selectedAddress as a non-negative integer
or null, and each networkEndpoints pair's two URL fields — which
applyChainSwitchFields() assigns straight onto s.rpcUrl on the next switch.
Guards, in src/popup/viewRouter.js: the four restore branches that gate on one
truthy field now check the entries their renderer dereferences, as
txStatus.restoreWait() has always done for wait-tx. "confirm-tx" joins
ADDRESS_VIEWS, because its Sign button dereferences
state.wallets[state.selectedWallet] behind no guard of its own.
A stored own "__proto__" key is dropped by siteMap(): it can never be a wallet
address, so it grants nothing, and keeping it only keeps a value the next save
would hand to the prototype setter. networkEndpoints keeps unknown keys by
design, so mergeMapByKey() in src/shared/state.js now writes with
defineProperty as well — the guard in the floor was being undone one layer
downstream.
A save that fails is also told, not merely repaired: onSaveFailure() reports
every failed save, awaited or not (the save queue's own rejection handler is
what made a failure vanish), and the popup raises a persistent "NOT SAVED"
banner naming the reason. doRefreshAndRender() no longer rejects, since every
one of its call sites fires it and walks away.
The per-field justification in the header of src/shared/stateSchema.js is
replaced by tests/persistedFieldContract.test.js. That comment shipped a false
claim in three consecutive changes; the artifact was the problem. The test is
one row per persisted field, declaring the property that field's floor is
claimed to have and PROVING it by driving the real code with hostile values —
the gate for a field the gate refuses, normalizePersisted() for a field it
floors, the real JsonRpcProvider constructor for rpcUrl, and a boot of the real
popup entry point for every field whose only defence is that nothing
dereferences it structurally. A field added to PERSISTED_FIELDS with no row
fails the suite; so does a row whose claim is false. The header and the README
mirror now point at it instead of restating it.
This commit is contained in:
@@ -153,7 +153,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
||||
|
||||
test("Back onto the transaction detail renders it", () => {
|
||||
reopenedOn("settings", ["main", "transaction"], {
|
||||
viewData: { tx: { hash: "0xdead" } },
|
||||
viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["transactionDetail"]);
|
||||
@@ -162,7 +162,14 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
||||
|
||||
test("Back onto the transaction confirmation restores it", () => {
|
||||
reopenedOn("settings", ["main", "confirm-tx"], {
|
||||
viewData: { pendingTx: { to: ADDRESS, amount: "1" } },
|
||||
viewData: {
|
||||
pendingTx: {
|
||||
token: "ETH",
|
||||
from: ADDRESS,
|
||||
to: ADDRESS,
|
||||
amount: "1",
|
||||
},
|
||||
},
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["confirmTx"]);
|
||||
@@ -171,7 +178,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
||||
|
||||
test("Back onto the success screen renders it", () => {
|
||||
reopenedOn("settings", ["main", "success-tx"], {
|
||||
viewData: { hash: "0xdead" },
|
||||
viewData: { hash: "0xdead", to: ADDRESS },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["successTx"]);
|
||||
@@ -180,7 +187,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
||||
|
||||
test("Back onto the failure screen renders it", () => {
|
||||
reopenedOn("settings", ["main", "error-tx"], {
|
||||
viewData: { message: "execution reverted" },
|
||||
viewData: { message: "execution reverted", to: ADDRESS },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["errorTx"]);
|
||||
|
||||
404
tests/persistedEntryFloors.test.js
Normal file
404
tests/persistedEntryFloors.test.js
Normal file
@@ -0,0 +1,404 @@
|
||||
// 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} is dropped`, () => {
|
||||
// JSON can carry the key. It can never be a wallet address, so it
|
||||
// grants and denies nothing and goes the way of every other key
|
||||
// whose value is unusable; keeping it would only keep a value that
|
||||
// saveState()'s merge hands to the prototype setter on the next
|
||||
// write.
|
||||
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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('a stored own "__proto__" key surviving a save', () => {
|
||||
// The floor writes map keys with defineProperty; saveState()'s merge is one
|
||||
// layer downstream of it and used to write them with plain assignment,
|
||||
// which hands "__proto__" to the prototype setter and records no entry.
|
||||
// networkEndpoints is where a key that is not a known id is deliberately
|
||||
// KEPT, so it is where that undoing shows.
|
||||
test("does not move a prototype or vanish from networkEndpoints", async () => {
|
||||
const profile = unversionedValidProfile();
|
||||
profile.networkEndpoints = JSON.parse(
|
||||
'{"__proto__":{"rpcUrl":"https://kept.invalid"}}',
|
||||
);
|
||||
const storage = makeStorageStub({ autistmask: profile });
|
||||
const { state, loadState, saveState } = loadStateModule(storage);
|
||||
|
||||
await loadState();
|
||||
state.theme = "dark";
|
||||
await saveState();
|
||||
|
||||
// Not compared against Object.prototype by identity: the storage stub
|
||||
// clones through structuredClone, which builds the result in the host
|
||||
// realm, so the two Object.prototypes are different objects.
|
||||
const stored = storage.read("autistmask").networkEndpoints;
|
||||
expect(Object.getPrototypeOf(stored).rpcUrl).toBeUndefined();
|
||||
expect(Object.keys(stored)).toContain("__proto__");
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
570
tests/persistedFieldContract.test.js
Normal file
570
tests/persistedFieldContract.test.js
Normal file
@@ -0,0 +1,570 @@
|
||||
// What the floor under each persisted field actually guarantees — as a table
|
||||
// that RUNS, one row per field.
|
||||
//
|
||||
// This file replaces a hand-written per-field justification in the header of
|
||||
// src/shared/stateSchema.js. That comment shipped a false claim in three
|
||||
// consecutive changes: every author wrote plausible prose about thirty fields,
|
||||
// every reviewer re-derived it by hand, and it kept being wrong in a different
|
||||
// place each time. The artifact was the problem. A claim nobody can execute is
|
||||
// worse than no claim, because it is believed.
|
||||
//
|
||||
// So the claim is a row here instead:
|
||||
//
|
||||
// KIND.REFUSED assertStateUsable() refuses the record outright. Proven by
|
||||
// stateProblem() naming a problem for every hostile value.
|
||||
// KIND.ENTRIES normalizePersisted() floors the container AND its entries.
|
||||
// Proven by holds() over the normalized value.
|
||||
// KIND.SCALAR normalizePersisted() floors it to one scalar type, or to a
|
||||
// fixed fallback. Proven the same way.
|
||||
// KIND.LOOSE `saved.x || default`, no type check at all. The claim is
|
||||
// that no structural dereference of it is reachable from a
|
||||
// stored record — which cannot be argued, only driven, so the
|
||||
// proof is a boot of the REAL popup entry point over a stored
|
||||
// record carrying the hostile value.
|
||||
//
|
||||
// Every row is driven through the boot regardless of kind, and a LOOSE row
|
||||
// must additionally prove it is loose: if someone floors the field and leaves
|
||||
// the row saying LOOSE, the "survives verbatim" assertion fails. A field added
|
||||
// to PERSISTED_FIELDS with no row fails the first test in the file.
|
||||
//
|
||||
// The three claims this replaced, all false, all caught here by construction:
|
||||
// rpcUrl reaching `new JsonRpcProvider()` (a synchronous throw, not a caught
|
||||
// request); viewData's ENTRIES being dereferenced by four restore branches
|
||||
// that gate on one truthy field each; and selectedWallet, where a stale
|
||||
// integer index is the SAFE case and `wallets["map"]` is the throwing one.
|
||||
|
||||
const {
|
||||
PERSISTED_FIELDS,
|
||||
normalizePersisted,
|
||||
} = require("../src/shared/persistedState");
|
||||
const { stateProblem } = require("../src/shared/stateSchema");
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
unversionedValidProfile,
|
||||
ADDRESS,
|
||||
TOKEN_ADDRESS,
|
||||
} = require("./support/popupBoot");
|
||||
|
||||
const KIND = {
|
||||
REFUSED: "refused by the gate",
|
||||
ENTRIES: "container and entries type-checked",
|
||||
SCALAR: "scalar type-checked",
|
||||
LOOSE: "loosely floored; safety proven by driving the popup",
|
||||
};
|
||||
|
||||
const isText = (v) => typeof v === "string";
|
||||
const isRecord = (v) =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const isIndexOrNull = (v) => v === null || (Number.isInteger(v) && v >= 0);
|
||||
const isTextOrNull = (v) => v === null || (isText(v) && v !== "");
|
||||
const everyEntry = (v, fn) => Array.isArray(v) && v.every(fn);
|
||||
|
||||
// ------------------------------------------------------------------ the table
|
||||
//
|
||||
// `hostile` is values a stored record can carry that nothing in src/ ever
|
||||
// writes. Each one is driven through the floor AND through a real popup boot,
|
||||
// so keep the list short and pointed. `floorOnly` is extra values checked
|
||||
// against the floor alone, which is pure and free.
|
||||
|
||||
const CONTRACT = [
|
||||
{
|
||||
field: "wallets",
|
||||
kind: KIND.REFUSED,
|
||||
hostile: [42, "notastructure", { a: 1 }, [null], [{ addresses: 1 }]],
|
||||
},
|
||||
{
|
||||
field: "networkId",
|
||||
kind: KIND.REFUSED,
|
||||
hostile: [42, "notanetwork", { a: 1 }, "__proto__"],
|
||||
},
|
||||
{
|
||||
field: "trackedTokens",
|
||||
kind: KIND.ENTRIES,
|
||||
hostile: [42, "notalist", { a: 1 }],
|
||||
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
|
||||
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
|
||||
},
|
||||
{
|
||||
field: "allowedSites",
|
||||
kind: KIND.ENTRIES,
|
||||
hostile: [42, "notarecord", { [ADDRESS]: "notalist" }],
|
||||
floorOnly: [
|
||||
[ADDRESS],
|
||||
{ [ADDRESS]: 42 },
|
||||
{ [ADDRESS]: [42, null, {}] },
|
||||
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
||||
],
|
||||
holds: siteMapHolds,
|
||||
},
|
||||
{
|
||||
field: "deniedSites",
|
||||
kind: KIND.ENTRIES,
|
||||
hostile: [42, "notarecord", { [ADDRESS]: "notalist" }],
|
||||
floorOnly: [
|
||||
[ADDRESS],
|
||||
{ [ADDRESS]: 42 },
|
||||
{ [ADDRESS]: [42, null, {}] },
|
||||
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
||||
],
|
||||
holds: siteMapHolds,
|
||||
},
|
||||
{
|
||||
field: "fraudContracts",
|
||||
kind: KIND.ENTRIES,
|
||||
hostile: [42, "notalist", { a: 1 }],
|
||||
floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]],
|
||||
holds: (v) => everyEntry(v, isText),
|
||||
},
|
||||
{
|
||||
field: "viewStack",
|
||||
kind: KIND.ENTRIES,
|
||||
hostile: [42, "notalist", ["main", "show-phrase", "settings"]],
|
||||
floorOnly: [[1, 2], [null], [{}], ["export-privkey"]],
|
||||
// Truncated at the first entry the popup will not reopen onto, rather
|
||||
// than filtered: every surviving entry's Back target has to stay the
|
||||
// one it had. restorableStack() may also substitute ["main"] under a
|
||||
// view restored below the root, so this is the one ENTRIES field whose
|
||||
// result is not always a subset of what was stored.
|
||||
holds: (v) => everyEntry(v, (e) => RESTORABLE_VIEWS.has(e)),
|
||||
},
|
||||
{
|
||||
field: "networkEndpoints",
|
||||
kind: KIND.ENTRIES,
|
||||
hostile: [42, "notarecord", { mainnet: "notapair" }],
|
||||
floorOnly: [
|
||||
[1, 2],
|
||||
{ mainnet: { rpcUrl: 42, blockscoutUrl: {} } },
|
||||
{ mainnet: { rpcUrl: "", blockscoutUrl: [] } },
|
||||
{ sepolia: 42 },
|
||||
],
|
||||
// Entries are coerced rather than dropped: an unknown network id is
|
||||
// KEPT, so a profile that has been on a build with more networks does
|
||||
// not lose their endpoints here. What is floored is the two URL fields
|
||||
// inside the pair, which applyChainSwitchFields() assigns straight onto
|
||||
// s.rpcUrl / s.blockscoutUrl on the next switch.
|
||||
holds: (v) =>
|
||||
isRecord(v) &&
|
||||
Object.keys(v).every((id) => {
|
||||
const pair = v[id];
|
||||
return (
|
||||
isRecord(pair) &&
|
||||
(pair.rpcUrl === undefined ||
|
||||
(isText(pair.rpcUrl) && pair.rpcUrl !== "")) &&
|
||||
(pair.blockscoutUrl === undefined ||
|
||||
(isText(pair.blockscoutUrl) &&
|
||||
pair.blockscoutUrl !== ""))
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
field: "rpcUrl",
|
||||
kind: KIND.SCALAR,
|
||||
hostile: [42, true, { a: 1 }],
|
||||
floorOnly: [[], "", null],
|
||||
holds: (v) => isText(v) && v !== "",
|
||||
// The claim this row replaced said a bad value "fails the request on a
|
||||
// path that already catches". It does not: getProvider() hands rpcUrl
|
||||
// to `new JsonRpcProvider()`, which throws SYNCHRONOUSLY, from two call
|
||||
// sites outside any try — and a stored `currentView: "wait-tx"` reaches
|
||||
// one of them through restoreView(). So the row proves the claim
|
||||
// against the real constructor rather than describing it.
|
||||
alsoProven: (normalized, hostile) => {
|
||||
// requireActual: bootPopup() mocks this module out for the boots
|
||||
// above, and a mocked getProvider() would prove nothing at all
|
||||
// about the constructor this row is a claim about.
|
||||
const { getProvider } = jest.requireActual(
|
||||
"../src/shared/balances",
|
||||
);
|
||||
expect(() => getProvider(hostile, "mainnet")).toThrow();
|
||||
const provider = getProvider(normalized, "mainnet");
|
||||
expect(provider).toBeTruthy();
|
||||
provider.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "blockscoutUrl",
|
||||
kind: KIND.SCALAR,
|
||||
hostile: [42, true, { a: 1 }],
|
||||
floorOnly: [[], "", null],
|
||||
holds: (v) => isText(v) && v !== "",
|
||||
},
|
||||
{
|
||||
field: "activeAddress",
|
||||
kind: KIND.SCALAR,
|
||||
hostile: [42, true, { a: 1 }],
|
||||
floorOnly: [[ADDRESS], ""],
|
||||
holds: isTextOrNull,
|
||||
},
|
||||
{
|
||||
field: "selectedToken",
|
||||
kind: KIND.SCALAR,
|
||||
hostile: [42, true, { a: 1 }],
|
||||
floorOnly: [[TOKEN_ADDRESS], ""],
|
||||
holds: isTextOrNull,
|
||||
},
|
||||
{
|
||||
field: "selectedWallet",
|
||||
kind: KIND.SCALAR,
|
||||
// The prototype members are the whole point: `wallets["map"]` is
|
||||
// TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and
|
||||
// `.addresses[…]` throws. A stale INTEGER is the safe case.
|
||||
hostile: ["map", "__proto__", { a: 1 }],
|
||||
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
||||
holds: isIndexOrNull,
|
||||
},
|
||||
{
|
||||
field: "selectedAddress",
|
||||
kind: KIND.SCALAR,
|
||||
hostile: ["map", "__proto__", { a: 1 }],
|
||||
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
||||
holds: isIndexOrNull,
|
||||
},
|
||||
{
|
||||
field: "currentView",
|
||||
kind: KIND.LOOSE,
|
||||
// Compared, and concatenated into the debug banner's textContent
|
||||
// (src/popup/views/helpers.js) with no gate in front of it, which
|
||||
// coerces. Nothing renders FROM it without RESTORABLE_VIEWS.has()
|
||||
// first, and Set.has() answers false for any value.
|
||||
hostile: [42, "no-such-view", { a: 1 }],
|
||||
},
|
||||
{
|
||||
field: "viewData",
|
||||
kind: KIND.LOOSE,
|
||||
// The container is taken verbatim; what makes its ENTRIES safe is the
|
||||
// per-branch guard in src/popup/viewRouter.js. Driven over every
|
||||
// restorable view in "a malformed viewData" below, which is the proof
|
||||
// this row rests on.
|
||||
hostile: [42, "notarecord", { a: 1 }],
|
||||
},
|
||||
{
|
||||
field: "lastBalanceRefresh",
|
||||
kind: KIND.LOOSE,
|
||||
// Arithmetic only: `now - (s.lastBalanceRefresh || 0)` compares false
|
||||
// for a non-number and forces a refresh.
|
||||
hostile: [true, "notatime", { a: 1 }],
|
||||
},
|
||||
{
|
||||
field: "tokenHolderCache",
|
||||
kind: KIND.LOOSE,
|
||||
// Nothing DEREFERENCES it structurally. It is read by the
|
||||
// field-agnostic snapshotPersisted()/deepEqual() in
|
||||
// src/shared/state.js, which are safe for any value, and otherwise
|
||||
// only reset wholesale in src/shared/chainSwitchFields.js.
|
||||
hostile: [42, "notarecord", [1, 2]],
|
||||
},
|
||||
{
|
||||
field: "theme",
|
||||
kind: KIND.LOOSE,
|
||||
// Compared against "dark"/"light" in applyTheme() and otherwise falls
|
||||
// to the system branch; assigned into an input .value, which coerces.
|
||||
hostile: [42, "chartreuse", { a: 1 }],
|
||||
},
|
||||
{
|
||||
field: "dustThresholdGwei",
|
||||
kind: KIND.LOOSE,
|
||||
hostile: ["notanumber", true, { a: 1 }],
|
||||
},
|
||||
...[
|
||||
"rememberSiteChoice",
|
||||
"showZeroBalanceTokens",
|
||||
"hideSpoofedSymbols",
|
||||
"hideLowHolderTokens",
|
||||
"hideFraudContracts",
|
||||
"hideDustTransactions",
|
||||
"utcTimestamps",
|
||||
"debugMode",
|
||||
].map((field) => ({
|
||||
field,
|
||||
kind: KIND.LOOSE,
|
||||
// A flag: only ever tested for truthiness, and written back verbatim.
|
||||
hostile: [42, "notabool", { a: 1 }],
|
||||
})),
|
||||
];
|
||||
|
||||
function siteMapHolds(v) {
|
||||
return (
|
||||
isRecord(v) &&
|
||||
Object.getPrototypeOf(v) === Object.prototype &&
|
||||
!Object.prototype.hasOwnProperty.call(v, "__proto__") &&
|
||||
Object.keys(v).every((key) => everyEntry(v[key], isText))
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupPopup();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- exhaustive
|
||||
|
||||
describe("the contract covers the record", () => {
|
||||
test("every persisted field has exactly one row, and no row invents one", () => {
|
||||
const rows = CONTRACT.map((row) => row.field);
|
||||
|
||||
expect([...rows].sort()).toEqual([...PERSISTED_FIELDS].sort());
|
||||
});
|
||||
|
||||
test("every row declares a kind this file knows how to prove", () => {
|
||||
const kinds = Object.values(KIND);
|
||||
for (const row of CONTRACT) {
|
||||
expect(kinds).toContain(row.kind);
|
||||
expect(row.hostile.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- the floors
|
||||
|
||||
function profileWith(field, value) {
|
||||
return unversionedValidProfile({ [field]: value });
|
||||
}
|
||||
|
||||
describe("the floor each row claims", () => {
|
||||
for (const row of CONTRACT) {
|
||||
const values = [...row.hostile, ...(row.floorOnly || [])];
|
||||
|
||||
if (row.kind === KIND.REFUSED) {
|
||||
test(`${row.field}: the gate refuses it`, () => {
|
||||
for (const value of values) {
|
||||
expect(
|
||||
typeof stateProblem(profileWith(row.field, value)),
|
||||
).toBe("string");
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
test(`${row.field}: ${row.kind}`, () => {
|
||||
for (const value of values) {
|
||||
const out = normalizePersisted(profileWith(row.field, value));
|
||||
if (row.kind === KIND.LOOSE) {
|
||||
// The claim IS that there is no floor. A field that grows
|
||||
// one has to move to another kind rather than keep a row
|
||||
// saying its readers are what make it safe.
|
||||
continue;
|
||||
}
|
||||
expect({
|
||||
value: value,
|
||||
holds: row.holds(out[row.field]),
|
||||
}).toEqual({ value: value, holds: true });
|
||||
}
|
||||
});
|
||||
|
||||
if (row.kind === KIND.LOOSE) {
|
||||
test(`${row.field}: is genuinely unfloored`, () => {
|
||||
const survived = values.some((value) => {
|
||||
const out = normalizePersisted(
|
||||
profileWith(row.field, value),
|
||||
);
|
||||
return (
|
||||
JSON.stringify(out[row.field]) === JSON.stringify(value)
|
||||
);
|
||||
});
|
||||
|
||||
expect(survived).toBe(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------- driving the real popup boot
|
||||
|
||||
// A booted popup is healthy when nothing threw out of init() and something is
|
||||
// on screen. A throw out of restoreView() is neither: init() does not guard it,
|
||||
// so the rest of popup init never runs and the user gets a popup with no view,
|
||||
// no message and no control on it.
|
||||
async function bootHealth(profile) {
|
||||
const env = await bootPopup(profile);
|
||||
return {
|
||||
errors: env.pageErrors,
|
||||
blank: env.visibleViews().length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
const HEALTHY = { errors: [], blank: false };
|
||||
|
||||
describe("a hostile value for one field, through the real popup", () => {
|
||||
for (const row of CONTRACT) {
|
||||
for (const value of row.hostile) {
|
||||
test(`${row.field} = ${JSON.stringify(value)}`, async () => {
|
||||
await expect(
|
||||
bootHealth(profileWith(row.field, value)),
|
||||
).resolves.toEqual(HEALTHY);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("a row's extra proof against the real reader", () => {
|
||||
for (const row of CONTRACT) {
|
||||
if (!row.alsoProven) continue;
|
||||
test(row.field, () => {
|
||||
for (const value of row.hostile) {
|
||||
const out = normalizePersisted(profileWith(row.field, value));
|
||||
row.alsoProven(out[row.field], value);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------- viewData, entry by entry
|
||||
|
||||
// The views that read viewData.
|
||||
const DATA_VIEWS = [
|
||||
"confirm-tx",
|
||||
"transaction",
|
||||
"wait-tx",
|
||||
"success-tx",
|
||||
"error-tx",
|
||||
];
|
||||
|
||||
// Each record below PASSES the gate of the branch it names, and then carries a
|
||||
// value that branch's renderer dereferences. `views` is where it is driven from
|
||||
// — the whole set for a value that is not a record at all, and otherwise the
|
||||
// branch it targets, since the cross-view case is covered by EVERY_GATE below.
|
||||
const HOSTILE_VIEW_DATA = [
|
||||
{ data: 42, views: DATA_VIEWS },
|
||||
{ data: "notarecord", views: DATA_VIEWS },
|
||||
{ data: [1, 2], views: DATA_VIEWS },
|
||||
// success-tx passes on `data.hash`, and renderSuccess() then calls
|
||||
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
|
||||
{ data: { hash: "0x1" }, views: ["success-tx"] },
|
||||
{ data: { hash: "0x1", to: 42 }, views: ["success-tx"] },
|
||||
{
|
||||
data: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
|
||||
views: ["success-tx"],
|
||||
},
|
||||
{
|
||||
data: {
|
||||
hash: "0x1",
|
||||
to: ADDRESS,
|
||||
decoded: { details: [{ address: 42 }] },
|
||||
},
|
||||
views: ["success-tx"],
|
||||
},
|
||||
// error-tx passes on `data.message`, same dereference.
|
||||
{ data: { message: "boom" }, views: ["error-tx"] },
|
||||
{ data: { message: "boom", to: 42 }, views: ["error-tx"] },
|
||||
// transaction passes on `data.tx`.
|
||||
{ data: { tx: { hash: "0x1" } }, views: ["transaction"] },
|
||||
{
|
||||
data: {
|
||||
tx: {
|
||||
hash: "0x1",
|
||||
from: ADDRESS,
|
||||
to: ADDRESS,
|
||||
contractAddress: 42,
|
||||
},
|
||||
},
|
||||
views: ["transaction"],
|
||||
},
|
||||
// confirm-tx passes on `data.pendingTx`.
|
||||
{ data: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] },
|
||||
{
|
||||
data: {
|
||||
pendingTx: { token: 42, from: ADDRESS, to: ADDRESS, amount: "1" },
|
||||
},
|
||||
views: ["confirm-tx"],
|
||||
},
|
||||
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked the
|
||||
// fields below it since it was written, and this is the regression guard.
|
||||
{
|
||||
data: { pendingWait: { hash: "0x1", txInfo: { to: 42, amount: "1" } } },
|
||||
views: ["wait-tx"],
|
||||
},
|
||||
];
|
||||
|
||||
function restoringOnto(view, extra) {
|
||||
return unversionedValidProfile({
|
||||
currentView: view,
|
||||
selectedWallet: 0,
|
||||
selectedAddress: 0,
|
||||
selectedToken: TOKEN_ADDRESS,
|
||||
viewStack: ["main"],
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
describe("a malformed viewData restoring onto", () => {
|
||||
for (const { data, views } of HOSTILE_VIEW_DATA) {
|
||||
for (const view of views) {
|
||||
test(`${view}: ${JSON.stringify(data)}`, async () => {
|
||||
await expect(
|
||||
bootHealth(restoringOnto(view, { viewData: data })),
|
||||
).resolves.toEqual(HEALTHY);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Every restorable view, against one record that passes every branch's
|
||||
// gate at once: a branch a view does not read must stay one it does not
|
||||
// read, and each renderer must survive the fields another branch left.
|
||||
const EVERY_GATE = {
|
||||
hash: "0x1",
|
||||
message: "boom",
|
||||
tx: { hash: "0x1" },
|
||||
pendingTx: { amount: "1" },
|
||||
pendingWait: { hash: "0x1" },
|
||||
};
|
||||
for (const view of RESTORABLE_VIEWS) {
|
||||
test(`${view}: a record passing every branch's gate at once`, async () => {
|
||||
await expect(
|
||||
bootHealth(restoringOnto(view, { viewData: EVERY_GATE })),
|
||||
).resolves.toEqual(HEALTHY);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------- selectedWallet / selectedAddress
|
||||
|
||||
// `wallets` is a real Array, so a selectedWallet naming an Array.prototype or
|
||||
// Object.prototype member is TRUTHY: hasValidAddress()'s `&&` does not
|
||||
// short-circuit, `.addresses` is undefined, and the index access throws out of
|
||||
// restoreView(). A stale INTEGER is falsy-or-in-range and safe — the opposite
|
||||
// way round from how this pair was described.
|
||||
const HOSTILE_INDEX = [
|
||||
{ selectedWallet: "map", selectedAddress: 0 },
|
||||
{ selectedWallet: "length", selectedAddress: 0 },
|
||||
{ selectedWallet: "__proto__", selectedAddress: 0 },
|
||||
{ selectedWallet: "constructor", selectedAddress: 0 },
|
||||
{ selectedWallet: 0, selectedAddress: "map" },
|
||||
{ selectedWallet: 5, selectedAddress: 0 },
|
||||
];
|
||||
|
||||
const INDEX_VIEWS = [
|
||||
"address",
|
||||
"address-token",
|
||||
"receive",
|
||||
"transaction",
|
||||
"confirm-tx",
|
||||
];
|
||||
|
||||
describe("a malformed wallet or address index restoring onto", () => {
|
||||
const WELL_FORMED_DATA = {
|
||||
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS },
|
||||
pendingTx: {
|
||||
token: "ETH",
|
||||
from: ADDRESS,
|
||||
to: ADDRESS,
|
||||
amount: "1",
|
||||
balance: "2",
|
||||
},
|
||||
};
|
||||
|
||||
for (const view of INDEX_VIEWS) {
|
||||
for (const indices of HOSTILE_INDEX) {
|
||||
test(`${view}: ${JSON.stringify(indices)}`, async () => {
|
||||
await expect(
|
||||
bootHealth(
|
||||
restoringOnto(view, {
|
||||
...indices,
|
||||
viewData: WELL_FORMED_DATA,
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual(HEALTHY);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -35,6 +35,11 @@ const POPUP_HTML_PATH = path.join(POPUP_DIR, "index.html");
|
||||
const RUNTIME_CREATED_IDS = new Set([
|
||||
// Created by updateDebugBanner() in src/popup/views/helpers.js.
|
||||
"debug-banner",
|
||||
// Created by showSaveFailureBanner() in the same file, on the first save
|
||||
// that fails. Absent from the markup on purpose: a popup where nothing has
|
||||
// failed must not have to carry an empty banner
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362).
|
||||
"save-failure-banner",
|
||||
]);
|
||||
|
||||
// Every id lookup the popup performs with a literal argument, as
|
||||
|
||||
@@ -13,61 +13,26 @@
|
||||
// calls, is exactly the defect: what has to be true is that BOOTING the popup
|
||||
// on a bad blob lands on it.
|
||||
//
|
||||
// The DOM stub is built FROM src/popup/index.html — every id in the markup,
|
||||
// with the classes the markup gives it — so "which views are visible" is
|
||||
// answered against the real element set, and a recovery screen with no markup
|
||||
// behind it cannot pass.
|
||||
// The boot harness and its DOM stub — built FROM src/popup/index.html, so
|
||||
// "which views are visible" is answered against the real element set — live in
|
||||
// tests/support/popupBoot.js, since tests/persistedEntryFloors.test.js needs
|
||||
// the same boot.
|
||||
//
|
||||
// The fourth case is the upgrade one, and it is the case that must NOT reach
|
||||
// the recovery screen: every install in the field has a valid profile with no
|
||||
// version field, and showing those users a wipe prompt would be a worse defect
|
||||
// than the one being fixed. It is migrated in place and keeps working.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const POPUP_HTML = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Fixed address, never used for anything but these tests.
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
// A fixed ERC-20 contract address, same rule.
|
||||
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
unversionedValidProfile,
|
||||
ADDRESS,
|
||||
TOKEN_ADDRESS,
|
||||
} = require("./support/popupBoot");
|
||||
|
||||
// ------------------------------------------------------------- fixtures
|
||||
|
||||
// A profile in the shape every install in the field has it: complete, valid,
|
||||
// and carrying no version field, because no build ever wrote one.
|
||||
function unversionedValidProfile() {
|
||||
return {
|
||||
hasWallet: true,
|
||||
wallets: [
|
||||
{
|
||||
type: "hd",
|
||||
name: "Wallet 1",
|
||||
xpub: "xpub-wallet-1",
|
||||
encryptedSecret: "encrypted-secret-1",
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
activeAddress: ADDRESS,
|
||||
networkId: "mainnet",
|
||||
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
||||
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
||||
allowedSites: { [ADDRESS]: ["dapp.example"] },
|
||||
deniedSites: {},
|
||||
trackedTokens: [],
|
||||
theme: "system",
|
||||
};
|
||||
}
|
||||
|
||||
// The three blobs from the issue, each with the error it produced.
|
||||
const CORRUPT_BLOBS = [
|
||||
{
|
||||
@@ -103,235 +68,7 @@ const CORRUPT_BLOBS = [
|
||||
},
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------- DOM stub
|
||||
|
||||
function makeElement(id, className) {
|
||||
const classes = new Set(
|
||||
(className || "").split(/\s+/).filter((name) => name !== ""),
|
||||
);
|
||||
const el = {
|
||||
id,
|
||||
tagName: "DIV",
|
||||
textContent: "",
|
||||
value: "",
|
||||
innerHTML: "",
|
||||
href: "",
|
||||
download: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
listeners: {},
|
||||
clicked: 0,
|
||||
classList: {
|
||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||
contains: (n) => classes.has(n),
|
||||
toggle: (n, force) => {
|
||||
const on = force === undefined ? !classes.has(n) : force;
|
||||
if (on) classes.add(n);
|
||||
else classes.delete(n);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
addEventListener: (name, fn) => {
|
||||
el.listeners[name] = el.listeners[name] || [];
|
||||
el.listeners[name].push(fn);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
appendChild: () => {},
|
||||
remove: () => {},
|
||||
focus: () => {},
|
||||
select: () => {},
|
||||
setAttribute: (name, value) => {
|
||||
el[name] = value;
|
||||
},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
click: () => {
|
||||
el.clicked += 1;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
// Every id in the markup, with the classes the markup gives it. A view the
|
||||
// popup is supposed to reveal has to exist here, which means it has to exist
|
||||
// in src/popup/index.html.
|
||||
function idsFromHtml(html) {
|
||||
const out = new Map();
|
||||
const tags = html.match(/<[a-zA-Z][^>]*>/g) || [];
|
||||
for (const tag of tags) {
|
||||
const id = /\bid="([^"]+)"/.exec(tag);
|
||||
if (!id) continue;
|
||||
const cls = /\bclass="([^"]*)"/.exec(tag);
|
||||
out.set(id[1], cls ? cls[1] : "");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeDocument(html) {
|
||||
const authored = idsFromHtml(html);
|
||||
const els = new Map();
|
||||
for (const [id, className] of authored) {
|
||||
els.set(id, makeElement(id, className));
|
||||
}
|
||||
const created = [];
|
||||
const doc = {
|
||||
listeners: {},
|
||||
getElementById(id) {
|
||||
// Created on demand by updateDebugBanner(); absent is the state a
|
||||
// non-debug, non-testnet popup is in.
|
||||
if (id === "debug-banner") return null;
|
||||
if (!els.has(id)) els.set(id, makeElement(id, ""));
|
||||
return els.get(id);
|
||||
},
|
||||
createElement(tag) {
|
||||
const el = makeElement("created-" + tag, "");
|
||||
el.tagName = String(tag).toUpperCase();
|
||||
created.push(el);
|
||||
return el;
|
||||
},
|
||||
addEventListener(name, fn) {
|
||||
doc.listeners[name] = doc.listeners[name] || [];
|
||||
doc.listeners[name].push(fn);
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
documentElement: makeElement("html", ""),
|
||||
body: {
|
||||
prepend: () => {},
|
||||
appendChild: () => {},
|
||||
removeChild: () => {},
|
||||
},
|
||||
elements: els,
|
||||
authoredIds: authored,
|
||||
created,
|
||||
};
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- harness
|
||||
|
||||
// Boot the real popup entry point over `stored`, exactly as the browser does:
|
||||
// storage already holds the record, the page loads, DOMContentLoaded fires.
|
||||
async function bootPopup(stored) {
|
||||
jest.resetModules();
|
||||
|
||||
// The two modules that reach the network. Neither is on the path under
|
||||
// test; both would make this suite hit the internet.
|
||||
jest.doMock("../src/shared/prices", () => ({
|
||||
prices: {},
|
||||
refreshPrices: jest.fn(async () => {}),
|
||||
clearPrices: jest.fn(),
|
||||
getPrice: () => null,
|
||||
formatUsd: () => "",
|
||||
formatAddressTotal: () => "",
|
||||
getAddressValue: () => ({ usd: null, partial: false }),
|
||||
getWalletValue: () => ({ usd: null, partial: false }),
|
||||
getTotalValue: () => ({ usd: null, partial: false }),
|
||||
}));
|
||||
jest.doMock("../src/shared/balances", () => ({
|
||||
fetchTokenBalances: jest.fn(async () => []),
|
||||
refreshBalances: jest.fn(async () => {}),
|
||||
lookupTokenInfo: jest.fn(async () => null),
|
||||
getProvider: () => ({}),
|
||||
scanForAddresses: jest.fn(async () => []),
|
||||
}));
|
||||
jest.doMock("../src/shared/transactions", () => ({
|
||||
fetchRecentTransactions: jest.fn(async () => []),
|
||||
filterTransactions: () => [],
|
||||
}));
|
||||
|
||||
const storage = makeStorageStub(
|
||||
stored === undefined ? {} : { autistmask: stored },
|
||||
);
|
||||
const document = makeDocument(POPUP_HTML);
|
||||
const reloads = [];
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: storage.local },
|
||||
runtime: {
|
||||
sendMessage: jest.fn(async () => ({})),
|
||||
getURL: (p) => "chrome-extension://autistmask/" + p,
|
||||
onMessage: { addListener: () => {} },
|
||||
},
|
||||
};
|
||||
globalThis.document = document;
|
||||
globalThis.window = {
|
||||
location: {
|
||||
search: "",
|
||||
href: "chrome-extension://autistmask/src/popup/index.html",
|
||||
reload: () => reloads.push(Date.now()),
|
||||
},
|
||||
matchMedia: () => ({
|
||||
matches: false,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}),
|
||||
addEventListener: () => {},
|
||||
};
|
||||
// The 10s refresh loop init() starts would outlive the test.
|
||||
const realSetInterval = globalThis.setInterval;
|
||||
globalThis.setInterval = () => 0;
|
||||
|
||||
require("../src/popup/index");
|
||||
|
||||
const booted = [];
|
||||
for (const fn of document.listeners.DOMContentLoaded || []) {
|
||||
booted.push(fn());
|
||||
}
|
||||
|
||||
// What the browser console would have shown. A throw out of init() is the
|
||||
// blank popup this issue is about, so it is captured rather than thrown:
|
||||
// the assertion that matters is what ended up on screen.
|
||||
const pageErrors = [];
|
||||
for (const p of booted) {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
pageErrors.push(String((e && e.message) || e));
|
||||
}
|
||||
}
|
||||
await settle();
|
||||
|
||||
globalThis.setInterval = realSetInterval;
|
||||
|
||||
return {
|
||||
storage,
|
||||
document,
|
||||
pageErrors,
|
||||
reloaded: () => reloads.length,
|
||||
node: (id) => document.getElementById(id),
|
||||
text: (id) => document.getElementById(id).textContent,
|
||||
value: (id) => document.getElementById(id).value,
|
||||
hidden: (id) =>
|
||||
document.getElementById(id).classList.contains("hidden"),
|
||||
click: async (id) => {
|
||||
const el = document.getElementById(id);
|
||||
const fns = el.listeners.click || [];
|
||||
for (const fn of fns) await fn();
|
||||
await settle();
|
||||
},
|
||||
// The view ids whose section is not hidden, as the audit measured them.
|
||||
visibleViews: () => {
|
||||
const out = [];
|
||||
for (const [id, el] of document.elements) {
|
||||
if (!id.startsWith("view-")) continue;
|
||||
if (!el.classList.contains("hidden")) out.push(id.slice(5));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.chrome;
|
||||
delete globalThis.document;
|
||||
delete globalThis.window;
|
||||
});
|
||||
afterEach(cleanupPopup);
|
||||
|
||||
// --------------------------------------------------------------- tests
|
||||
|
||||
|
||||
333
tests/support/popupBoot.js
Normal file
333
tests/support/popupBoot.js
Normal file
@@ -0,0 +1,333 @@
|
||||
// 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: () => [],
|
||||
// The Receive view draws its QR onto #receive-qr through the qrcode
|
||||
// package, which calls getContext("2d") and then createImageData/
|
||||
// putImageData on the result. Without this the render throws from
|
||||
// inside a promise the view does not await, which takes the whole node
|
||||
// process down rather than failing a test — so a suite that boots onto
|
||||
// Receive could not report anything.
|
||||
getContext: () => ({
|
||||
createImageData: (w, h) => ({
|
||||
width: w,
|
||||
height: h,
|
||||
data: new Uint8ClampedArray(w * h * 4),
|
||||
}),
|
||||
putImageData: () => {},
|
||||
clearRect: () => {},
|
||||
}),
|
||||
click: () => {
|
||||
el.clicked += 1;
|
||||
},
|
||||
};
|
||||
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