import { beforeEach, describe, expect, it, vi } from "vitest"; // vi.mock is hoisted to the top of the file — it cannot see runtime-created // `const`s, so the shared stub has to live in a vi.hoisted block. const { triggerSync, setSyncRunner } = vi.hoisted(() => ({ triggerSync: vi.fn(async () => undefined), setSyncRunner: vi.fn(), })); vi.mock("./sync-trigger", () => ({ triggerSyncIfReachable: triggerSync, setSyncRunner, })); import { setupSyncAlarm, SYNC_ALARM_NAME, SYNC_ALARM_PERIOD_MINUTES, _resetSyncAlarmForTest, } from "./sync-alarm"; describe("sync-alarm", () => { type Alarm = { name: string; periodInMinutes?: number; delayInMinutes?: number }; let alarmListener: ((alarm: Alarm) => void) | undefined; let createCalls: Array<{ name: string; opts: Record }>; let clears: string[]; beforeEach(() => { triggerSync.mockClear(); setSyncRunner.mockClear(); _resetSyncAlarmForTest(); alarmListener = undefined; createCalls = []; clears = []; (globalThis as { chrome?: unknown }).chrome = { alarms: { clear: vi.fn((name: string) => { clears.push(name); }), create: vi.fn((name: string, opts: Record) => { createCalls.push({ name, opts }); }), onAlarm: { addListener: vi.fn((fn: (alarm: Alarm) => void) => { alarmListener = fn; }), }, }, runtime: { sendMessage: vi.fn() }, storage: { local: { get: vi.fn(async () => ({})), set: vi.fn(async () => undefined), remove: vi.fn(async () => undefined), }, }, }; }); it("schedules a recurring alarm at SYNC_ALARM_PERIOD_MINUTES with the named alarm", () => { setupSyncAlarm(); expect(createCalls).toHaveLength(1); expect(createCalls[0].name).toBe(SYNC_ALARM_NAME); expect(createCalls[0].opts).toEqual({ periodInMinutes: SYNC_ALARM_PERIOD_MINUTES }); // Period must be at least Chrome's 1-minute recurring-alarm floor. expect(SYNC_ALARM_PERIOD_MINUTES).toBeGreaterThanOrEqual(1); }); it("is idempotent: calling setup twice clears the first and reschedules once", () => { setupSyncAlarm(); setupSyncAlarm(); expect(clears).toEqual([SYNC_ALARM_NAME, SYNC_ALARM_NAME]); expect(createCalls).toHaveLength(2); }); it("registers the alarm listener exactly once across multiple setup calls", () => { setupSyncAlarm(); setupSyncAlarm(); setupSyncAlarm(); const addListener = ( globalThis as unknown as { chrome: { alarms: { onAlarm: { addListener: ReturnType } } } } ).chrome.alarms.onAlarm.addListener; expect(addListener).toHaveBeenCalledTimes(1); }); it("on alarm fire, calls triggerSyncIfReachable with reason 'alarm'", async () => { setupSyncAlarm(); await alarmListener?.({ name: SYNC_ALARM_NAME }); expect(triggerSync).toHaveBeenCalledTimes(1); expect(triggerSync).toHaveBeenCalledWith({ reason: "alarm" }); }); it("ignores alarms for other names — does not dial on the auto-lock alarm", async () => { setupSyncAlarm(); await alarmListener?.({ name: "autolock" }); expect(triggerSync).not.toHaveBeenCalled(); }); it("alarm fire that fails inside triggerSyncIfReachable does not break the listener", async () => { // The handler swallows triggerSync errors — a one-off dial failure must // not unsubscribe the listener or throw into Chrome's alarm dispatcher. triggerSync.mockRejectedValueOnce(new Error("dial failed")); setupSyncAlarm(); // alarmListener is registered sync in setupSyncAlarm; fire it and // observe triggerSync was called without the rejection propagating up. await alarmListener?.({ name: SYNC_ALARM_NAME }); expect(triggerSync).toHaveBeenCalledTimes(1); // Subsequent fires still dial — the listener is still installed. await alarmListener?.({ name: SYNC_ALARM_NAME }); expect(triggerSync).toHaveBeenCalledTimes(2); }); });