Files
fangke/sdk/test/stay.test.ts
T
2026-09-18 13:45:36 +08:00

288 lines
13 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";
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 钳制不重叠
}
});
});