Files
fangke/sdk/test/index.test.ts
T
2026-09-18 23:47:44 +08:00

339 lines
14 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/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<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);
}
/** 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<string, any>[] = [];
const beacon = (navigator as unknown as { sendBeacon: ReturnType<typeof vi.fn> }).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<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("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<string, unknown>;
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<string, unknown>;
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 直调同补归属
});
});