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; let beacon: ReturnType; 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 }[] }[]; } /** 桩掉 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 }); });