The popup's saveState() and the background's updateState() each read the stored record, change it and write it back, and each was 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, shared by every page and the background) from the read to the write, through withLock() in src/shared/browserApi.js; the recovery screen's reset takes it too. 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
79 lines
3.1 KiB
JavaScript
79 lines
3.1 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 one 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 extension's pages and its
|
|
// background 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("a save that fails holding the lock lets it go", async () => {
|
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
const storage = makeStorageStub({ autistmask: { networkId: "sepolia" } });
|
|
const { popup, background, applyChainSwitchFields } = loadContexts(storage);
|
|
await popup.loadState();
|
|
|
|
storage.local.set.mockRejectedValueOnce(new Error("quota exceeded"));
|
|
await expect(popup.saveState()).rejects.toThrow("quota exceeded");
|
|
|
|
await background.updateState((fresh) =>
|
|
applyChainSwitchFields(fresh, "mainnet"),
|
|
);
|
|
expect(storage.read("autistmask").networkId).toBe("mainnet");
|
|
});
|