// 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. 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("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); }); }); describe("background worker scheduling", () => { let alarmsStub; let timers; function loadBackground() { const storageStore = {}; alarmsStub = makeAlarmsStub(); const listeners = { onInstalled: [], onStartup: [] }; global.chrome = { alarms: alarmsStub, storage: { local: { get: async (key) => Object.prototype.hasOwnProperty.call(storageStore, key) ? { [key]: storageStore[key] } : {}, set: async (items) => 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 listeners; } beforeEach(() => { timers = { setInterval: jest .spyOn(global, "setInterval") .mockImplementation(() => 0), }; }); afterEach(() => { timers.setInterval.mockRestore(); delete global.chrome; delete global.fetch; jest.resetModules(); }); test("startup schedules the recurring jobs as alarms, not timers", async () => { loadBackground(); // Let the startup path's promises settle. await new Promise((resolve) => setImmediate(resolve)); 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(timers.setInterval).not.toHaveBeenCalled(); }); test("an onAlarm listener is installed on startup", async () => { loadBackground(); await new Promise((resolve) => setImmediate(resolve)); expect(alarmsStub.listenerCount()).toBe(1); }); test("onInstalled and onStartup both re-establish the schedule", async () => { const listeners = loadBackground(); await new Promise((resolve) => setImmediate(resolve)); expect(listeners.onInstalled).toHaveLength(1); expect(listeners.onStartup).toHaveLength(1); // A browser start after the alarms were dropped must put them back. alarmsStub.alarms.clear(); alarmsStub.created.length = 0; listeners.onStartup[0](); await new Promise((resolve) => setImmediate(resolve)); expect(alarmsStub.created).toHaveLength(2); }); });