import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // index.ts 是 IIFE 打包入口:模块加载即读取全局队列、替换 g.ctk 并重放。 // 隔离手段:vi.resetModules() + 动态 import,每用例拿到全新模块级 transport 状态。 // startPageTracking 注册的 document/window 监听跨用例存留,用独立 siteId 过滤批次。 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); } 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.0"); // 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("批次 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); expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([ "sdk_loaded", "page_view", ]); }); 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"); expect(bodies).toHaveLength(1); expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([ "sdk_loaded", "page_view", "cta_click", ]); expect(bodies[0].events[2].properties).toEqual({ k: 1 }); // 重放结束后宿主新调用直接进 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"]); }); });