2026-09-15 16:28:14 +08:00
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
|
|
|
|
|
|
|
|
|
|
// index.ts 是 IIFE 打包入口:模块加载即读取全局队列、替换 g.ctk 并重放。
|
|
|
|
|
|
// 隔离手段:vi.resetModules() + 动态 import,每用例拿到全新模块级 transport 状态。
|
2026-09-18 14:56:56 +08:00
|
|
|
|
// startPageSession/startStayTracking 注册的 document/window 监听跨用例存留,
|
|
|
|
|
|
// 用独立 siteId 过滤批次。0.2.0 接线(T10):初始 page_view 由 startPageSession
|
|
|
|
|
|
// 发(seq=1),sdk_loaded 随后入队(seq=2)——队列顺序 [page_view, sdk_loaded]。
|
2026-09-15 16:28:14 +08:00
|
|
|
|
|
|
|
|
|
|
type Cmd = [string, unknown?];
|
|
|
|
|
|
type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] };
|
|
|
|
|
|
const g = globalThis as unknown as {
|
|
|
|
|
|
ctk?: CtkFn;
|
|
|
|
|
|
__ctk_ctx?: Record<string, unknown>;
|
|
|
|
|
|
};
|
|
|
|
|
|
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>;
|
|
|
|
|
|
|
|
|
|
|
|
/** 模拟宿主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;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-18 12:22:05 +08:00
|
|
|
|
it("init 后 sdk_loaded 入队(含版本组:sdk_version/build_id/schema_version/config_version)", async () => {
|
2026-09-15 16:28:14 +08:00
|
|
|
|
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();
|
2026-09-18 12:22:05 +08:00
|
|
|
|
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");
|
2026-09-15 16:28:14 +08:00
|
|
|
|
expect(loaded.event_id).toMatch(UUID_RE);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-18 14:56:56 +08:00
|
|
|
|
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<string, unknown>[]) {
|
|
|
|
|
|
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); // 同段归属
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-15 16:28:14 +08:00
|
|
|
|
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);
|
2026-09-18 14:56:56 +08:00
|
|
|
|
// page_view 由 startPageSession 先发(seq=1),sdk_loaded 随后(seq=2);
|
|
|
|
|
|
// 15s 的 stay 周期切分在同一 fake 时刻触发,但 flush 定时器注册更早(先发)
|
2026-09-15 16:28:14 +08:00
|
|
|
|
expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([
|
|
|
|
|
|
"page_view",
|
2026-09-18 14:56:56 +08:00
|
|
|
|
"sdk_loaded",
|
2026-09-15 16:28:14 +08:00
|
|
|
|
]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-16 19:12:20 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-15 16:28:14 +08:00
|
|
|
|
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");
|
2026-09-18 14:56:56 +08:00
|
|
|
|
// T8 分组拆批:归属组(page_view+sdk_loaded 同四元组)+ 兜底组(track 宿主
|
|
|
|
|
|
// 对象无归属顶层字段,信封走 identity 旧路径)——一次 flush 两个子批
|
|
|
|
|
|
expect(bodies).toHaveLength(2);
|
2026-09-15 16:28:14 +08:00
|
|
|
|
expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([
|
|
|
|
|
|
"page_view",
|
2026-09-18 14:56:56 +08:00
|
|
|
|
"sdk_loaded",
|
2026-09-15 16:28:14 +08:00
|
|
|
|
]);
|
2026-09-18 14:56:56 +08:00
|
|
|
|
expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click"]);
|
|
|
|
|
|
expect(bodies[1].events[0].properties).toEqual({ k: 1 });
|
2026-09-15 16:28:14 +08:00
|
|
|
|
|
|
|
|
|
|
// 重放结束后宿主新调用直接进 dispatch(不再排队丢失)
|
|
|
|
|
|
g.ctk!("track", { ...trackEv, event_id: "e-2", event_name: "cta_click2" });
|
|
|
|
|
|
g.ctk!("flush");
|
|
|
|
|
|
bodies = bodiesOf("site-8");
|
2026-09-18 14:56:56 +08:00
|
|
|
|
expect(bodies).toHaveLength(3);
|
|
|
|
|
|
expect(bodies[2].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]);
|
2026-09-15 16:28:14 +08:00
|
|
|
|
});
|
|
|
|
|
|
});
|