Files
fangke/sdk/test/events.test.ts
T

211 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> }[] }[];
}
/** 桩掉 performance.getEntriesByType(happy-dom 原生 navigation 恒 []):
* entry=null 模拟无 Navigation API;throwErr 模拟环境异常。afterEach 的
* unstubAllGlobals 负责还原;now 透传 Date.now 以防环境内部偶发调用。 */
function stubNavigation(entry?: { type: string; activationStart: number } | null, throwErr?: boolean) {
vi.stubGlobal("performance", {
now: () => Date.now(),
getEntriesByType: (type: string) => {
if (throwErr) throw new Error("no performance");
return type === "navigation" && entry ? [entry] : [];
},
});
}
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/nav_type/prerendering(path 同源清洗)", async () => {
document.title = "落地页";
Object.defineProperty(document, "referrer", {
value: "https://news.example/article/1",
configurable: true,
});
stubNavigation({ type: "navigate", activationStart: 0 });
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" },
nav_type: "navigate",
prerendering: false,
});
});
it("来源信号:activationStart>0 → prerendering=true(AI 搜索/聚合器预渲染指纹)", async () => {
stubNavigation({ type: "navigate", activationStart: 100 });
const t = mkTransport("site-nav-pp");
startPageTracking(t);
t.flush();
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.properties.nav_type).toBe("navigate");
expect(pv.properties.prerendering).toBe(true);
});
it("来源信号降级:无 Navigation API(空数组)或读取抛异常 → 空串/false,page_view 照发不崩", async () => {
stubNavigation(null); // getEntriesByType 返回 []
let t = mkTransport("site-nav-none");
startPageTracking(t);
t.flush();
let pv = JSON.parse(fetchMock.mock.calls[0][1].body).events.find(
(e: { event_name: string }) => e.event_name === "page_view"
);
expect(pv.properties.nav_type).toBe("");
expect(pv.properties.prerendering).toBe(false);
stubNavigation(undefined, true); // getEntriesByType 抛异常
t = mkTransport("site-nav-throw");
startPageTracking(t);
t.flush();
pv = JSON.parse(fetchMock.mock.calls[1][1].body).events.find(
(e: { event_name: string }) => e.event_name === "page_view"
);
expect(pv.properties.nav_type).toBe("");
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");
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
});
});