Files
fangke/sdk/test/index.test.ts
T

245 lines
10 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";
// index.ts 是 IIFE 打包入口:模块加载即读取全局队列、替换 g.ctk 并重放。
// 隔离手段:vi.resetModules() + 动态 import,每用例拿到全新模块级 transport 状态。
// startPageSession/startStayTracking 注册的 document/window 监听跨用例存留,
// 用独立 siteId 过滤批次。0.2.0 接线(T10):初始 page_view 由 startPageSession
// 发(seq=1),sdk_loaded 随后入队(seq=2)——队列顺序 [page_view, sdk_loaded]。
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;
});
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("接线冒烟(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); // 同段归属
});
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");
// T8 分组拆批:归属组(page_view+sdk_loaded 同四元组)+ 兜底组(track 宿主
// 对象无归属顶层字段,信封走 identity 旧路径)——一次 flush 两个子批
expect(bodies).toHaveLength(2);
expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([
"page_view",
"sdk_loaded",
]);
expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click"]);
expect(bodies[1].events[0].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(3);
expect(bodies[2].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]);
});
});