The popup's saveState() and the background's updateState() each read the stored record, change it and write it back, each ordered only within its own context, so a write by one could land between the other's read and write and be lost. Both now hold the browser's lock (navigator.locks) from the read to the write, through withLock() in src/shared/browserApi.js, and the recovery screen's reset takes it too. A page in a Firefox private window gets locks of its own and is not covered. The lock also orders a page's own saves, so both per-context queues are gone. The unit tests get a small lock from a Jest setup file. Model: opus-5-5
97 lines
3.8 KiB
JavaScript
97 lines
3.8 KiB
JavaScript
// A popup save reads the stored record, merges this page's changes into it
|
|
// and writes it back. The background's write of a network the user approved
|
|
// used to be able to land between that read and that write, and the popup's
|
|
// write then put the old network back
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every write of the record
|
|
// now holds a lock from its read to its write.
|
|
//
|
|
// The popup and the background run here as the real modules over one shared
|
|
// storage stub, and share the lock the way the background and a page outside
|
|
// a Firefox private window share the browser's
|
|
// (tests/support/navigatorLocks.js).
|
|
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
|
|
function loadContexts(storage) {
|
|
jest.resetModules();
|
|
globalThis.chrome = { storage: { local: storage.local } };
|
|
return {
|
|
popup: require("../src/shared/state"),
|
|
background: require("../src/background/state"),
|
|
applyChainSwitchFields: require("../src/shared/chainSwitchFields")
|
|
.applyChainSwitchFields,
|
|
};
|
|
}
|
|
|
|
// Lets every pending promise callback run, and then some.
|
|
function nextMacrotask() {
|
|
return new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
afterEach(() => {
|
|
delete globalThis.chrome;
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
test("a network the background switches to during a popup save stays stored", async () => {
|
|
const storage = makeStorageStub({ autistmask: { networkId: "sepolia" } });
|
|
const { popup, background, applyChainSwitchFields } = loadContexts(storage);
|
|
await popup.loadState();
|
|
|
|
// The popup navigates, so its save has a change of its own to write.
|
|
popup.state.currentView = "settings";
|
|
|
|
// The popup's save has read the record and is about to write it back when
|
|
// the user's approval of a site's switch to mainnet reaches the
|
|
// background. Without the lock the background's whole update runs before
|
|
// the next macrotask; with it, the update waits for the popup's write.
|
|
let backgroundSwitch;
|
|
const realSet = storage.local.set.getMockImplementation();
|
|
storage.local.set.mockImplementationOnce(async (items) => {
|
|
backgroundSwitch = background.updateState((fresh) =>
|
|
applyChainSwitchFields(fresh, "mainnet"),
|
|
);
|
|
await nextMacrotask();
|
|
return realSet(items);
|
|
});
|
|
|
|
await popup.saveState();
|
|
await backgroundSwitch;
|
|
|
|
const stored = storage.read("autistmask");
|
|
expect(stored.networkId).toBe("mainnet");
|
|
expect(stored.currentView).toBe("settings");
|
|
});
|
|
|
|
test("after a save that failed, the next save still keeps a network the background switches to during it", async () => {
|
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
const storage = makeStorageStub({ autistmask: { networkId: "sepolia" } });
|
|
const { popup, background, applyChainSwitchFields } = loadContexts(storage);
|
|
await popup.loadState();
|
|
popup.state.currentView = "settings";
|
|
|
|
// The first save's write is refused, so it gives the lock back having
|
|
// failed.
|
|
storage.local.set.mockRejectedValueOnce(new Error("quota exceeded"));
|
|
await expect(popup.saveState()).rejects.toThrow("quota exceeded");
|
|
|
|
// The next save is between its read and its write when the background
|
|
// switches to mainnet, as in the test above.
|
|
let backgroundSwitch;
|
|
const realSet = storage.local.set.getMockImplementation();
|
|
storage.local.set.mockImplementationOnce(async (items) => {
|
|
backgroundSwitch = background.updateState((fresh) =>
|
|
applyChainSwitchFields(fresh, "mainnet"),
|
|
);
|
|
await nextMacrotask();
|
|
return realSet(items);
|
|
});
|
|
|
|
await popup.saveState();
|
|
await backgroundSwitch;
|
|
|
|
const stored = storage.read("autistmask");
|
|
expect(stored.networkId).toBe("mainnet");
|
|
expect(stored.currentView).toBe("settings");
|
|
});
|