// 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. 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 both recurring jobs", async () => { const created = await alarmsMod.ensureRecurringAlarms(); expect(created).toEqual({ balance: true, phishing: true }); const names = alarmsStub.created.map((c) => c.name).sort(); expect(names).toEqual( [ alarmsMod.BALANCE_REFRESH_ALARM, alarmsMod.PHISHING_REFRESH_ALARM, ].sort(), ); }); 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("the phishing refresh keeps its 24-hour cadence", async () => { await alarmsMod.ensureRecurringAlarms(); const phishing = alarmsStub.alarms.get( alarmsMod.PHISHING_REFRESH_ALARM, ); expect(phishing.periodInMinutes).toBe(24 * 60); }); 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(2); // 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, phishing: false }); expect(alarmsStub.create).toHaveBeenCalledTimes(2); }); 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, phishing: false }); 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.PHISHING_REFRESH_ALARM, { periodInMinutes: 7 * 24 * 60, }); alarmsStub.create.mockClear(); const created = await alarmsMod.ensureRecurringAlarms(); expect(created.phishing).toBe(true); expect( alarmsStub.alarms.get(alarmsMod.PHISHING_REFRESH_ALARM) .periodInMinutes, ).toBe(alarmsMod.PHISHING_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, phishing: false }); expect(alarmsStub.create).not.toHaveBeenCalled(); }); test("handlers are dispatched by alarm name from one listener", () => { const balance = jest.fn(); const phishing = jest.fn(); expect( alarmsMod.registerAlarmHandlers({ [alarmsMod.BALANCE_REFRESH_ALARM]: balance, [alarmsMod.PHISHING_REFRESH_ALARM]: phishing, }), ).toBe(true); expect(alarmsStub.listenerCount()).toBe(1); alarmsStub.fire(alarmsMod.BALANCE_REFRESH_ALARM); expect(balance).toHaveBeenCalledTimes(1); expect(phishing).not.toHaveBeenCalled(); alarmsStub.fire(alarmsMod.PHISHING_REFRESH_ALARM); expect(phishing).toHaveBeenCalledTimes(1); alarmsStub.fire("some-other-extension-alarm"); expect(balance).toHaveBeenCalledTimes(1); expect(phishing).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, phishing: true }); expect(firefoxAlarms.created).toHaveLength(2); // 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, phishing: false, }); 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. function loadBackground(initialStore = {}) { const storageStore = initialStore; const alarmsStub = makeAlarmsStub(); const listeners = { onInstalled: [], onStartup: [] }; global.chrome = { alarms: alarmsStub, storage: { local: { get: async (key) => { mockStorageTick(); return Object.prototype.hasOwnProperty.call( storageStore, key, ) ? { [key]: storageStore[key] } : {}; }, set: async (items) => { mockStorageTick(); Object.assign(storageStore, items); }, remove: async (key) => { delete storageStore[key]; }, }, }, 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() }, }; global.fetch = jest.fn(async () => ({ ok: true, json: async () => ({ blacklist: [] }), })); jest.resetModules(); require("../src/background/index"); return { alarmsStub, listeners, store: storageStore }; } // 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).sort(); const { BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM, } = require("../src/shared/alarms"); expect(names).toEqual( [BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM].sort(), ); expect(mockSetIntervalCalls).toBe(0); }); 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(2); }); 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(2); expect(alarmsStub.created.map((c) => c.name).sort()).toEqual( [ "autistmask-balance-refresh", "autistmask-phishing-refresh", ].sort(), ); }); }); // 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 store = seededStore(); const { alarmsStub } = loadBackground(store); await settle(); mockNow += PERIOD_MS; store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000; alarmsStub.fire(BALANCE_REFRESH_ALARM); await settle(); expect(mockBalanceRefreshAt).toHaveLength(0); }); });