Files
fangke/sdk/test/events.test.ts
T
2026-09-18 13:29:24 +08:00

149 lines
6.4 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";
// 有效停留已按 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 替换
// (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("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); // 旧实现此处会累计 3000ms
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
});
});