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. The
write is the whole record, and updateState()'s header now names what that costs:
a popup write landing inside that one-round-trip window is reverted.
- 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, and it is enforced by
the bundler rather than by a guess at what the bundler does. The table of
modules an entry point's bundle may not contain lives in
script/lib/forbiddenBundleInputs.js — one copy, read by both layers that act on
it — and build.js's assertNoForbiddenInputs() fails the build when esbuild's
metafile reports src/shared/state.js as an input of a background bundle, naming
the import chain from the metafile's own graph. That is the resolution the
shipped bundle was built from, so no specifier syntax, no hop and no resolution
rule can slip past it; Dockerfile:42 runs make build, so it holds in CI.
A background entry point the table does not name fails the build as well. The
five defects were accidents, and so is adding a second worker entry point
without knowing that a table elsewhere needs a line for it: entry points under
src/background/ are prohibited by default and must be listed, rather than
protected only when someone remembers. That prefix is the build's only notion of
"the background", and eslint.config.js scopes the lint rule from the same
constant so the two layers cannot disagree about it.
Every way the table can rot is a failure rather than a quiet pass: a key no
bundled entry point matched, a listed module this build bundled nowhere, and an
entry that lists no modules. The second is what makes a rename of
src/shared/state.js loud instead of silently disarming the check, and it is
stronger than an existsSync() because it also fails when the module is still
there but has dropped out of every bundle. The third is refused at require time,
where the table is defined, because an empty list also empties the lint rule's
forbidden set — one character, and a plain require of the singleton in the
worker was green in make test, make lint and make build alike.
An entry is recorded as checked only once its bundle's inputs are in hand. It
used to be recorded before the output lookup that produces them, so an early
return past that point left both halves of the guarantee satisfied by a bundle
nothing had examined.
What the assertion does NOT cover is a COPY of the singleton at another path: it
is keyed by path, so a copy builds and lints clean. That is stated where the
table lives, with what the residual actually is — a copy carries the singleton's
own guard, so an unloaded read is a loud StateNotLoadedError and defects 1-3
cannot recur silently, but a copy carries loadState() too, so defects 4 and 5
(a stale read several awaits after a load, a load detaching objects an in-flight
handler is mutating) would recur over it in silence.
make check does not run make build, so the assertion is unit tested against
synthetic metafiles in tests/buildForbiddenInputs.test.js: build.js runs its
build() only as a program now and exports the checks. Executing a check in CI
is not testing it — without that file, inverting the condition leaves every
check in this repo green with the singleton back in the worker. Each vacuous
pass above has a case, including the output lookup that finds nothing, the empty
list, the unlisted second entry point, and recordBundledInputs() itself, which
every other case used to hand-seed.
A custom ESLint rule walks the CommonJS require graph from every src/background/
file and reports the same thing in the editor, before a full bundle. It reads
the same table, and it matches specifiers textually, so it is best-effort fast
feedback and not the guarantee — two earlier revisions of it shipped holes (a
template literal, a dynamic import(), a comment inside the call, a directory
resolved through package.json main). Those are covered now and pinned by
tests/backgroundStateLintRule.test.js. Two shapes it does not report are pinned
there as asserted non-reports, so the header's list of its bounds is measured
rather than claimed: a computed specifier (require("../shared/" + "state"),
which esbuild constant-folds into the bundle) and a symlink to the module
(esbuild reports the real path). Each is make lint exit 0 and make build exit 2.
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. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.
closes #320
482 lines
18 KiB
JavaScript
482 lines
18 KiB
JavaScript
// Scheduling for the background context.
|
|
//
|
|
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
|
|
// so anything scheduled with setInterval/setTimeout dies with it. These tests
|
|
// pin the recurring jobs to the alarms API and to the re-registration path a
|
|
// revived worker runs.
|
|
|
|
// A controllable clock plus a stubbed balance refresh, so a cadence test can
|
|
// measure the interval between refreshes that actually happened rather than
|
|
// asserting the interval someone intended.
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
|
|
let mockNow = 0;
|
|
const mockBalanceRefreshAt = [];
|
|
|
|
// jest.resetModules() clears the call record of every jest.fn, and loading the
|
|
// worker is exactly that call — so anything that must be counted across a load
|
|
// is counted here rather than read off a mock.
|
|
let mockSetIntervalCalls = 0;
|
|
|
|
// Extension storage reads do not take a constant amount of time, and that is
|
|
// what makes a guard timed to the alarm period bite: backgroundRefresh()
|
|
// stamps its freshness marker after awaiting loadState(), so any read that is
|
|
// quicker than the previous one puts the next tick inside a guard of exactly
|
|
// one period and the tick is skipped. A simulation with a constant latency
|
|
// would sit exactly on the boundary and hide the bug.
|
|
const MOCK_STORAGE_LATENCIES_MS = [7, 3, 11, 2, 9, 4, 13, 1, 6, 5];
|
|
const MOCK_MAX_STORAGE_LATENCY_MS = Math.max(...MOCK_STORAGE_LATENCIES_MS);
|
|
let mockStorageJitter = false;
|
|
let mockStorageOpCount = 0;
|
|
|
|
function mockStorageTick() {
|
|
if (!mockStorageJitter) return;
|
|
mockNow +=
|
|
MOCK_STORAGE_LATENCIES_MS[
|
|
mockStorageOpCount++ % MOCK_STORAGE_LATENCIES_MS.length
|
|
];
|
|
}
|
|
|
|
jest.mock("../src/shared/balances", () => ({
|
|
refreshBalances: jest.fn(async () => {
|
|
mockBalanceRefreshAt.push(Date.now());
|
|
}),
|
|
getProvider: jest.fn(() => ({})),
|
|
}));
|
|
|
|
function makeAlarmsStub() {
|
|
const alarms = new Map();
|
|
const listeners = [];
|
|
const stub = {
|
|
created: [],
|
|
alarms,
|
|
create: jest.fn((name, info) => {
|
|
stub.created.push({ name, info });
|
|
alarms.set(name, { name, ...info });
|
|
}),
|
|
get: jest.fn(async (name) => alarms.get(name)),
|
|
clear: jest.fn(async (name) => alarms.delete(name)),
|
|
onAlarm: {
|
|
addListener: jest.fn((fn) => listeners.push(fn)),
|
|
},
|
|
fire: (name) => {
|
|
for (const fn of listeners) fn({ name });
|
|
},
|
|
listenerCount: () => listeners.length,
|
|
};
|
|
return stub;
|
|
}
|
|
|
|
describe("alarms module", () => {
|
|
let alarmsStub;
|
|
let alarmsMod;
|
|
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
alarmsStub = makeAlarmsStub();
|
|
global.chrome = { alarms: alarmsStub };
|
|
alarmsMod = require("../src/shared/alarms");
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete global.chrome;
|
|
});
|
|
|
|
test("ensureRecurringAlarms schedules the recurring job", async () => {
|
|
const created = await alarmsMod.ensureRecurringAlarms();
|
|
expect(created).toEqual({ balance: true, cleared: [] });
|
|
|
|
const names = alarmsStub.created.map((c) => c.name);
|
|
expect(names).toEqual([alarmsMod.BALANCE_REFRESH_ALARM]);
|
|
});
|
|
|
|
test("the balance refresh keeps its 60-second cadence", async () => {
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
const balance = alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM);
|
|
expect(balance.periodInMinutes).toBe(1);
|
|
});
|
|
|
|
test("a retired job's alarm is cleared, not left running", async () => {
|
|
// The browser holds an alarm until something clears it. Deleting the
|
|
// job from the code is not enough: on every install that ever ran the
|
|
// version which created it, the alarm goes on waking the service
|
|
// worker on its old schedule with nothing to deliver it to.
|
|
for (const name of alarmsMod.OBSOLETE_ALARMS) {
|
|
alarmsStub.create(name, { periodInMinutes: 24 * 60 });
|
|
}
|
|
expect(alarmsMod.OBSOLETE_ALARMS.length).toBeGreaterThan(0);
|
|
|
|
const result = await alarmsMod.ensureRecurringAlarms();
|
|
expect(result.cleared).toEqual(alarmsMod.OBSOLETE_ALARMS);
|
|
for (const name of alarmsMod.OBSOLETE_ALARMS) {
|
|
expect(alarmsStub.alarms.get(name)).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
test("clearing a retired alarm is not re-reported once it is gone", async () => {
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
const again = await alarmsMod.ensureRecurringAlarms();
|
|
expect(again.cleared).toEqual([]);
|
|
});
|
|
|
|
test("no retired name is also a live one", async () => {
|
|
// A name in both lists would be created and then cleared on every
|
|
// start, so the job it schedules would never fire.
|
|
expect(alarmsMod.OBSOLETE_ALARMS).not.toContain(
|
|
alarmsMod.BALANCE_REFRESH_ALARM,
|
|
);
|
|
});
|
|
|
|
test("no period is below the browser-enforced minimum", async () => {
|
|
// A period under one minute is silently clamped by the browser, so a
|
|
// request for one would mean the documented cadence is not the real
|
|
// one. Every period must be a whole minute at or above the minimum.
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
for (const { info } of alarmsStub.created) {
|
|
expect(info.periodInMinutes).toBeGreaterThanOrEqual(
|
|
alarmsMod.MIN_ALARM_PERIOD_MINUTES,
|
|
);
|
|
expect(Number.isInteger(info.periodInMinutes)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("a revived worker does not reset an existing alarm's schedule", async () => {
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
expect(alarmsStub.create).toHaveBeenCalledTimes(1);
|
|
|
|
// Every wake re-runs the startup path. Re-creating an alarm restarts
|
|
// its period, so a busy extension would push the next fire out
|
|
// forever and the job would never run.
|
|
const again = await alarmsMod.ensureRecurringAlarms();
|
|
expect(again).toEqual({ balance: false, cleared: [] });
|
|
expect(alarmsStub.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("a missing alarm is re-created on the next start", async () => {
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
await alarmsStub.clear(alarmsMod.BALANCE_REFRESH_ALARM);
|
|
|
|
const again = await alarmsMod.ensureRecurringAlarms();
|
|
expect(again).toEqual({ balance: true, cleared: [] });
|
|
expect(
|
|
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM),
|
|
).toBeDefined();
|
|
});
|
|
|
|
test("an alarm left over with a stale period is re-created", async () => {
|
|
// An install carries its alarms across an extension update, so a
|
|
// period changed in a new release only ever reaches users if the
|
|
// stale one is reconciled.
|
|
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
|
|
periodInMinutes: 7 * 24 * 60,
|
|
});
|
|
alarmsStub.create.mockClear();
|
|
|
|
const created = await alarmsMod.ensureRecurringAlarms();
|
|
expect(created.balance).toBe(true);
|
|
expect(
|
|
alarmsStub.alarms.get(alarmsMod.BALANCE_REFRESH_ALARM)
|
|
.periodInMinutes,
|
|
).toBe(alarmsMod.BALANCE_REFRESH_PERIOD_MINUTES);
|
|
});
|
|
|
|
test("reconciling a period settles instead of re-creating forever", async () => {
|
|
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
|
|
periodInMinutes: 30,
|
|
});
|
|
await alarmsMod.ensureRecurringAlarms();
|
|
alarmsStub.create.mockClear();
|
|
|
|
const again = await alarmsMod.ensureRecurringAlarms();
|
|
expect(again).toEqual({ balance: false, cleared: [] });
|
|
expect(alarmsStub.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("handlers are dispatched by alarm name from one listener", () => {
|
|
const balance = jest.fn();
|
|
const other = jest.fn();
|
|
expect(
|
|
alarmsMod.registerAlarmHandlers({
|
|
[alarmsMod.BALANCE_REFRESH_ALARM]: balance,
|
|
"autistmask-some-other-job": other,
|
|
}),
|
|
).toBe(true);
|
|
expect(alarmsStub.listenerCount()).toBe(1);
|
|
|
|
alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM);
|
|
expect(balance).toHaveBeenCalledTimes(1);
|
|
expect(other).not.toHaveBeenCalled();
|
|
|
|
alarmsStub.fire("autistmask-some-other-job");
|
|
expect(other).toHaveBeenCalledTimes(1);
|
|
|
|
alarmsStub.fire("an-alarm-with-no-handler");
|
|
expect(balance).toHaveBeenCalledTimes(1);
|
|
expect(other).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("Firefox MV2 gets the same treatment via browser.alarms", async () => {
|
|
// Both targets are built from one bundle. MV2 has a persistent
|
|
// background page, but it takes the alarm path too, so the schedule
|
|
// is the same code on both browsers.
|
|
jest.resetModules();
|
|
const firefoxAlarms = makeAlarmsStub();
|
|
global.browser = { alarms: firefoxAlarms };
|
|
try {
|
|
const mod = require("../src/shared/alarms");
|
|
const created = await mod.ensureRecurringAlarms();
|
|
expect(created).toEqual({ balance: true, cleared: [] });
|
|
expect(firefoxAlarms.created).toHaveLength(1);
|
|
// The Chrome stub must not have been touched.
|
|
expect(alarmsStub.create).not.toHaveBeenCalled();
|
|
} finally {
|
|
delete global.browser;
|
|
}
|
|
});
|
|
|
|
test("a context without the alarms API degrades instead of throwing", async () => {
|
|
jest.resetModules();
|
|
delete global.chrome;
|
|
const mod = require("../src/shared/alarms");
|
|
await expect(mod.ensureRecurringAlarms()).resolves.toEqual({
|
|
balance: false,
|
|
cleared: [],
|
|
});
|
|
expect(mod.registerAlarmHandlers({})).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Loads the background worker against stubbed browser APIs. The returned
|
|
// store is the extension storage the worker sees, so a test can seed wallet
|
|
// state and read back what the worker persisted.
|
|
// The stub clones in both directions, as the real chrome.storage.local does,
|
|
// and carries the latency simulation above on every operation. It used to
|
|
// alias, which for this file meant the worker's in-memory wallets and the
|
|
// "stored" ones were one object — see tests/support/storageStub.js.
|
|
function loadBackground(initialStore = {}) {
|
|
const storage = makeStorageStub(initialStore, mockStorageTick);
|
|
const alarmsStub = makeAlarmsStub();
|
|
const listeners = { onInstalled: [], onStartup: [] };
|
|
global.chrome = {
|
|
alarms: alarmsStub,
|
|
storage,
|
|
runtime: {
|
|
onMessage: { addListener: jest.fn() },
|
|
onConnect: { addListener: jest.fn() },
|
|
onInstalled: {
|
|
addListener: jest.fn((fn) => listeners.onInstalled.push(fn)),
|
|
},
|
|
onStartup: {
|
|
addListener: jest.fn((fn) => listeners.onStartup.push(fn)),
|
|
},
|
|
getURL: (p) => "chrome-extension://test/" + p,
|
|
lastError: null,
|
|
},
|
|
windows: {
|
|
onRemoved: { addListener: jest.fn() },
|
|
create: jest.fn(),
|
|
},
|
|
tabs: { query: jest.fn(), sendMessage: jest.fn() },
|
|
action: { setPopup: jest.fn() },
|
|
};
|
|
// Present so that a startup path which went to the network would be
|
|
// recorded rather than throwing, which is what makes "no request was made"
|
|
// an observation instead of an assumption.
|
|
global.fetch = jest.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({}),
|
|
}));
|
|
jest.resetModules();
|
|
require("../src/background/index");
|
|
return { alarmsStub, listeners, storage };
|
|
}
|
|
|
|
// Flush the promise chains the startup path and the alarm handlers run on.
|
|
async function settle() {
|
|
for (let i = 0; i < 3; i++) {
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
}
|
|
|
|
describe("background worker scheduling", () => {
|
|
let alarmsStub;
|
|
let timers;
|
|
|
|
beforeEach(() => {
|
|
mockSetIntervalCalls = 0;
|
|
timers = {
|
|
setInterval: jest
|
|
.spyOn(global, "setInterval")
|
|
.mockImplementation(() => {
|
|
mockSetIntervalCalls++;
|
|
return 0;
|
|
}),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
timers.setInterval.mockRestore();
|
|
delete global.chrome;
|
|
delete global.fetch;
|
|
jest.resetModules();
|
|
});
|
|
|
|
test("startup schedules the recurring jobs as alarms, not timers", async () => {
|
|
alarmsStub = loadBackground().alarmsStub;
|
|
// Let the startup path's promises settle.
|
|
await settle();
|
|
|
|
const names = alarmsStub.created.map((c) => c.name);
|
|
const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms");
|
|
expect(names).toEqual([BALANCE_REFRESH_ALARM]);
|
|
expect(mockSetIntervalCalls).toBe(0);
|
|
});
|
|
|
|
test("startup contacts nothing", async () => {
|
|
// The phishing blocklist is vendored at build time and there is no
|
|
// other startup fetch, so a worker coming up asks nobody anything.
|
|
// Every wake used to be a candidate for a blocklist download.
|
|
loadBackground();
|
|
await settle();
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("an onAlarm listener is installed on startup", async () => {
|
|
alarmsStub = loadBackground().alarmsStub;
|
|
await settle();
|
|
expect(alarmsStub.listenerCount()).toBe(1);
|
|
});
|
|
|
|
test("onInstalled and onStartup both re-establish the schedule", async () => {
|
|
const loaded = loadBackground();
|
|
alarmsStub = loaded.alarmsStub;
|
|
await settle();
|
|
|
|
expect(loaded.listeners.onInstalled).toHaveLength(1);
|
|
expect(loaded.listeners.onStartup).toHaveLength(1);
|
|
|
|
// A browser start after the alarms were dropped must put them back.
|
|
alarmsStub.alarms.clear();
|
|
alarmsStub.created.length = 0;
|
|
loaded.listeners.onStartup[0]();
|
|
await settle();
|
|
expect(alarmsStub.created).toHaveLength(1);
|
|
});
|
|
|
|
test("the install-time listener and the top-level call share one run", async () => {
|
|
// On a fresh install both fire, close enough that both could observe
|
|
// an alarm missing and create it — and a second create restarts the
|
|
// period the first one just set.
|
|
const loaded = loadBackground();
|
|
alarmsStub = loaded.alarmsStub;
|
|
loaded.listeners.onInstalled[0]();
|
|
await settle();
|
|
|
|
expect(alarmsStub.created).toHaveLength(1);
|
|
expect(alarmsStub.created.map((c) => c.name)).toEqual([
|
|
"autistmask-balance-refresh",
|
|
]);
|
|
});
|
|
});
|
|
|
|
// The alarm period alone must set the cadence. A freshness guard timed to the
|
|
// period vetoes the very tick it gates, because the guard is measured from
|
|
// when the last run finished and the alarm fires one run-duration before that.
|
|
// These tests measure the interval between refreshes that actually ran.
|
|
describe("balance refresh steady-state cadence", () => {
|
|
const {
|
|
BALANCE_REFRESH_PERIOD_MINUTES,
|
|
BALANCE_REFRESH_ALARM,
|
|
} = require("../src/shared/alarms");
|
|
const PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
|
|
|
|
let clockSpy;
|
|
let timerSpy;
|
|
|
|
function seededStore() {
|
|
return {
|
|
autistmask: {
|
|
hasWallet: true,
|
|
wallets: [
|
|
{ address: "0x0000000000000000000000000000000000000001" },
|
|
],
|
|
lastBalanceRefresh: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockNow = Date.UTC(2026, 0, 1, 0, 0, 0);
|
|
mockBalanceRefreshAt.length = 0;
|
|
mockSetIntervalCalls = 0;
|
|
mockStorageOpCount = 0;
|
|
mockStorageJitter = false;
|
|
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => mockNow);
|
|
timerSpy = jest.spyOn(global, "setInterval").mockImplementation(() => {
|
|
mockSetIntervalCalls++;
|
|
return 0;
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockStorageJitter = false;
|
|
clockSpy.mockRestore();
|
|
timerSpy.mockRestore();
|
|
delete global.chrome;
|
|
delete global.fetch;
|
|
jest.resetModules();
|
|
});
|
|
|
|
test("ten alarm ticks produce ten refreshes, one per period", async () => {
|
|
const { alarmsStub } = loadBackground(seededStore());
|
|
await settle();
|
|
mockStorageJitter = true;
|
|
|
|
const TICKS = 10;
|
|
let tickAt = mockNow + PERIOD_MS;
|
|
for (let i = 0; i < TICKS; i++) {
|
|
mockNow = tickAt;
|
|
tickAt += PERIOD_MS;
|
|
alarmsStub.fire(BALANCE_REFRESH_ALARM);
|
|
await settle();
|
|
}
|
|
|
|
// No tick was a no-op. This is the assertion that fails when the guard
|
|
// is timed to the alarm period.
|
|
expect(mockBalanceRefreshAt).toHaveLength(TICKS);
|
|
|
|
// And the observed cadence is one period, not two.
|
|
const intervals = mockBalanceRefreshAt
|
|
.slice(1)
|
|
.map((t, i) => t - mockBalanceRefreshAt[i]);
|
|
for (const interval of intervals) {
|
|
expect(interval).toBeGreaterThanOrEqual(
|
|
PERIOD_MS - MOCK_MAX_STORAGE_LATENCY_MS,
|
|
);
|
|
expect(interval).toBeLessThanOrEqual(
|
|
PERIOD_MS + MOCK_MAX_STORAGE_LATENCY_MS,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("a refresh an open popup just did still suppresses the tick", async () => {
|
|
// The guard's actual job, and the reason it is shortened rather than
|
|
// removed: while the popup is open it refreshes every 10 seconds and
|
|
// stamps the same field, and the background job has nothing to add.
|
|
const { alarmsStub, storage } = loadBackground(seededStore());
|
|
await settle();
|
|
|
|
mockNow += PERIOD_MS;
|
|
// As the open popup's own refresh would leave it: written to storage,
|
|
// not poked into an object the worker happens to share.
|
|
storage.write("autistmask", {
|
|
...storage.read("autistmask"),
|
|
lastBalanceRefresh: mockNow - 10 * 1000,
|
|
});
|
|
alarmsStub.fire(BALANCE_REFRESH_ALARM);
|
|
await settle();
|
|
|
|
expect(mockBalanceRefreshAt).toHaveLength(0);
|
|
});
|
|
});
|