Files
fangke/sdk/test/index.test.ts
T
浅念 dc13ae6e0e feat(sdk): 页面浏览/有效停留/事件入口,打包≤15KB
- events.ts:page_view/page_stay(有效停留=可见+30s 内有活动),
  close 双触发(hidden+pagehide)靠 activeMs 清零不重发
- index.ts:IIFE 入口,队列重放前先替换 g.ctk=dispatch;
  首触归因——__ctk_ctx 除设备环境外带 referrer_domain/utm/path
- transport.ts:stringify 抛错(track 宿主可控对象)按已换出
  批次事件数计 dropped(Task 14 审查加固)
- demo 双页互跳;gzip 2209B ≤ 15360B;测试 35→44

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 16:28:14 +08:00

178 lines
6.4 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("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"]);
});
});