// A chrome.storage stub that behaves like the real one. // // The real extension storage API is a serialization boundary: `set` writes a // structured clone of what it is given, and `get` hands back a structured // clone of what is stored. Nothing an extension page holds is ever the object // storage holds. // // A stub that skips the clone aliases them together, and that hides an entire // class of defect rather than merely being imprecise. loadState() assigns // nested references straight out of the get result, so over an aliasing stub a // test can assert "the endpoint was persisted" and pass on a build that never // called saveState() at all: the in-memory mutation IS the stored record. // Measured, not theorised — with an aliasing `get` restored over the handler // fixed in https://git.eeqj.de/sneak/AutistMask/pulls/319, the whole suite // passed 794/794 (https://git.eeqj.de/sneak/AutistMask/issues/324). // // So every test that drives real persistence uses this, and nothing rebuilds // a storage stub by hand. // One storage area: get, set and remove over its own store, cloning both ways. // `changed` is handed what each set or remove changed, in the shape // storage.onChanged reports it. function makeArea(store, tick, changed) { const get = jest.fn(async (key) => { tick(); if (key === undefined || key === null) return structuredClone(store); const keys = Array.isArray(key) ? key : [key]; const out = {}; for (const k of keys) { if (Object.prototype.hasOwnProperty.call(store, k)) { out[k] = structuredClone(store[k]); } } return out; }); const set = jest.fn(async (items) => { tick(); const changes = {}; for (const k of Object.keys(items)) { changes[k] = structuredClone({ oldValue: store[k], newValue: items[k], }); store[k] = structuredClone(items[k]); } changed(changes); }); const remove = jest.fn(async (key) => { tick(); const changes = {}; for (const k of Array.isArray(key) ? key : [key]) { changes[k] = { oldValue: store[k] }; delete store[k]; } changed(changes); }); return { get, set, remove }; } // `initial` is the starting contents of `storage.local`, keyed as storage is: // { autistmask: {...} }. `storage.session` starts empty. // `onOp` runs before each operation on either area, for a test that needs to // advance a clock or count round trips. function makeStorageStub(initial, onOp) { const store = initial ? structuredClone(initial) : {}; const tick = onOp || (() => {}); // storage.onChanged: after a set or remove has finished, every listener is // called with what it changed and the area's name, as the browser calls // them for a write by any page or the background. const listeners = []; const announce = (areaName) => (changes) => { Promise.resolve().then(() => { for (const fn of listeners) fn(structuredClone(changes), areaName); }); }; const local = makeArea(store, tick, announce("local")); return { // Drop this straight in as chrome.storage. local, session: makeArea({}, tick, announce("session")), onChanged: { addListener: (fn) => listeners.push(fn) }, get: local.get, set: local.set, remove: local.remove, // What is stored in `storage.local`, cloned on the way out: a test can // neither observe a later write through an object it read nor reach // into the store by mutating one. read: (key) => key === undefined ? structuredClone(store) : structuredClone(store[key]), // Seed or replace a record without going through the module under // test — for standing in as "another page wrote this". Nothing is // announced to storage.onChanged, so it also stands in for the time // before the browser tells this page of such a write. write: (key, value) => { store[key] = structuredClone(value); }, }; } module.exports = { makeStorageStub };