All checks were successful
check / check (push) Successful in 30s
The Chrome MV3 service worker is terminated after roughly 30 seconds idle, which destroyed both recurring jobs: the 60-second balance refresh and the 24-hour phishing blocklist refresh were setInterval schedules, so in practice each ran only while the worker happened to be alive. The phishing delta was persisted to localStorage, which does not exist in a service worker, so on Chrome it was never persisted at all. Both jobs now run off the extension alarms API in the new src/shared/alarms.js: the browser holds the schedule and wakes the worker to deliver it. The balance refresh is one minute and the phishing refresh is 1440 minutes, both whole minutes at or above the one-minute minimum, so neither is silently clamped. Alarms are created only when missing, because creating one restarts its period and the startup path runs on every wake. The phishing delta and the timestamp of the fetch that produced it now live in extension storage, and updatePhishingList() reloads that record before deciding whether a fetch is due. A revived worker therefore neither re-fetches on every wake nor sleeps through an overdue update. The 256 KiB cap covers the whole record: an oversized delta is dropped together with its timestamp so the next start fetches again. The startup path (ensureRecurringAlarms plus the phishing list init) is registered on onInstalled and onStartup as well as running at the top level of the worker, and is idempotent. Firefox MV2 has a persistent background page where timers would have survived, but both browsers are built from one bundle and both take the alarm path, so there is a single code path; "alarms" is declared in both manifests. src/shared/ens.js keeps its localStorage cache and gains a comment recording that it is popup-only, so it does not get pulled into the worker later.
267 lines
9.5 KiB
JavaScript
267 lines
9.5 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.
|
|
|
|
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);
|
|
});
|
|
});
|