Files
AutistMask/tests/alarms.test.js
T
sneak 4910ad02fc
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: Blockscout and Etherscan lookups are off until the user turns them on (closes #668)
Out of the box the wallet sent the user's addresses to Blockscout and each
recipient to Etherscan. Two settings, "Use Blockscout" and "Check recipients on
Etherscan", now gate those requests. Both are off in a new install and offered
unticked on Welcome and in Settings. While one is off, the screens that relied
on it say so in a plain line. Each gated request reads its setting from storage
before it asks and when the answer lands, and drops a late answer. An open popup
takes another page's change and draws its screens again. While "Use Blockscout"
is off, no address keeps an untracked token, and no screen names a token from
Blockscout's report. ENS lookups ask only the RPC endpoint.

Model: opus-5-5
2026-10-26 10:06:20 +00:00

499 lines
19 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());
}),
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
.dropUntrackedTokens,
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,
// A whole wallet record, not a bare address: a stored profile
// is validated against the schema on every read now
// (src/shared/stateSchema.js), and a wallet with no address
// list is one of the shapes that refuses to load.
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{
address:
"0x0000000000000000000000000000000000000001",
balance: "0",
tokenBalances: [],
},
],
},
],
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);
});
});