feat(sdk): 停留改单调时钟区间制——15s切分/共享结算/幂等

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 13:29:24 +08:00
parent d7d5ed9c94
commit d6b2289cee
4 changed files with 419 additions and 103 deletions
+10 -72
View File
@@ -3,7 +3,8 @@ import { Transport } from "../src/transport";
import { mergeConfig } from "../src/config";
import { startPageTracking } from "../src/events";
// 有效停留(spec §4.3):可见 + 30s 内有活动才按秒累计。
// 有效停留已按 T7 拆分至 stay.ts(区间制:单调时钟 / 15s 切分 / 共享结算幂等),
// 本文件只守 page_view(含 nav 信号冻结)与卸载 flush 路径;区间制语义见 stay.test.ts。
// 注意:startPageTracking 在 document/window 上注册的监听跨用例存留(匿名函数无法摘除),
// 故每个用例用独立 siteId,并按 body.site_id 过滤断言,避免旧 tracker 的 close 串扰计数。
// happy-dom 的 navigator.sendBeacon 会发真实网络请求——beforeEach 里整体 stubGlobal 替换
@@ -131,80 +132,17 @@ describe("events / startPageTracking", () => {
expect(pv.properties.prerendering).toBe(false);
});
it("有效停留:可见 + pointerdown 后 3s → close 发 page_stay(active_ms=3000)", async () => {
window.history.pushState({}, "", "/p4");
const t = mkTransport("site-t4");
it("T7 拆分:停留移交 stay.ts——本模块不再产生 page_stay;pagehide 只 flush(true) 送走队列", async () => {
window.history.pushState({}, "", "/p-t7-split");
const t = mkTransport("site-t7-split");
startPageTracking(t);
document.dispatchEvent(new Event("pointerdown"));
await vi.advanceTimersByTimeAsync(3000); // 1s interval 走 3 拍
await vi.advanceTimersByTimeAsync(3000); // 旧实现此处会累计 3000ms
window.dispatchEvent(new Event("pagehide")); // 触发 close
expect(fetchMock).not.toHaveBeenCalled(); // close 走 flush(true) → sendBeacon 而非 fetch
const bodies = await beaconBodies("site-t4");
window.dispatchEvent(new Event("pagehide")); // 卸载路径保留:flush(true)
expect(fetchMock).not.toHaveBeenCalled(); // 走 sendBeacon 而非 fetch
const bodies = await beaconBodies("site-t7-split");
expect(bodies).toHaveLength(1);
expect(bodies[0].events.map((e) => e.event_name)).toEqual(["page_view", "page_stay"]);
const stay = bodies[0].events[1];
expect(stay.properties.active_ms).toBe(3000);
expect(stay.path).toBe("/p4"); // 顶层 path 与 page_view 同源
expect(stay.properties.path).toBe("/p4"); // properties.path 供服务端合并相邻 page_view
});
it("hidden 后不累计:visibilitychange→close 发 page_stay 并 flush(true);再次 close 不重发", async () => {
window.history.pushState({}, "", "/p5");
const t = mkTransport("site-t5");
startPageTracking(t);
document.dispatchEvent(new Event("pointerdown"));
await vi.advanceTimersByTimeAsync(2000); // 累计 2000
Object.defineProperty(document, "hidden", { value: true, configurable: true });
document.dispatchEvent(new Event("visibilitychange")); // close 第 1 次
await vi.advanceTimersByTimeAsync(5000); // hidden 期间不累计(若累计则下步会多出一批)
window.dispatchEvent(new Event("pagehide")); // close 第 2 次(双触发)
const bodies = await beaconBodies("site-t5");
expect(bodies).toHaveLength(1); // 第 2 次 close:activeMs=0 不发 stay,空队列 no-op
const stay = bodies[0].events.find((e) => e.event_name === "page_stay")!;
expect(stay.properties.active_ms).toBe(2000); // 恰为隐藏前累计值
expect(fetchMock).not.toHaveBeenCalled();
});
it("30s 无活动不累计:单次活动后空闲 35s → active_ms 恰为 29000 后停止", async () => {
window.history.pushState({}, "", "/pidle");
const t = mkTransport("site-idle");
startPageTracking(t);
document.dispatchEvent(new Event("pointerdown")); // 仅此一次活动
await vi.advanceTimersByTimeAsync(35_000);
window.dispatchEvent(new Event("pagehide"));
const bodies = await beaconBodies("site-idle");
expect(bodies).toHaveLength(1);
const stay = bodies[0].events.find((e) => e.event_name === "page_stay")!;
// tick 于 +1s..+29s 时 Date.now()-lastAct<30000 成立(+30s 起等于 30000 不成立)
expect(stay.properties.active_ms).toBe(29000);
});
it("隐藏→恢复→再隐藏:两段 stay 各自正确(第二段从 0 重新累计)", async () => {
window.history.pushState({}, "", "/p6");
const t = mkTransport("site-t6");
startPageTracking(t);
document.dispatchEvent(new Event("pointerdown"));
await vi.advanceTimersByTimeAsync(2000); // 段 1:2000ms
Object.defineProperty(document, "hidden", { value: true, configurable: true });
document.dispatchEvent(new Event("visibilitychange")); // close → 第 1 批
delete (document as { hidden?: boolean }).hidden; // 恢复可见(还原原型 getter)
document.dispatchEvent(new Event("visibilitychange")); // 非 hidden → 不 close
document.dispatchEvent(new Event("pointerdown")); // 新活动
await vi.advanceTimersByTimeAsync(3000); // 段 2:3000ms
Object.defineProperty(document, "hidden", { value: true, configurable: true });
document.dispatchEvent(new Event("visibilitychange")); // close → 第 2 批
const bodies = await beaconBodies("site-t6");
expect(bodies).toHaveLength(2);
const s1 = bodies[0].events.find((e) => e.event_name === "page_stay")!;
const s2 = bodies[1].events.find((e) => e.event_name === "page_stay")!;
expect(s1.properties.active_ms).toBe(2000);
expect(s2.properties.active_ms).toBe(3000); // 从 0 重新起算,非 5000
expect(bodies[1].events).toHaveLength(1); // 第 2 批只有 page_stay
expect(bodies[0].events.map((e) => e.event_name)).toEqual(["page_view"]); // 无 page_stay
});
});