21c3cb2423
- events.ts:act 回调与 visibilitychange 监听体包 try/catch(字面红线) - transport.ts:sendBeacon 返回 false(64KB 超限等)按批事件数计 dropped ——与 stringify 丢包计数对称的缺口 - index.ts:顶层队列重放双层包裹,单个坏条目只跳过自身不吞后续 init/track - 测试:30s 空闲负向(active_ms=29000 后停止)、beacon-false 丢批;44→46 - build.mjs target es2018→es2020(代码依赖 globalThis,目标诚实化) Co-Authored-By: Claude Code <noreply@anthropic.com>
161 lines
7.4 KiB
TypeScript
161 lines
7.4 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||
import { Transport } from "../src/transport";
|
||
import { mergeConfig } from "../src/config";
|
||
import { startPageTracking } from "../src/events";
|
||
|
||
// 有效停留(spec §4.3):可见 + 30s 内有活动才按秒累计。
|
||
// 注意:startPageTracking 在 document/window 上注册的监听跨用例存留(匿名函数无法摘除),
|
||
// 故每个用例用独立 siteId,并按 body.site_id 过滤断言,避免旧 tracker 的 close 串扰计数。
|
||
// happy-dom 的 navigator.sendBeacon 会发真实网络请求——beforeEach 里整体 stubGlobal 替换
|
||
// (Task 14 注释警示),凡 close→flush(true) 路径均不得触网。
|
||
|
||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||
|
||
let fetchMock: ReturnType<typeof vi.fn>;
|
||
let beacon: ReturnType<typeof vi.fn>;
|
||
|
||
function stubNavigator() {
|
||
beacon = vi.fn().mockReturnValue(true);
|
||
vi.stubGlobal("navigator", {
|
||
userAgent:
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
language: "zh-CN",
|
||
hardwareConcurrency: 8,
|
||
sendBeacon: beacon,
|
||
});
|
||
}
|
||
|
||
function mkTransport(siteId: string) {
|
||
return new Transport(
|
||
mergeConfig({ siteId, endpoint: "/api/collect/batch", batchSize: 10, flushMs: 60000 })
|
||
);
|
||
}
|
||
|
||
/** 从 beacon 调用里取指定 site 的请求体(过滤掉旧用例 tracker 的串扰批次) */
|
||
async function beaconBodies(siteId: string) {
|
||
const out = [];
|
||
for (const [, blob] of beacon.mock.calls as unknown as [string, Blob][]) {
|
||
const body = JSON.parse(await blob.text());
|
||
if (body.site_id === siteId) out.push(body);
|
||
}
|
||
return out as { site_id: string; events: { event_name: string; path: string; properties: Record<string, unknown> }[] }[];
|
||
}
|
||
|
||
describe("events / startPageTracking", () => {
|
||
beforeEach(() => {
|
||
localStorage.clear();
|
||
vi.useFakeTimers();
|
||
fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
stubNavigator();
|
||
});
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.useRealTimers(); // 旧 tracker 的 1s interval 挂在各自 fake clock 上,随钟一起废弃
|
||
delete (document as { hidden?: boolean }).hidden; // 还原 happy-dom 原型 getter
|
||
delete (document as { referrer?: string }).referrer;
|
||
});
|
||
|
||
it("page_view 含 path/title/referrer_domain/utm(path 同源清洗)", async () => {
|
||
document.title = "落地页";
|
||
Object.defineProperty(document, "referrer", {
|
||
value: "https://news.example/article/1",
|
||
configurable: true,
|
||
});
|
||
window.history.pushState({}, "", "/land?utm_source=ns&utm_medium=cpc&email=a@b.c");
|
||
const t = mkTransport("site-pv");
|
||
startPageTracking(t);
|
||
t.flush();
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
const pv = body.events.find((e: { event_name: string }) => e.event_name === "page_view");
|
||
expect(pv.event_id).toMatch(UUID_RE);
|
||
expect(pv.occurred_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||
expect(pv.path).toBe("/land?utm_source=ns&utm_medium=cpc"); // email 参数被清洗,utm 保留
|
||
expect(pv.title).toBe("落地页");
|
||
expect(pv.properties).toEqual({
|
||
referrer: "https://news.example/article/1",
|
||
referrer_domain: "news.example",
|
||
utm: { utm_source: "ns", utm_medium: "cpc" },
|
||
});
|
||
});
|
||
|
||
it("有效停留:可见 + pointerdown 后 3s → close 发 page_stay(active_ms=3000)", async () => {
|
||
window.history.pushState({}, "", "/p4");
|
||
const t = mkTransport("site-t4");
|
||
startPageTracking(t);
|
||
document.dispatchEvent(new Event("pointerdown"));
|
||
await vi.advanceTimersByTimeAsync(3000); // 1s interval 走 3 拍
|
||
|
||
window.dispatchEvent(new Event("pagehide")); // 触发 close
|
||
expect(fetchMock).not.toHaveBeenCalled(); // close 走 flush(true) → sendBeacon 而非 fetch
|
||
const bodies = await beaconBodies("site-t4");
|
||
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
|
||
});
|
||
});
|