import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // index.ts 是 IIFE 打包入口:模块加载即读取全局队列、替换 g.ctk 并重放。 // 隔离手段:vi.resetModules() + 动态 import,每用例拿到全新模块级 transport 状态。 // startPageSession/startStayTracking/startBusinessTracking 注册的 document/window // 监听跨用例存留,用独立 siteId 过滤批次。0.2.0 接线(T10):初始 page_view 由 // startPageSession 发(seq=1),sdk_loaded 随后入队(seq=2)。0.2.1 接线(T5): // startBusinessTracking 挂业务监听(产品页路径下 init 队列追加 product_view)、 // track 补当前段归属(不再走兜底组)。 type Cmd = [string, unknown?]; type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] }; const g = globalThis as unknown as { ctk?: CtkFn; __ctk_ctx?: Record; }; 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; /** 模拟宿主snippet:先以桩函数排队,再动态加载 index 模块(触发重放) */ async function loadSdk(q: Cmd[] = []) { vi.resetModules(); const stub = (cmd: string, arg?: unknown) => { q.push([cmd, arg]); }; stub.q = q; g.ctk = stub; await import("../src/index"); } function bodiesOf(siteId: string) { return fetchMock.mock.calls .map((c) => JSON.parse(c[1].body as string)) .filter((b: { site_id: string }) => b.site_id === siteId); } /** URL 重置:走原型方法绕开历史用例的 page.ts pushState 包装——不触发旧模块 * 切段杂事件(同 business.test.ts setUrl 模式) */ function setUrl(url: string) { const proto = Object.getPrototypeOf(window.history) as { replaceState: (data: unknown, unused: string, url?: string | URL | null) => void; }; proto.replaceState.call(window.history, {}, "", url); } /** beacon 路径(contact_click 的 flush(true))批次汇总(按信封 site_id 过滤) */ async function beaconBodiesOf(siteId: string) { const out: Record[] = []; const beacon = (navigator as unknown as { sendBeacon: ReturnType }).sendBeacon; 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; } describe("index 入口", () => { beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response); vi.stubGlobal("fetch", fetchMock); // happy-dom 的 sendBeacon 会发真实网络请求——整体替换(Task 14 注释警示) 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: vi.fn().mockReturnValue(true), }); }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); delete g.__ctk_ctx; delete (document as { hidden?: boolean }).hidden; delete (document as { referrer?: string }).referrer; }); it("init 后 sdk_loaded 入队(含版本组:sdk_version/build_id/schema_version/config_version)", async () => { await loadSdk([["init", { siteId: "site-1", endpoint: "/e", flushMs: 60000 }]]); g.ctk!("flush"); const bodies = bodiesOf("site-1"); expect(bodies).toHaveLength(1); const loaded = bodies[0].events.find((e: { event_name: string }) => e.event_name === "sdk_loaded"); expect(loaded).toBeTruthy(); expect(loaded.properties.sdk_version).toBe("0.2.1"); // build_id 由 vitest.config define 注入 "vitest";无 define 环境兜底 "unknown" expect(loaded.properties.build_id).toBe("vitest"); expect(loaded.properties.schema_version).toBe(2); expect(loaded.properties.config_version).toBe("1"); expect(loaded.event_id).toMatch(UUID_RE); }); it("接线冒烟(T10):init 后 page_view+sdk_loaded 两事件均带顶层归属快照(四元组+seq+V1.1 预留位)", async () => { await loadSdk([["init", { siteId: "site-wire", endpoint: "/e", flushMs: 60000 }]]); g.ctk!("flush"); const bodies = bodiesOf("site-wire"); expect(bodies).toHaveLength(1); // 两事件同归属四元组 → 单组子批 const evs = bodies[0].events; expect(evs.map((e: { event_name: string }) => e.event_name)).toEqual(["page_view", "sdk_loaded"]); for (const e of evs as Record[]) { expect(e.site_id).toBe("site-wire"); expect(e.visitor_id).toMatch(UUID_RE); expect(e.session_id).toMatch(UUID_RE); expect(e.page_id).toMatch(UUID_RE); expect(typeof e.seq).toBe("number"); expect(e.purpose).toBe(null); // V1.1 预留位从快照展开(非字面量) expect(e.epoch).toBe(null); expect(e.grant_ref).toBe(null); } expect(evs[0].seq).toBe(1); // 初始 page_view:段内首快照(startPageSession 内发) expect(evs[1].seq).toBe(2); // sdk_loaded:归属取自同一 PageSession expect(evs[0].page_id).toBe(evs[1].page_id); // 同段归属 }); it("track 补归属(T5):init 后 track 事件信封带顶层归属(四元组+seq+V1.1 预留位),不再走兜底组", async () => { await loadSdk([["init", { siteId: "site-track", endpoint: "/e", flushMs: 60000 }]]); g.ctk!("track", { event_id: "e-track-1", event_name: "cta_click", occurred_at: new Date().toISOString(), path: "/anywhere", title: "t", properties: { k: 1 }, }); g.ctk!("flush"); const bodies = bodiesOf("site-track"); // track 补当前段归属 → 与 page_view/sdk_loaded 同四元组,单组子批 // (兜底组路径只剩 PageSession 未启动的异常接线场景) expect(bodies).toHaveLength(1); const evs = bodies[0].events; expect(evs.map((e: { event_name: string }) => e.event_name)).toEqual([ "page_view", "sdk_loaded", "cta_click", ]); const ev = evs[2] as Record; expect(ev.event_id).toBe("e-track-1"); // 宿主字段原样保留(归属展开在后只补/覆盖归属键) expect(ev.properties).toEqual({ k: 1 }); expect(ev.site_id).toBe("site-track"); expect(ev.visitor_id).toMatch(UUID_RE); expect(ev.session_id).toMatch(UUID_RE); expect(ev.page_id).toBe(evs[0].page_id); // 与初始 page_view 同段 expect(ev.seq).toBe(3); // page_view=1、sdk_loaded=2 之后 expect(ev.purpose).toBe(null); // V1.1 预留位从快照展开 expect(ev.epoch).toBe(null); expect(ev.grant_ref).toBe(null); }); it("init 接线 business(T5):产品页路径下队列 [page_view,sdk_loaded,product_view];wa.me 点击 → contact_click beacon 冒烟", async () => { setUrl("/product/hydraulic-press-100t/"); await loadSdk([["init", { siteId: "site-biz", endpoint: "/e", flushMs: 60000 }]]); g.ctk!("flush"); const bodies = bodiesOf("site-biz"); expect(bodies).toHaveLength(1); // 三事件同四元组 → 单组子批 const evs = bodies[0].events; expect(evs.map((e: { event_name: string }) => e.event_name)).toEqual([ "page_view", "sdk_loaded", "product_view", ]); const pv = evs[2] as Record; expect(pv.properties).toEqual({ product_id: "hydraulic-press-100t", inferred: true, rule_version: "v1" }); expect(pv.page_id).toBe(evs[0].page_id); // init 同步判定归当前段 // business 监听已装:点击 wa.me 链接 → contact_click 入队并 flush(true) beacon 送达 const a = document.createElement("a"); a.href = "https://wa.me/8613800138000"; document.body.appendChild(a); try { a.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } finally { a.remove(); } const beacons = await beaconBodiesOf("site-biz"); expect(beacons).toHaveLength(1); const cc = beacons[0].events[0]; expect(cc.event_name).toBe("contact_click"); expect(cc.properties).toEqual({ channel: "whatsapp" }); expect(cc.page_id).toBe(pv.page_id); // 归属同段 expect(cc.site_id).toBe("site-biz"); }); it("批次 context 含设备环境 + referrer_domain/utm/path(首触归因链路)", async () => { Object.defineProperty(document, "referrer", { value: "https://news.example/a", configurable: true, }); window.history.pushState({}, "", "/land?utm_source=ns&utm_campaign=m1"); await loadSdk([["init", { siteId: "site-2", endpoint: "/e", flushMs: 60000 }]]); // __ctk_ctx 四类键齐全:设备环境 + referrer_domain + utm + path const ctx = g.__ctk_ctx!; expect(ctx.device_type).toBe("PC"); expect(ctx.os).toBe("Windows"); expect(ctx.browser).toBe("Chrome"); expect(ctx.language).toBe("zh-CN"); expect(typeof ctx.timezone).toBe("string"); expect(ctx.screen).toMatch(/^\d+x\d+$/); expect(ctx.referrer_domain).toBe("news.example"); expect(ctx.utm).toEqual({ utm_source: "ns", utm_campaign: "m1" }); expect(ctx.path).toBe("/land?utm_source=ns&utm_campaign=m1"); g.ctk!("flush"); // 原样透传进批次 body.context const bodies = bodiesOf("site-2"); expect(bodies).toHaveLength(1); expect(bodies[0].context).toEqual(ctx); }); it("localStorage 抛异常:15s 定时 flush 无未捕获异常,身份降级内存后事件照常发出", async () => { vi.stubGlobal("localStorage", { getItem: () => { throw new Error("denied"); }, setItem: () => { throw new Error("denied"); }, removeItem: () => { throw new Error("denied"); }, clear: () => { throw new Error("denied"); }, }); const unhandled: unknown[] = []; const onRej = (e: unknown) => unhandled.push(e); // tsconfig 无 @types/node,经 globalThis 取 process(vitest node worker 内必在) const proc = ( globalThis as { process?: { on(ev: "unhandledRejection", cb: (e: unknown) => void): void; off(ev: "unhandledRejection", cb: (e: unknown) => void): void; }; } ).process; proc?.on("unhandledRejection", onRej); await loadSdk([["init", { siteId: "site-7", endpoint: "/e", flushMs: 15000 }]]); let syncErr: unknown; try { await vi.advanceTimersByTimeAsync(15000); // 定时 flush 到期 } catch (e) { syncErr = e; // fake timers 会把定时回调内的同步异常抛到 advance 调用方 } proc?.off("unhandledRejection", onRej); expect(syncErr).toBeUndefined(); expect(unhandled).toEqual([]); const bodies = bodiesOf("site-7"); expect(bodies).toHaveLength(1); expect(bodies[0].visitor_id).toMatch(UUID_RE); // 内存降级身份仍可用 expect(bodies[0].session_id).toMatch(UUID_RE); // page_view 由 startPageSession 先发(seq=1),sdk_loaded 随后(seq=2); // 15s 的 stay 周期切分在同一 fake 时刻触发,但 flush 定时器注册更早(先发) expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([ "page_view", "sdk_loaded", ]); }); it("UA-CH hints 异步并入 __ctk_ctx:晚于 init 到达,flush 时随批次 context 发出", async () => { // 1s 后才 resolve 的高熵桩(fake timers 控制):保证 init 同步完成时 hints 确实未到 (navigator as { userAgentData?: unknown }).userAgentData = { getHighEntropyValues: () => new Promise((resolve) => setTimeout(() => resolve({ model: "Pixel 7", bitness: "64" }), 1000)), }; await loadSdk([["init", { siteId: "site-hints", endpoint: "/e", flushMs: 60000 }]]); expect(g.__ctk_ctx!.device_model).toBeUndefined(); // hints 未到(1s < 已流逝 0ms) await vi.advanceTimersByTimeAsync(1000); // hints 到达(先于 2s 超时) expect(g.__ctk_ctx!.device_model).toBe("Pixel 7"); expect(g.__ctk_ctx!.platform_version).toBeUndefined(); // 未提供的键不挂 g.ctk!("flush"); const bodies = bodiesOf("site-hints"); expect(bodies).toHaveLength(1); expect(bodies[0].context.device_model).toBe("Pixel 7"); // 并入后 flush 的批次携带 }); it("无 userAgentData 环境:hints 降级为空,__ctk_ctx 不挂键不崩(未捕获拒绝为零)", async () => { const unhandled: unknown[] = []; const onRej = (e: unknown) => unhandled.push(e); const proc = (globalThis as { process?: { on(ev: "unhandledRejection", cb: (e: unknown) => void): void; off(ev: "unhandledRejection", cb: (e: unknown) => void): void } }).process; proc?.on("unhandledRejection", onRej); await loadSdk([["init", { siteId: "site-nohints", endpoint: "/e", flushMs: 60000 }]]); await vi.advanceTimersByTimeAsync(2500); // 超过 2s 降级窗口 proc?.off("unhandledRejection", onRej); expect("device_model" in (g.__ctk_ctx ?? {})).toBe(false); expect(unhandled).toEqual([]); g.ctk!("flush"); // 降级批次照常发送 expect(bodiesOf("site-nohints")).toHaveLength(1); }); it("init 前队列重放:排队的 track 事件入队,g.ctk 已替换为 dispatch", async () => { const trackEv = { event_id: "e-custom", event_name: "cta_click", occurred_at: new Date().toISOString(), path: "/x", title: "t", properties: { k: 1 }, }; await loadSdk([ ["init", { siteId: "site-8", endpoint: "/e", flushMs: 60000 }], ["track", trackEv], ]); expect(typeof g.ctk).toBe("function"); expect(g.ctk!.q).toBeUndefined(); // 桩(带 q)已被 dispatch 替换 g.ctk!("flush"); let bodies = bodiesOf("site-8"); // T5 起 track 补当前段归属:cta_click 与 page_view/sdk_loaded 同四元组 → // 单组子批(兜底组只剩 PageSession 未启动的异常接线场景) expect(bodies).toHaveLength(1); expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([ "page_view", "sdk_loaded", "cta_click", ]); expect(bodies[0].events[2].properties).toEqual({ k: 1 }); expect(bodies[0].events[2].event_id).toBe("e-custom"); // 宿主字段原样保留 // 重放结束后宿主新调用直接进 dispatch(不再排队丢失) g.ctk!("track", { ...trackEv, event_id: "e-2", event_name: "cta_click2" }); g.ctk!("flush"); bodies = bodiesOf("site-8"); expect(bodies).toHaveLength(2); expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]); expect(bodies[1].events[0].site_id).toBe("site-8"); // dispatch 直调同补归属 }); });