25e37f56dc
Co-Authored-By: Claude Code <noreply@anthropic.com>
288 lines
13 KiB
TypeScript
288 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||
import { Transport } from "../src/transport";
|
||
import { mergeConfig } from "../src/config";
|
||
import { PageSession } from "../src/page";
|
||
import { startStayTracking } from "../src/stay";
|
||
|
||
// 停留区间制(spec §1.4 / 设计 1.4):一条 page_stay = 一段连续有效活动的不重叠区间。
|
||
// 时钟模型:区间长度只用单调时钟(performance.now——整体 stub 为受控 perfNow,与
|
||
// fake 墙钟锁步:step() 每秒先把 perf 拨到当秒再走 fake timer,tick 在 fake 第 N 秒
|
||
// 触发时读到 N*1000);active_from/active_to 为展示 epoch ms(cut 时单一墙钟读数换算,
|
||
// from/to 同基 → to-from 恒等于 active_ms,墙钟中途回拨不产生倒置区间)。
|
||
// 活动判定沿用 events.ts 冻结语义:可见 + Date.now() 距最近交互 <30s。
|
||
// 注意:startStayTracking 安装的监听/interval 跨用例存留(匿名无法摘除),每用例
|
||
// 独立 siteId 并按 site 过滤批次(沿 events.test.ts / page.test.ts 模式);
|
||
// happy-dom 的 navigator.sendBeacon 会发真实网络请求——beforeEach 整体 stubGlobal。
|
||
|
||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||
/** fake 墙钟基点:perf 0 对应的 epoch ms(setSystemTime 同值 → active_from/to 断言 = TO+perf) */
|
||
const TO = Date.UTC(2026, 8, 18, 10, 0, 0);
|
||
|
||
let fetchMock: ReturnType<typeof vi.fn>;
|
||
let beacon: ReturnType<typeof vi.fn>;
|
||
let perfNow = 0;
|
||
|
||
function stubEnv() {
|
||
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",
|
||
sendBeacon: beacon,
|
||
});
|
||
// performance 整体受控:now=perfNow(由 step 锁步推进,单调不受墙钟回拨影响);
|
||
// timeOrigin=TO 备查——实现按 cut 时 Date.now()-performance.now() 偏移换算 epoch
|
||
vi.stubGlobal("performance", {
|
||
now: () => perfNow,
|
||
timeOrigin: TO,
|
||
getEntriesByType: () => [],
|
||
});
|
||
}
|
||
|
||
function mkTransport(siteId: string) {
|
||
// flushMs 拉大到 24h:区间结算只入队,flush 全部由用例显式驱动
|
||
return new Transport(
|
||
mergeConfig({ siteId, endpoint: "/api/collect/batch", batchSize: 50, flushMs: 86_400_000 })
|
||
);
|
||
}
|
||
|
||
/** 锁步推进 1 秒:先把 perfNow 拨到当秒(perf 单调,独立于墙钟),再走 1s fake
|
||
* timer——tick 在 fake 第 N 秒触发时 performance.now() 恰为 N*1000。
|
||
* 必须逐秒走:一次跳 N 秒会让沿途所有 tick 读到跳表后的 perfNow,回溯开区出错。 */
|
||
function step() {
|
||
perfNow += 1000;
|
||
vi.advanceTimersByTime(1000);
|
||
}
|
||
function advance(ms: number) {
|
||
for (let i = 0; i < ms / 1000; i++) step();
|
||
}
|
||
|
||
/** beacon 路径(hidden/pagehide 结算的 flush(true))事件汇总 */
|
||
async function beaconEvents(siteId: string) {
|
||
const out: Record<string, any>[] = [];
|
||
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.events);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** fetch 路径(显式 t.flush())事件汇总 */
|
||
function fetchedEvents(siteId: string) {
|
||
const out: Record<string, any>[] = [];
|
||
for (const call of fetchMock.mock.calls as unknown as [string, RequestInit][]) {
|
||
const body = JSON.parse(call[1].body as string);
|
||
if (body.site_id === siteId) out.push(...body.events);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function staysOf(events: Record<string, any>[]) {
|
||
return events.filter((e) => e.event_name === "page_stay");
|
||
}
|
||
|
||
describe("stay / startStayTracking", () => {
|
||
beforeEach(() => {
|
||
localStorage.clear();
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date(TO));
|
||
perfNow = 0;
|
||
fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
stubEnv();
|
||
});
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.useRealTimers(); // 旧 tracker 的 1s/15s interval 挂在各自 fake clock 上,随钟一起废弃
|
||
delete (document as { hidden?: boolean }).hidden; // 还原 happy-dom 原型 getter
|
||
});
|
||
|
||
it("15s 连续活动切 4 条相邻区间:[from,to] 不重叠、flush_reason=periodic", () => {
|
||
window.history.pushState({}, "", "/stay-60s");
|
||
const t = mkTransport("stay-cut");
|
||
const ps = new PageSession(t);
|
||
startStayTracking(t, ps);
|
||
|
||
for (let s = 1; s <= 60; s++) {
|
||
// 每 10s 一次交互维持 30s 活动窗(加载本身计一次活动)
|
||
if (s % 10 === 0) document.dispatchEvent(new Event("pointerdown"));
|
||
step();
|
||
}
|
||
|
||
t.flush();
|
||
const stays = staysOf(fetchedEvents("stay-cut"));
|
||
expect(stays).toHaveLength(4); // 60s / 15s = 4 条
|
||
expect(stays.map((e) => e.properties.active_ms)).toEqual([15000, 15000, 15000, 15000]);
|
||
expect(stays.map((e) => [e.properties.active_from, e.properties.active_to])).toEqual([
|
||
[TO, TO + 15_000],
|
||
[TO + 15_000, TO + 30_000],
|
||
[TO + 30_000, TO + 45_000],
|
||
[TO + 45_000, TO + 60_000],
|
||
]); // 相邻区间首尾相接不重叠(服务端按 page_id 并集拼回总时长)
|
||
expect(stays.every((e) => e.properties.flush_reason === "periodic")).toBe(true);
|
||
});
|
||
|
||
it("hidden 结算当前区间;pagehide 紧随不重发(共享结算状态)", async () => {
|
||
const t = mkTransport("stay-hidden");
|
||
const ps = new PageSession(t);
|
||
startStayTracking(t, ps);
|
||
advance(10_000); // 区间 [0,10s)
|
||
|
||
Object.defineProperty(document, "hidden", { value: true, configurable: true });
|
||
document.dispatchEvent(new Event("visibilitychange")); // cut(hidden) + flush(true)
|
||
advance(5_000); // hidden 期间无活动 tick
|
||
window.dispatchEvent(new Event("pagehide")); // 紧随其后:幂等,不重发
|
||
|
||
const stays = staysOf(await beaconEvents("stay-hidden"));
|
||
expect(stays).toHaveLength(1);
|
||
expect(stays[0].properties.active_ms).toBe(10_000);
|
||
expect(stays[0].properties.active_from).toBe(TO);
|
||
expect(stays[0].properties.active_to).toBe(TO + 10_000);
|
||
expect(stays[0].properties.flush_reason).toBe("hidden");
|
||
});
|
||
|
||
it("hidden 期间不累计;恢复 visible 后活动从恢复点开新区间", async () => {
|
||
const t = mkTransport("stay-resume");
|
||
const ps = new PageSession(t);
|
||
startStayTracking(t, ps, { stayIntervalMs: 60_000 }); // 观察 single 恢复区间(15s 切分另测)
|
||
advance(8_000); // 段 1:[0,8s)
|
||
Object.defineProperty(document, "hidden", { value: true, configurable: true });
|
||
document.dispatchEvent(new Event("visibilitychange"));
|
||
|
||
advance(22_000); // hidden 至 t=30s:不计入
|
||
delete (document as { hidden?: boolean }).hidden;
|
||
document.dispatchEvent(new Event("visibilitychange")); // 恢复可见(本身不开区间)
|
||
advance(9_000); // t=39s:可见但距上次交互 39s ≥30s → 仍不累计
|
||
document.dispatchEvent(new Event("pointerdown")); // 活动恢复
|
||
advance(11_000); // t=50s:新区间自恢复点起
|
||
Object.defineProperty(document, "hidden", { value: true, configurable: true });
|
||
document.dispatchEvent(new Event("visibilitychange")); // 段 2 结算
|
||
|
||
const stays = staysOf(await beaconEvents("stay-resume"));
|
||
expect(stays).toHaveLength(2);
|
||
expect(stays[0].properties.active_ms).toBe(8_000); // 恰为隐藏前区间
|
||
expect(stays[1].properties.active_ms).toBe(11_000); // [39s,50s],从恢复点重新起算
|
||
expect(stays[1].properties.active_from).toBe(TO + 39_000); // 起点=活动恢复时刻
|
||
// 隐藏/空闲跨度 [8s,39s] 不落入任何区间
|
||
});
|
||
|
||
it("单调时钟:墙钟回拨不影响区间长度(active_ms 恒为 perf 差)", async () => {
|
||
const t = mkTransport("stay-clock");
|
||
const ps = new PageSession(t);
|
||
startStayTracking(t, ps, { stayIntervalMs: 60_000 }); // 单区间观察(不 15s 切)
|
||
advance(5_000);
|
||
document.dispatchEvent(new Event("pointerdown"));
|
||
advance(15_000); // t=20s,区间 open
|
||
|
||
vi.setSystemTime(new Date(TO - 8 * 60_000)); // 系统墙钟回拨 8 分钟
|
||
advance(20_000); // t=40s:perf 照常单调推进
|
||
window.dispatchEvent(new Event("pagehide"));
|
||
|
||
const stays = staysOf(await beaconEvents("stay-clock"));
|
||
expect(stays).toHaveLength(1);
|
||
expect(stays[0].properties.active_ms).toBe(40_000); // [0,40s]:回拨不改区间长度
|
||
expect(stays[0].properties.active_to - stays[0].properties.active_from).toBe(40_000); // from/to 同基,恒正不倒置
|
||
});
|
||
|
||
it("flush_reason 正确:pagehide(直接离开)与 route_change(SPA 切段)", async () => {
|
||
// pagehide:无 hidden 前置的直接离开
|
||
let t = mkTransport("stay-pagehide");
|
||
let ps = new PageSession(t);
|
||
startStayTracking(t, ps);
|
||
advance(7_000);
|
||
window.dispatchEvent(new Event("pagehide"));
|
||
expect(staysOf(await beaconEvents("stay-pagehide")).map((e) => e.properties.flush_reason)).toEqual([
|
||
"pagehide",
|
||
]);
|
||
|
||
// route_change:SPA 路由切段(PageSession.close → onClose 结算;页面仍在台前,周期 flush 送)
|
||
t = mkTransport("stay-route");
|
||
ps = new PageSession(t);
|
||
startStayTracking(t, ps);
|
||
advance(7_000);
|
||
ps.close();
|
||
t.flush();
|
||
expect(staysOf(fetchedEvents("stay-route")).map((e) => e.properties.flush_reason)).toEqual([
|
||
"route_change",
|
||
]);
|
||
});
|
||
|
||
it("入队事件结构:active_from/active_to/active_ms/path/measurement_version=2 + 顶层归属", () => {
|
||
window.history.pushState({}, "", "/stay-shape");
|
||
const t = mkTransport("stay-shape");
|
||
const ps = new PageSession(t);
|
||
startStayTracking(t, ps);
|
||
advance(16_000); // 一次周期切分 [0,15s]
|
||
|
||
t.flush();
|
||
const stay = staysOf(fetchedEvents("stay-shape"))[0];
|
||
expect(stay.event_id).toMatch(UUID_RE);
|
||
expect(stay.event_name).toBe("page_stay");
|
||
expect(stay.occurred_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||
expect(stay.path).toBe("/stay-shape");
|
||
expect(stay.title).toBe(document.title);
|
||
// properties 恰为服务端 page_stay 白名单六键(constants.py EVENT_PROP_WHITELIST)
|
||
expect(stay.properties).toEqual({
|
||
active_ms: 15_000,
|
||
path: "/stay-shape", // 供服务端"page_stay 合并进相邻 page_view"
|
||
active_from: TO,
|
||
active_to: TO + 15_000,
|
||
flush_reason: "periodic",
|
||
measurement_version: "2",
|
||
});
|
||
// 顶层归属(events 形态 + Attribution 展开,同 PageSession.push)
|
||
expect(stay.site_id).toBe("stay-shape");
|
||
expect(stay.visitor_id).toMatch(UUID_RE);
|
||
expect(stay.session_id).toMatch(UUID_RE);
|
||
expect(stay.page_id).toBe(ps.pageId);
|
||
expect(stay.seq).toBe(1); // 直接构造的段内首事件
|
||
expect(stay.purpose).toBe(null); // V1.1 预留从快照展开
|
||
expect(stay.epoch).toBe(null);
|
||
expect(stay.grant_ref).toBe(null);
|
||
});
|
||
|
||
it("失活悬置:单次交互后空闲 → 区间在 30s 活动窗末端 cut(periodic),之后不再累计", async () => {
|
||
const t = mkTransport("stay-idle");
|
||
const ps = new PageSession(t);
|
||
startStayTracking(t, ps, { stayIntervalMs: 60_000 });
|
||
document.dispatchEvent(new Event("pointerdown")); // 仅此一次交互(t=0)
|
||
advance(35_000); // tick 于 t=30s 起 Date.now()-lastAct≥30s → 悬置
|
||
window.dispatchEvent(new Event("pagehide")); // 已悬置:无新区间,只补 flush
|
||
|
||
const stays = staysOf(await beaconEvents("stay-idle"));
|
||
expect(stays).toHaveLength(1);
|
||
expect(stays[0].properties.active_ms).toBe(30_000); // 活动窗 [0,30s] 整段
|
||
expect(stays[0].properties.flush_reason).toBe("periodic"); // 失活悬置属周期结算
|
||
});
|
||
|
||
it("stayIntervalMs 边界守卫:NaN 回落默认 15s;0 钳制下限 ≥1s(防高频 cut 灌入)", () => {
|
||
// NaN:非 nullish,绕过 ?? 默认值——须回落 15s 语义(16s 恰一条 [0,15s] periodic)
|
||
let t = mkTransport("stay-nan");
|
||
let ps = new PageSession(t);
|
||
startStayTracking(t, ps, { stayIntervalMs: NaN });
|
||
advance(16_000);
|
||
t.flush();
|
||
let stays = staysOf(fetchedEvents("stay-nan"));
|
||
expect(stays).toHaveLength(1);
|
||
expect(stays[0].properties.active_ms).toBe(15_000);
|
||
expect(stays[0].properties.flush_reason).toBe("periodic");
|
||
|
||
// 0:无守卫时 setInterval(0) 每毫秒 cut 灌入事件;钳到下限后 5s 内切分有界。
|
||
// 首秒 tick/cut 同瞬触发顺序不定——断言保持顺序无关:条数有界、区间 ≥1s、相邻不重叠
|
||
t = mkTransport("stay-zero");
|
||
ps = new PageSession(t);
|
||
startStayTracking(t, ps, { stayIntervalMs: 0 });
|
||
advance(5_000);
|
||
t.flush();
|
||
stays = staysOf(fetchedEvents("stay-zero")).sort(
|
||
(a, b) => a.properties.active_from - b.properties.active_from
|
||
);
|
||
expect(stays.length).toBeGreaterThanOrEqual(1);
|
||
expect(stays.length).toBeLessThanOrEqual(5); // 下限 1s 一刀 → 至多 5 条
|
||
for (const s of stays) expect(s.properties.active_ms).toBeGreaterThanOrEqual(1_000);
|
||
for (let i = 1; i < stays.length; i++) {
|
||
expect(stays[i].properties.active_from).toBeGreaterThanOrEqual(stays[i - 1].properties.active_to); // lastCutEnd 钳制不重叠
|
||
}
|
||
});
|
||
});
|