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>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Transport } from "../src/transport";
|
||||
import { mergeConfig } from "../src/config";
|
||||
import { startPageTracking } from "../src/events";
|
||||
|
||||
// 有效停留(spec §4.3):可见 + 30s 内有活动才按秒累计。
|
||||
// 注意:startPageTracking 在 document/window 上注册的监听跨用例存留(匿名函数无法摘除),
|
||||
// 故每个用例用独立 siteId,并按 body.site_id 过滤断言,避免旧 tracker 的 close 串扰计数。
|
||||
// happy-dom 的 navigator.sendBeacon 会发真实网络请求——beforeEach 里整体 stubGlobal 替换
|
||||
// (Task 14 注释警示),凡 close→flush(true) 路径均不得触网。
|
||||
|
||||
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>;
|
||||
let beacon: ReturnType<typeof vi.fn>;
|
||||
|
||||
function stubNavigator() {
|
||||
beacon = vi.fn().mockReturnValue(true);
|
||||
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: beacon,
|
||||
});
|
||||
}
|
||||
|
||||
function mkTransport(siteId: string) {
|
||||
return new Transport(
|
||||
mergeConfig({ siteId, endpoint: "/api/collect/batch", batchSize: 10, flushMs: 60000 })
|
||||
);
|
||||
}
|
||||
|
||||
/** 从 beacon 调用里取指定 site 的请求体(过滤掉旧用例 tracker 的串扰批次) */
|
||||
async function beaconBodies(siteId: string) {
|
||||
const out = [];
|
||||
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 as { site_id: string; events: { event_name: string; path: string; properties: Record<string, unknown> }[] }[];
|
||||
}
|
||||
|
||||
describe("events / startPageTracking", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
stubNavigator();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers(); // 旧 tracker 的 1s interval 挂在各自 fake clock 上,随钟一起废弃
|
||||
delete (document as { hidden?: boolean }).hidden; // 还原 happy-dom 原型 getter
|
||||
delete (document as { referrer?: string }).referrer;
|
||||
});
|
||||
|
||||
it("page_view 含 path/title/referrer_domain/utm(path 同源清洗)", async () => {
|
||||
document.title = "落地页";
|
||||
Object.defineProperty(document, "referrer", {
|
||||
value: "https://news.example/article/1",
|
||||
configurable: true,
|
||||
});
|
||||
window.history.pushState({}, "", "/land?utm_source=ns&utm_medium=cpc&email=a@b.c");
|
||||
const t = mkTransport("site-pv");
|
||||
startPageTracking(t);
|
||||
t.flush();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
const pv = body.events.find((e: { event_name: string }) => e.event_name === "page_view");
|
||||
expect(pv.event_id).toMatch(UUID_RE);
|
||||
expect(pv.occurred_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(pv.path).toBe("/land?utm_source=ns&utm_medium=cpc"); // email 参数被清洗,utm 保留
|
||||
expect(pv.title).toBe("落地页");
|
||||
expect(pv.properties).toEqual({
|
||||
referrer: "https://news.example/article/1",
|
||||
referrer_domain: "news.example",
|
||||
utm: { utm_source: "ns", utm_medium: "cpc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("有效停留:可见 + pointerdown 后 3s → close 发 page_stay(active_ms=3000)", async () => {
|
||||
window.history.pushState({}, "", "/p4");
|
||||
const t = mkTransport("site-t4");
|
||||
startPageTracking(t);
|
||||
document.dispatchEvent(new Event("pointerdown"));
|
||||
await vi.advanceTimersByTimeAsync(3000); // 1s interval 走 3 拍
|
||||
|
||||
window.dispatchEvent(new Event("pagehide")); // 触发 close
|
||||
expect(fetchMock).not.toHaveBeenCalled(); // close 走 flush(true) → sendBeacon 而非 fetch
|
||||
const bodies = await beaconBodies("site-t4");
|
||||
expect(bodies).toHaveLength(1);
|
||||
expect(bodies[0].events.map((e) => e.event_name)).toEqual(["page_view", "page_stay"]);
|
||||
const stay = bodies[0].events[1];
|
||||
expect(stay.properties.active_ms).toBe(3000);
|
||||
expect(stay.path).toBe("/p4"); // 顶层 path 与 page_view 同源
|
||||
expect(stay.properties.path).toBe("/p4"); // properties.path 供服务端合并相邻 page_view
|
||||
});
|
||||
|
||||
it("hidden 后不累计:visibilitychange→close 发 page_stay 并 flush(true);再次 close 不重发", async () => {
|
||||
window.history.pushState({}, "", "/p5");
|
||||
const t = mkTransport("site-t5");
|
||||
startPageTracking(t);
|
||||
document.dispatchEvent(new Event("pointerdown"));
|
||||
await vi.advanceTimersByTimeAsync(2000); // 累计 2000
|
||||
|
||||
Object.defineProperty(document, "hidden", { value: true, configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange")); // close 第 1 次
|
||||
await vi.advanceTimersByTimeAsync(5000); // hidden 期间不累计(若累计则下步会多出一批)
|
||||
window.dispatchEvent(new Event("pagehide")); // close 第 2 次(双触发)
|
||||
|
||||
const bodies = await beaconBodies("site-t5");
|
||||
expect(bodies).toHaveLength(1); // 第 2 次 close:activeMs=0 不发 stay,空队列 no-op
|
||||
const stay = bodies[0].events.find((e) => e.event_name === "page_stay")!;
|
||||
expect(stay.properties.active_ms).toBe(2000); // 恰为隐藏前累计值
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("隐藏→恢复→再隐藏:两段 stay 各自正确(第二段从 0 重新累计)", async () => {
|
||||
window.history.pushState({}, "", "/p6");
|
||||
const t = mkTransport("site-t6");
|
||||
startPageTracking(t);
|
||||
document.dispatchEvent(new Event("pointerdown"));
|
||||
await vi.advanceTimersByTimeAsync(2000); // 段 1:2000ms
|
||||
Object.defineProperty(document, "hidden", { value: true, configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange")); // close → 第 1 批
|
||||
|
||||
delete (document as { hidden?: boolean }).hidden; // 恢复可见(还原原型 getter)
|
||||
document.dispatchEvent(new Event("visibilitychange")); // 非 hidden → 不 close
|
||||
document.dispatchEvent(new Event("pointerdown")); // 新活动
|
||||
await vi.advanceTimersByTimeAsync(3000); // 段 2:3000ms
|
||||
Object.defineProperty(document, "hidden", { value: true, configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange")); // close → 第 2 批
|
||||
|
||||
const bodies = await beaconBodies("site-t6");
|
||||
expect(bodies).toHaveLength(2);
|
||||
const s1 = bodies[0].events.find((e) => e.event_name === "page_stay")!;
|
||||
const s2 = bodies[1].events.find((e) => e.event_name === "page_stay")!;
|
||||
expect(s1.properties.active_ms).toBe(2000);
|
||||
expect(s2.properties.active_ms).toBe(3000); // 从 0 重新起算,非 5000
|
||||
expect(bodies[1].events).toHaveLength(1); // 第 2 批只有 page_stay
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -176,6 +176,22 @@ describe("transport", () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(t.dropped).toBe(1); // maxRetry=0 耗尽 → 按事件数丢弃
|
||||
});
|
||||
|
||||
it("stringify 抛错(track 引入宿主可控对象):已换出批次按事件数计 dropped", () => {
|
||||
const t = new Transport(mkCfg({ batchSize: 10, flushMs: 60000 }));
|
||||
const bad: { event_id: string; self?: unknown } = { event_id: "bad" };
|
||||
bad.self = bad; // 循环引用 → JSON.stringify 抛 TypeError
|
||||
t.push(bad);
|
||||
t.push({ event_id: "ok" });
|
||||
expect(() => t.flush()).not.toThrow();
|
||||
expect(t.dropped).toBe(2); // 换出的 2 条全部落丢,不能静默不计
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
t.push({ event_id: "e3" }); // 队列已换出,后续事件照常
|
||||
t.flush();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body).events).toEqual([{ event_id: "e3" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig", () => {
|
||||
|
||||
Reference in New Issue
Block a user