Skip to content

Commit 3959476

Browse files
committed
修订 chrome.alarms 相关代码
1 parent 080a859 commit 3959476

File tree

7 files changed

+579
-106
lines changed

7 files changed

+579
-106
lines changed
Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
/* eslint-disable chrome-error/require-last-error-check */
2+
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
3+
/**
4+
*
5+
* 测试方法说明:
6+
* - 我们模拟 Chrome 扩展的 alarms/storage/runtime 接口,控制其行为并观察调用;
7+
* - 使用 vi.useFakeTimers() + vi.setSystemTime() 锁定时间,便于验证“延迟触发/补偿执行(isFlushed)”;
8+
* - 每个用例都以 buildChromeMock() 创建隔离的 mock,避免跨用例状态污染;
9+
* - freshImport() 每次重新导入模块,确保模块级别的状态(例如回调登记表)在每个测试中都是“干净”的;
10+
* - 通过 chrome.alarms.onAlarm.__trigger(...) 主动触发 onAlarm 事件,模拟浏览器实际调度;
11+
* - 通过 storage.local.get/set/remove 记录/清理“待处理(pending)”信息,以模拟掉电/重启后的补偿执行逻辑。
12+
*/
13+
14+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15+
16+
const importTarget = "./alarm" as const;
17+
18+
/**
19+
* 重新导入模块,使模块内的单例或闭包状态被重置。
20+
* 为什么需要?
21+
* - alarm.ts 很可能在模块级保存“回调登记表/监控标记”等状态;
22+
* - 测试之间必须相互独立,否则前一个用例的注册会影响后续用例,导致误判。
23+
*/
24+
async function freshImport() {
25+
vi.resetModules();
26+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
27+
return (await import(importTarget)) as typeof import("./alarm");
28+
}
29+
30+
/**
31+
* 构造最小可用的 Chrome API Mock:alarms / storage / runtime。
32+
* 关键点:
33+
* - 允许我们注入 get/create 的返回值与 side-effect;
34+
* - onAlarm.addListener 保存回调函数,并提供 __trigger() 手动触发;
35+
* - storage.local 使用 Promise 风格便于 await;
36+
* - runtime.lastError 用于模拟扩展 API 的错误通道(API 调用后读取)。
37+
*/
38+
type OnAlarmListener = (alarm: any) => void;
39+
function buildChromeMock() {
40+
let onAlarmListener: OnAlarmListener | null = null;
41+
42+
const chromeMock: any = {
43+
alarms: {
44+
/**
45+
* chrome.alarms.get(name, cb)
46+
* - 我们会在用例里 mockImplementation 注入具体返回:
47+
* - cb(undefined) 表示不存在;
48+
* - cb({ name, periodInMinutes }) 表示已存在;
49+
*/
50+
get: vi.fn(),
51+
/**
52+
* chrome.alarms.create(name, info, cb?)
53+
* - 我们会在用例里检查是否被调用,以及调用参数是否正确;
54+
* - 也可以设置 runtime.lastError 来模拟创建时的“配额错误”等情况。
55+
*/
56+
create: vi.fn(),
57+
onAlarm: {
58+
/**
59+
* 注册 onAlarm 监听器。我们把监听器保存到闭包变量 onAlarmListener 中,
60+
* 稍后通过 __trigger() 主动触发它,模拟浏览器调度。
61+
*/
62+
addListener: vi.fn((listener: OnAlarmListener) => {
63+
onAlarmListener = listener;
64+
}),
65+
/**
66+
* 手动触发 alarm 事件:
67+
* - 传入形如 { name, periodInMinutes, scheduledTime } 的对象;
68+
* - scheduledTime 用于判断是否“补偿执行”(isFlushed)。
69+
*/
70+
__trigger(alarm: any) {
71+
onAlarmListener?.(alarm);
72+
},
73+
},
74+
},
75+
storage: {
76+
local: {
77+
// 读取/写入/删除“待处理(pending)”记录,用于 SW 重启补偿等场景
78+
get: vi.fn().mockResolvedValue({}),
79+
set: vi.fn().mockResolvedValue(undefined),
80+
remove: vi.fn().mockResolvedValue(undefined),
81+
},
82+
},
83+
runtime: { lastError: null },
84+
};
85+
86+
(globalThis as any).chrome = chromeMock;
87+
return chromeMock;
88+
}
89+
90+
let savedChrome: any;
91+
92+
beforeEach(() => {
93+
savedChrome = (global as any).chrome;
94+
// 伪造时间:保持所有用例处在固定“当前时间”,便于判断延迟/补偿
95+
vi.useFakeTimers();
96+
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
97+
});
98+
99+
afterEach(() => {
100+
(global as any).chrome = savedChrome;
101+
savedChrome = undefined;
102+
});
103+
104+
// ====================== mightCreatePeriodicAlarm ======================
105+
/**
106+
* 目标:
107+
* - 当 alarm 不存在时应创建;
108+
* - 当存在且周期一致时不重新创建(复用);
109+
* - 当存在但周期不同应重建;
110+
* - get() 产生 lastError 也不影响后续创建;
111+
* - create() 产生 lastError 仅记录,不抛错(行为仍然 resolve)。
112+
*
113+
* 方法:
114+
* - 通过 mock 的 get/create 行为与 runtime.lastError 状态,观察 mightCreatePeriodicAlarm 的返回值和副作用。
115+
*/
116+
117+
describe("mightCreatePeriodicAlarm", () => {
118+
it("当不存在同名 alarm 时:应创建", async () => {
119+
const chrome = buildChromeMock();
120+
// 模拟 get 返回 undefined 表示“没有现存的 alarm”
121+
chrome.alarms.get.mockImplementation((_n: string, cb: Function) => cb(undefined));
122+
// 模拟 create 正常调用(可选回调被安全调用)
123+
chrome.alarms.create.mockImplementation((_n: string, _i: any, cb?: Function) => cb?.());
124+
125+
const { mightCreatePeriodicAlarm } = await freshImport();
126+
const result = await mightCreatePeriodicAlarm("DataSync", { periodInMinutes: 10 });
127+
128+
expect(result).toEqual({ justCreated: true });
129+
expect(chrome.alarms.create).toHaveBeenCalledWith("DataSync", { periodInMinutes: 10 }, expect.any(Function));
130+
});
131+
132+
it("当已存在且周期一致:应复用(不创建)", async () => {
133+
const chrome = buildChromeMock();
134+
chrome.alarms.get.mockImplementation((_n: string, cb: Function) => cb({ name: "DataSync", periodInMinutes: 10 }));
135+
136+
const { mightCreatePeriodicAlarm } = await freshImport();
137+
const result = await mightCreatePeriodicAlarm("DataSync", { periodInMinutes: 10 });
138+
139+
expect(result).toEqual({ justCreated: false });
140+
expect(chrome.alarms.create).not.toHaveBeenCalled();
141+
});
142+
143+
it("当已存在但周期不同:应重建", async () => {
144+
const chrome = buildChromeMock();
145+
chrome.alarms.get.mockImplementation((_n: string, cb: Function) => cb({ name: "DataSync", periodInMinutes: 5 }));
146+
chrome.alarms.create.mockImplementation((_n: string, _i: any, cb?: Function) => cb?.());
147+
148+
const { mightCreatePeriodicAlarm } = await freshImport();
149+
const result = await mightCreatePeriodicAlarm("DataSync", { periodInMinutes: 10 });
150+
151+
expect(result).toEqual({ justCreated: true });
152+
expect(chrome.alarms.create).toHaveBeenCalled();
153+
});
154+
155+
it("忽略 get() 的 runtime.lastError,仍应继续创建", async () => {
156+
const chrome = buildChromeMock();
157+
// get() 产生 lastError,但我们仍按“未找到”处理
158+
chrome.alarms.get.mockImplementation((_n: string, cb: Function) => {
159+
chrome.runtime.lastError = new Error("Some get error");
160+
cb(undefined);
161+
});
162+
// create() 正常
163+
chrome.alarms.create.mockImplementation((_n: string, _i: any, cb?: Function) => {
164+
chrome.runtime.lastError = null;
165+
cb?.();
166+
});
167+
168+
const { mightCreatePeriodicAlarm } = await freshImport();
169+
const result = await mightCreatePeriodicAlarm("ErrAlarm", { periodInMinutes: 1 });
170+
171+
expect(result).toEqual({ justCreated: true });
172+
expect(chrome.alarms.create).toHaveBeenCalled();
173+
});
174+
175+
it("记录 create() 的 runtime.lastError,但仍 resolve", async () => {
176+
const chrome = buildChromeMock();
177+
chrome.alarms.get.mockImplementation((_n: string, cb: Function) => cb(undefined));
178+
// create() 设置 lastError,代表“配额不足”等,但行为不抛错
179+
chrome.alarms.create.mockImplementation((_n: string, _i: any, cb?: Function) => {
180+
chrome.runtime.lastError = new Error("quota exceeded");
181+
cb?.();
182+
});
183+
184+
const { mightCreatePeriodicAlarm } = await freshImport();
185+
const result = await mightCreatePeriodicAlarm("Quota", { periodInMinutes: 2 });
186+
187+
expect(result).toEqual({ justCreated: true });
188+
});
189+
});
190+
191+
// =========== setPeriodicAlarmCallback + monitorPeriodicAlarm ===========
192+
/**
193+
* 目标:
194+
* - 注册回调后,onAlarm 触发应调用对应回调;
195+
* - 根据 scheduledTime 与当前时间判断是否补偿执行(isFlushed);
196+
* - 回调失败也要清理 pending;
197+
* - monitorPeriodicAlarm 只能启动一次;
198+
* - SW 重启后若发现“未变化的 pending”则执行补偿;若有变化则不补偿;
199+
* - onAlarm 期间若出现 runtime.lastError,应中止处理。
200+
*
201+
* 方法:
202+
* - 通过 setPeriodicAlarmCallback(name, cb) 注册;
203+
* - 调用 monitorPeriodicAlarm() 启动监听(内部可能有 100ms 延迟与 3s 补偿轮询);
204+
* - __trigger(...) 触发 onAlarm;
205+
* - 使用 fake timers 推进时间,等待内部 setTimeout;
206+
* - 通过 storage.local 的调用轨迹确认“写入 pending / 清理 pending”。
207+
*/
208+
209+
describe("setPeriodicAlarmCallback + monitorPeriodicAlarm", () => {
210+
it("准时触发:应调用回调且 isFlushed=false,并完成 pending 记录/清理", async () => {
211+
const chrome = buildChromeMock();
212+
const now = Date.now();
213+
const { setPeriodicAlarmCallback, monitorPeriodicAlarm } = await freshImport();
214+
215+
const cb = vi.fn().mockResolvedValue(undefined);
216+
setPeriodicAlarmCallback("A1", cb);
217+
chrome.storage.local.get.mockResolvedValue({}); // 初始无 pending
218+
219+
const monitorPromise = monitorPeriodicAlarm(); // 启动监听
220+
221+
// 触发“准时”的 alarm:scheduledTime == now
222+
chrome.alarms.onAlarm.__trigger({ name: "A1", periodInMinutes: 1, scheduledTime: now });
223+
224+
// monitor 内部会延迟 ~100ms 再执行回调;推进时间触发执行
225+
await vi.advanceTimersByTimeAsync(120);
226+
await monitorPromise;
227+
228+
expect(cb).toHaveBeenCalledTimes(1);
229+
const arg = cb.mock.calls[0][0];
230+
expect(arg.alarm.name).toBe("A1");
231+
expect(arg.isFlushed).toBe(false);
232+
expect(typeof arg.triggeredAt).toBe("number");
233+
234+
// 验证 pending 生命周期:回调前 set,完成后 remove
235+
expect(chrome.storage.local.set).toHaveBeenCalledWith({
236+
"AlarmPending:A1": expect.objectContaining({ alarm: expect.any(Object) }),
237+
});
238+
expect(chrome.storage.local.remove).toHaveBeenCalledWith("AlarmPending:A1");
239+
});
240+
241+
it("延迟≥65s:应判定为补偿执行 isFlushed=true", async () => {
242+
const chrome = buildChromeMock();
243+
const base = Date.now();
244+
const { setPeriodicAlarmCallback, monitorPeriodicAlarm } = await freshImport();
245+
246+
const cb = vi.fn().mockResolvedValue(undefined);
247+
setPeriodicAlarmCallback("Late", cb);
248+
chrome.storage.local.get.mockResolvedValue({});
249+
250+
const monitorPromise = monitorPeriodicAlarm();
251+
252+
// 模拟“延迟 70s 后才触发”的 alarm:scheduledTime 比现在早 70s
253+
chrome.alarms.onAlarm.__trigger({ name: "Late", periodInMinutes: 2, scheduledTime: base - 70_000 });
254+
255+
await vi.advanceTimersByTimeAsync(120);
256+
await monitorPromise;
257+
258+
const arg = cb.mock.calls[0][0];
259+
expect(arg.isFlushed).toBe(true);
260+
});
261+
262+
it("回调抛错也必须清理 pending(保证下次不误判)", async () => {
263+
const chrome = buildChromeMock();
264+
const { setPeriodicAlarmCallback, monitorPeriodicAlarm } = await freshImport();
265+
266+
const cb = vi.fn().mockRejectedValue(new Error("Callback failed"));
267+
setPeriodicAlarmCallback("Err", cb);
268+
chrome.storage.local.get.mockResolvedValue({});
269+
270+
const monitorPromise = monitorPeriodicAlarm();
271+
272+
chrome.alarms.onAlarm.__trigger({ name: "Err", periodInMinutes: 1, scheduledTime: Date.now() });
273+
274+
await vi.advanceTimersByTimeAsync(120);
275+
await monitorPromise;
276+
277+
expect(cb).toHaveBeenCalledTimes(1);
278+
expect(chrome.storage.local.remove).toHaveBeenCalledWith("AlarmPending:Err");
279+
});
280+
281+
it("同一进程内 monitor 只能启动一次(第二次应抛错)", async () => {
282+
buildChromeMock();
283+
const { monitorPeriodicAlarm } = await freshImport();
284+
285+
await monitorPeriodicAlarm();
286+
await expect(monitorPeriodicAlarm()).rejects.toThrow(/cannot be called twice/i);
287+
});
288+
289+
it("SW 重启后:发现未变化的 pending -> 进行补偿执行", async () => {
290+
const chrome = buildChromeMock();
291+
const { setPeriodicAlarmCallback, monitorPeriodicAlarm } = await freshImport();
292+
293+
const cb = vi.fn().mockResolvedValue(undefined);
294+
setPeriodicAlarmCallback("Comp", cb);
295+
296+
const now = Date.now();
297+
// 第一次扫描发现一个旧的 pending;3 秒后再次扫描,内容未变化 -> 说明回调上次未完成,应执行补偿
298+
const pending = {
299+
["AlarmPending:Comp"]: {
300+
alarm: { name: "Comp", periodInMinutes: 1, scheduledTime: now - 90_000 },
301+
isFlushed: true,
302+
triggeredAt: now - 10_000,
303+
},
304+
};
305+
306+
chrome.storage.local.get.mockResolvedValueOnce(pending).mockResolvedValueOnce({ ...pending });
307+
308+
const monitorPromise = monitorPeriodicAlarm();
309+
310+
// monitor 内部约 3s 后再检查一次,推进时间触发补偿逻辑
311+
await vi.advanceTimersByTimeAsync(3050);
312+
313+
expect(cb).toHaveBeenCalledTimes(1);
314+
const arg = cb.mock.calls[0][0];
315+
// 补偿应更新触发时间为“现在”,避免重复补偿
316+
expect(arg.triggeredAt).toBeGreaterThanOrEqual(now);
317+
318+
// 仍然遵循 pending 生命周期:set -> remove
319+
expect(chrome.storage.local.set).toHaveBeenCalledWith({
320+
"AlarmPending:Comp": expect.objectContaining({ alarm: expect.any(Object) }),
321+
});
322+
expect(chrome.storage.local.remove).toHaveBeenCalledWith("AlarmPending:Comp");
323+
324+
await monitorPromise;
325+
});
326+
327+
it("SW 重启后:若 pending 在两次扫描间发生变化 -> 认为已处理/处理中,不做补偿", async () => {
328+
const chrome = buildChromeMock();
329+
const { setPeriodicAlarmCallback, monitorPeriodicAlarm } = await freshImport();
330+
331+
const cb = vi.fn().mockResolvedValue(undefined);
332+
setPeriodicAlarmCallback("NoComp", cb);
333+
334+
const first = {
335+
["AlarmPending:NoComp"]: {
336+
alarm: { name: "NoComp", periodInMinutes: 1, scheduledTime: Date.now() - 70_000 },
337+
isFlushed: true,
338+
triggeredAt: 1000,
339+
},
340+
};
341+
const second = {
342+
["AlarmPending:NoComp"]: { ...first["AlarmPending:NoComp"], triggeredAt: 2000 }, // 触发时间发生变化
343+
};
344+
345+
chrome.storage.local.get.mockResolvedValueOnce(first).mockResolvedValueOnce(second);
346+
347+
const monitorPromise = monitorPeriodicAlarm();
348+
await vi.advanceTimersByTimeAsync(3020);
349+
350+
expect(cb).not.toHaveBeenCalled();
351+
await monitorPromise;
352+
});
353+
354+
it("onAlarm 期间若出现 runtime.lastError:本次事件应被忽略(不写入、不清理、不调用回调)", async () => {
355+
const chrome = buildChromeMock();
356+
const { setPeriodicAlarmCallback, monitorPeriodicAlarm } = await freshImport();
357+
358+
const cb = vi.fn().mockResolvedValue(undefined);
359+
setPeriodicAlarmCallback("E", cb);
360+
chrome.storage.local.get.mockResolvedValue({});
361+
362+
const monitorPromise = monitorPeriodicAlarm();
363+
364+
// 模拟 onAlarm 回调执行前 runtime.lastError 非空,代表框架层错误 -> 应该直接返回
365+
chrome.runtime.lastError = new Error("onAlarm error");
366+
chrome.alarms.onAlarm.__trigger({ name: "E", periodInMinutes: 1, scheduledTime: Date.now() });
367+
chrome.runtime.lastError = null; // 复位
368+
369+
await vi.advanceTimersByTimeAsync(200);
370+
await monitorPromise;
371+
372+
expect(cb).not.toHaveBeenCalled();
373+
expect(chrome.storage.local.set).not.toHaveBeenCalled();
374+
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
375+
});
376+
});

0 commit comments

Comments
 (0)