Files
fangke/sdk/test/index.test.ts
T
浅念 22d89a3f42 feat: 移动端设备信息最大化采集(型号/UA-CH/视口/方向/触点/内存/网络/深浅色)
SDK:buildEnvContext 同步扩展 12 键(dpr/viewport/orientation/maxTouchPoints/
deviceMemory/languages/colorScheme/standalone/network downlink·rtt·saveData/
微信版本),逐键 try/catch 静默缺则不挂;新增 collectHighEntropyHints(UA-CH
model/platformVersion/architecture/bitness),init 后 Promise.race 2s 超时
异步并入 __ctk_ctx——批次 context flush 时读取,不阻塞首屏。

后端:Visitor 加 device_model/env_extra(迁移 0005);ingest 首批快照
EXTENDED_ENV_KEYS 白名单(值截 100),非首批不覆盖、唯 device_model 空且
本批有值时补填一次(晚到 hints);列表/详情 API 随行输出。

前端:详情访问环境卡设备行升级显示型号(手机 · Pixel 7),追加像素比/
视口/屏幕方向/触点/内存/网络质量/深浅色/微信版本行(env_extra 逐键判空,
旧数据整块不显示);列表设备列主行拼型号(手机·Pixel 7)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 19:12:20 +08:00

212 lines
8.3 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 状态。
// 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<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)", 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.1.0");
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"]);
});
});