Five defects traced to one fact: src/background/index.js read and wrote the module-level `state` singleton in src/shared/state.js, which the MV3 service worker never populates and which answered an unpopulated read out of DEFAULT_STATE in silence. Every previous fix added a loadState() before the access, and that is what produced the fifth: a load detaches the objects an in-flight handler is holding. So the reachability goes rather than a sixth call site. The background now has its own storage layer, src/background/state.js: getState() is a detached, normalized per-call read, and updateState() is a queued read-modify-write whose read is one storage round trip ahead of its write. Nothing in the background holds an in-memory copy of the profile. - Every handler takes one snapshot and answers from it, including the address it names: activeAddressOf(s) replaced a second, later storage read that could disagree with the first. - wallet_switchEthereumChain applies applyChainSwitchFields() (split out of chainSwitch.js, which keeps the singleton path for the popup) inside updateState() instead of calling onChainSwitch() on the singleton. - The remembered site decision is a read-modify-write, not a load-mutate-save around a prompt the user takes seconds to answer. - backgroundRefresh() refreshes a private copy of the wallets and applies the balances that came back by address, so it never publishes an object other in-flight work holds, and a wallet added or deleted during the round trip survives its write. - The transaction attempt takes its chain id and its endpoint from the same snapshot. They used to come from different moments, so a chain switch committed in between moved the endpoint under an artifact already verified against the old chain. getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it against networks.js. That closes the cold-worker wrong-chain send at its shape rather than at one call site: the hint used to default to currentNetwork() off the unpopulated singleton, so the endpoint was the user's chain and ethers fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses() and resolveEnsName() carry the id through; balances.js no longer requires state.js at all. The prohibition is enforced mechanically, not by review: a custom ESLint rule walks the CommonJS require graph from every src/background/ file and fails the lint when src/shared/state.js is reachable, naming the chain. A re-export from any shared module cannot put the singleton back in the bundle unnoticed. Reading a persisted field of the singleton before any load now throws StateNotLoadedError instead of serving DEFAULT_STATE. Test stubs: chrome.storage.local is a serialization boundary, and eight files stubbed it with an aliasing get, so the object a module held and the object "storage" held were one object — an assertion could pass on a build that never wrote anything. They all go through tests/support/storageStub.js now, which structured-clones in both directions. closes #320
118 lines
4.4 KiB
JavaScript
118 lines
4.4 KiB
JavaScript
// Tests for the UTC Timestamps setting.
|
|
//
|
|
// The checkbox was moved out of the Token Spam Protection well and into the
|
|
// Display well next to the theme selector. It is wired by id through the $()
|
|
// helper, so the move cannot break the handler — but nothing in the suite said
|
|
// so. These tests pin both halves down: the markup lives in Display and
|
|
// nowhere else, and the value still round-trips through storage.
|
|
|
|
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",
|
|
);
|
|
|
|
// The body of one `<div class="bg-well ...">` well, selected by its heading.
|
|
function wellWithHeading(html, heading) {
|
|
const headingIndex = html.indexOf(
|
|
'<h3 class="font-bold mb-1">' + heading + "</h3>",
|
|
);
|
|
expect(headingIndex).toBeGreaterThan(-1);
|
|
const start = html.lastIndexOf('<div class="bg-well', headingIndex);
|
|
const end = html.indexOf('<div class="bg-well', headingIndex);
|
|
return html.slice(start, end === -1 ? html.length : end);
|
|
}
|
|
|
|
describe("the UTC Timestamps checkbox placement", () => {
|
|
test("the checkbox appears exactly once in the popup markup", () => {
|
|
const matches = POPUP_HTML.match(/id="settings-utc-timestamps"/g);
|
|
expect(matches).toHaveLength(1);
|
|
});
|
|
|
|
test("it renders in the Display well, alongside the theme selector", () => {
|
|
const display = wellWithHeading(POPUP_HTML, "Display");
|
|
|
|
expect(display).toContain('id="settings-utc-timestamps"');
|
|
expect(display).toContain('id="settings-theme"');
|
|
});
|
|
|
|
test("it does not render in the Token Spam Protection well", () => {
|
|
const spam = wellWithHeading(POPUP_HTML, "Token Spam Protection");
|
|
|
|
expect(spam).not.toContain('id="settings-utc-timestamps"');
|
|
// The filters that do belong there are untouched.
|
|
expect(spam).toContain('id="settings-hide-low-holders"');
|
|
expect(spam).toContain('id="settings-hide-fraud-contracts"');
|
|
expect(spam).toContain('id="settings-hide-dust"');
|
|
expect(spam).toContain('id="settings-dust-threshold"');
|
|
});
|
|
});
|
|
|
|
describe("the UTC Timestamps setting round-trips through storage", () => {
|
|
let storage;
|
|
|
|
// The stub clones in both directions, as the real chrome.storage.local
|
|
// does. It used to alias, which is fatal to a round-trip test in
|
|
// particular: the object the module holds and the object "storage" holds
|
|
// are then the same object, so the setting appears to have been persisted
|
|
// and read back on a build where neither happened. See
|
|
// tests/support/storageStub.js.
|
|
function loadStateModule() {
|
|
storage = makeStorageStub();
|
|
global.chrome = { storage };
|
|
jest.resetModules();
|
|
return require("../src/shared/state");
|
|
}
|
|
|
|
afterEach(() => {
|
|
delete global.chrome;
|
|
});
|
|
|
|
test("defaults to off with nothing persisted", async () => {
|
|
const { state, loadState } = loadStateModule();
|
|
|
|
await loadState();
|
|
|
|
expect(state.utcTimestamps).toBe(false);
|
|
});
|
|
|
|
test("an enabled checkbox is persisted and read back", async () => {
|
|
const first = loadStateModule();
|
|
|
|
// What the change handler in views/settings.js does.
|
|
first.state.utcTimestamps = true;
|
|
await first.saveState();
|
|
expect(storage.read("autistmask").utcTimestamps).toBe(true);
|
|
|
|
// A fresh popup load sees it — and, before that load, refuses to
|
|
// answer at all rather than reporting the default. That refusal is
|
|
// what makes the assertion below evidence of a read from storage
|
|
// instead of a value that was already sitting in memory
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
|
|
jest.resetModules();
|
|
const second = require("../src/shared/state");
|
|
expect(() => second.state.utcTimestamps).toThrow(
|
|
second.StateNotLoadedError,
|
|
);
|
|
await second.loadState();
|
|
expect(second.state.utcTimestamps).toBe(true);
|
|
});
|
|
|
|
test("turning it back off is persisted too", async () => {
|
|
const { state, saveState, loadState } = loadStateModule();
|
|
|
|
state.utcTimestamps = true;
|
|
await saveState();
|
|
state.utcTimestamps = false;
|
|
await saveState();
|
|
|
|
state.utcTimestamps = true;
|
|
await loadState();
|
|
expect(state.utcTimestamps).toBe(false);
|
|
});
|
|
});
|