feat(sdk): 停留改单调时钟区间制——15s切分/共享结算/幂等
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+7
-31
@@ -17,32 +17,15 @@ export function navSignals(): { nav_type: string; prerendering: boolean } {
|
||||
}
|
||||
}
|
||||
|
||||
// 有效停留(spec §4.3):页面可见且 30s 内有交互活动才按秒累计 activeMs;
|
||||
// 隐藏(visibilitychange→hidden)或离开(pagehide)时发 page_stay 并 flush(true)。
|
||||
// close 可能连续触发(hidden 后紧跟 pagehide):activeMs 清零后不再重发 stay。
|
||||
// T7 拆分:有效停留(区间制重做)移至 stay.ts(startStayTracking 与 PageSession
|
||||
// 协作:单调时钟区间 / 15s 周期切分 / hidden-pagehide 共享结算幂等)。
|
||||
// 本函数保留 v1 接线兼容(index.ts 过渡期仍调用,T10 改接 page.ts+stay.ts):
|
||||
// 只发 page_view,并在 hidden/pagehide 时 flush(true)(沿 v1 卸载 beacon 路径,
|
||||
// 防首屏事件随页面关闭丢失),不再产生 page_stay。
|
||||
// 红线:任何回调异常不得冒泡到宿主页(spec §4.5)。
|
||||
export function startPageTracking(t: Transport) {
|
||||
let activeMs = 0;
|
||||
let lastAct = Date.now();
|
||||
const act = () => {
|
||||
try {
|
||||
lastAct = Date.now();
|
||||
} catch {
|
||||
/* 静默(spec §4.5 红线:监听器异常不得冒泡到宿主页) */
|
||||
}
|
||||
};
|
||||
["pointerdown", "keydown", "wheel", "touchstart"].forEach((e) =>
|
||||
document.addEventListener(e, act, { passive: true, capture: true })
|
||||
);
|
||||
setInterval(() => {
|
||||
try {
|
||||
if (!document.hidden && Date.now() - lastAct < 30_000) activeMs += 1000;
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
}, 1000);
|
||||
// emit 内 pageContext() 只取一次(pc)复用于 path/title——避免多次读取间
|
||||
// location 瞬变导致同一事件内字段不一致;page_view 另将 pc 传入复用
|
||||
// location 瞬变导致同一事件内字段不一致
|
||||
const emit = (
|
||||
event_name: string,
|
||||
properties: Record<string, unknown> = {},
|
||||
@@ -66,16 +49,9 @@ export function startPageTracking(t: Transport) {
|
||||
);
|
||||
const close = () => {
|
||||
try {
|
||||
// path 必须与 page_view 同源同清洗(pageContext().path 含 query),
|
||||
// 否则带 UTM 进落地页时服务端"page_stay 合并进相邻 page_view"匹配失败
|
||||
if (activeMs > 0) {
|
||||
const path = pageContext().path;
|
||||
emit("page_stay", { active_ms: activeMs, path });
|
||||
}
|
||||
activeMs = 0;
|
||||
t.flush(true);
|
||||
} catch {
|
||||
/* 静默 */
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import type { Transport } from "./transport";
|
||||
import type { PageSession } from "./page";
|
||||
import { pageContext } from "./context";
|
||||
import { uuid } from "./util";
|
||||
|
||||
// 停留区间制(设计 §1.4 / spec §1.1 归属):一条 page_stay = 一段连续有效活动的
|
||||
// 不重叠区间。区间长度只用单调时钟 performance.now 计算——系统墙钟回拨/漂移不改
|
||||
// active_ms;active_from/active_to 是展示用 epoch ms:cut 时取一次墙钟读数 wall,
|
||||
// 按 wall - performance.now() 偏移把两个 perf 端点换算到同一墙基(to-from 恒等于
|
||||
// active_ms,墙钟中途回拨不会产生倒置区间被服务端重算丢弃)。
|
||||
// 活动判定沿用 events.ts 冻结语义(可见 + Date.now() 距最近交互 <30s)。
|
||||
// 服务端(ingest._recompute_session_stay)按 page_id 对区间做并集拼回总时长,
|
||||
// 本模块只须保证:一条区间 = 一段连续活动;相邻区间不重叠——重开时的回溯
|
||||
// (nowPerf-1s)被 lastCutEnd 钳制:1s tick 与 stayIntervalMs 周期定时器在同一
|
||||
// 瞬间触发时回调顺序不定,两种顺序都不得让新区间起点越过上一切割点。
|
||||
// 红线:所有回调/定时器异常 try/catch 静默,绝不冒泡宿主页(spec §4.5)。
|
||||
|
||||
const TICK_MS = 1_000;
|
||||
const ACTIVITY_WINDOW_MS = 30_000;
|
||||
|
||||
export interface StayOptions {
|
||||
/** 周期切分长度(默认 15s):连续活动每满一刀发一条 flush_reason=periodic 区间 */
|
||||
stayIntervalMs?: number;
|
||||
}
|
||||
|
||||
export function startStayTracking(t: Transport, ps: PageSession, opts: StayOptions = {}): void {
|
||||
const stayIntervalMs = opts.stayIntervalMs ?? 15_000;
|
||||
let intervalStart: number | null = null; // performance.now 基准;null=无 open 区间
|
||||
let lastCutEnd: number | null = null; // 最近一次 cut 的 perf 端点——重开回溯的钳制上界
|
||||
let lastAct = Date.now(); // 墙钟活动戳(沿 events.ts 冻结语义;页面加载即活动)
|
||||
|
||||
// 结算当前区间(幂等:无 open 区间则 no-op——hidden 后 pagehide 不重发)。
|
||||
// attribution()/pageContext() 在 closed 段上仍可调(尾部区间随段归属冻结)。
|
||||
const cut = (reason: string) => {
|
||||
try {
|
||||
if (intervalStart === null) return;
|
||||
const startPerf = intervalStart;
|
||||
const endPerf = performance.now();
|
||||
intervalStart = null; // 先收状态再 emit:任何异常不留悬挂区间(不重发)
|
||||
lastCutEnd = endPerf;
|
||||
if (endPerf <= startPerf) return; // perf 非单调的异常环境:丢弃零/负区间
|
||||
const wall = Date.now(); // from/to 同基换算——展示墙钟,不参与长度计算
|
||||
const a = ps.attribution();
|
||||
const pc = pageContext();
|
||||
t.push({
|
||||
event_id: uuid(),
|
||||
event_name: "page_stay",
|
||||
occurred_at: new Date(wall).toISOString(),
|
||||
path: pc.path,
|
||||
title: pc.title,
|
||||
properties: {
|
||||
active_ms: endPerf - startPerf,
|
||||
path: pc.path, // 顶层 path 同源(服务端"page_stay 合并进相邻 page_view")
|
||||
active_from: wall - endPerf + startPerf,
|
||||
active_to: wall,
|
||||
flush_reason: reason,
|
||||
measurement_version: "2", // 区间制口径标记(v1 为 active_ms 累加制)
|
||||
},
|
||||
site_id: a.site_id,
|
||||
visitor_id: a.visitor_id,
|
||||
session_id: a.session_id,
|
||||
page_id: a.page_id,
|
||||
seq: a.seq,
|
||||
// V1.1 预留字段从快照展开(非字面量)——将来 attribution() 填真实值时自动携带
|
||||
purpose: a.purpose,
|
||||
epoch: a.epoch,
|
||||
grant_ref: a.grant_ref,
|
||||
});
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
};
|
||||
|
||||
// 1s tick:活动判定 + 开区间(回溯本 tick——tick 触发时活动已持续至多 1s)。
|
||||
// 失活/隐藏即 cut("periodic") 悬置:恢复后另开新区间(起点=活动恢复点),
|
||||
// 空闲/隐藏跨度不计入任何区间——区间语义纯净:连续活动段。
|
||||
setInterval(() => {
|
||||
try {
|
||||
const active = !document.hidden && Date.now() - lastAct < ACTIVITY_WINDOW_MS;
|
||||
if (!active) {
|
||||
cut("periodic"); // 无 open 区间时 no-op
|
||||
return;
|
||||
}
|
||||
if (intervalStart === null) {
|
||||
const backdate = performance.now() - TICK_MS;
|
||||
intervalStart = lastCutEnd !== null && backdate < lastCutEnd ? lastCutEnd : backdate;
|
||||
}
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
}, TICK_MS);
|
||||
|
||||
// 周期切一刀:连续活动每满 stayIntervalMs 发一条(服务端并集拼回总时长)。
|
||||
setInterval(() => {
|
||||
try {
|
||||
cut("periodic");
|
||||
} catch {
|
||||
/* 静默(spec §4.5) */
|
||||
}
|
||||
}, stayIntervalMs);
|
||||
|
||||
// 活动监听:本模块独立记录停留判定用的活动戳(与 page.ts 的 touch 并存期——
|
||||
// page.ts 管会话空闲检查点,这里管停留活动窗;T10 接线时再评估合并)。
|
||||
const act = () => {
|
||||
try {
|
||||
lastAct = Date.now();
|
||||
} catch {
|
||||
/* 静默(spec §4.5:监听器异常不得冒泡到宿主页) */
|
||||
}
|
||||
};
|
||||
["pointerdown", "keydown", "wheel", "touchstart"].forEach((name) =>
|
||||
document.addEventListener(name, act, { passive: true, capture: true })
|
||||
);
|
||||
|
||||
// hidden / pagehide:结算 + flush(true)(沿 v1 卸载 beacon 路径,尾部数据不驻队列)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
try {
|
||||
if (document.hidden) {
|
||||
cut("hidden");
|
||||
t.flush(true);
|
||||
}
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
});
|
||||
window.addEventListener("pagehide", () => {
|
||||
try {
|
||||
cut("pagehide");
|
||||
t.flush(true);
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
});
|
||||
// SPA 路由切段(PageSession.close → onClose,晚注册已被 T6 修复为立即补触发):
|
||||
// 尾部区间随段结算。pagehide 也会先关段(page.ts 的 pagehide 监听注册更早),
|
||||
// 按 document.hidden 区分——卸载路径归 "pagehide"(上面自己的监听已结算则此处
|
||||
// 幂等 no-op),SPA 切段归 "route_change"。切段不 flush:页面仍在台前,周期照送。
|
||||
ps.onClose(() => {
|
||||
try {
|
||||
cut(document.hidden ? "pagehide" : "route_change");
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
});
|
||||
}
|
||||
+10
-72
@@ -3,7 +3,8 @@ import { Transport } from "../src/transport";
|
||||
import { mergeConfig } from "../src/config";
|
||||
import { startPageTracking } from "../src/events";
|
||||
|
||||
// 有效停留(spec §4.3):可见 + 30s 内有活动才按秒累计。
|
||||
// 有效停留已按 T7 拆分至 stay.ts(区间制:单调时钟 / 15s 切分 / 共享结算幂等),
|
||||
// 本文件只守 page_view(含 nav 信号冻结)与卸载 flush 路径;区间制语义见 stay.test.ts。
|
||||
// 注意:startPageTracking 在 document/window 上注册的监听跨用例存留(匿名函数无法摘除),
|
||||
// 故每个用例用独立 siteId,并按 body.site_id 过滤断言,避免旧 tracker 的 close 串扰计数。
|
||||
// happy-dom 的 navigator.sendBeacon 会发真实网络请求——beforeEach 里整体 stubGlobal 替换
|
||||
@@ -131,80 +132,17 @@ describe("events / startPageTracking", () => {
|
||||
expect(pv.properties.prerendering).toBe(false);
|
||||
});
|
||||
|
||||
it("有效停留:可见 + pointerdown 后 3s → close 发 page_stay(active_ms=3000)", async () => {
|
||||
window.history.pushState({}, "", "/p4");
|
||||
const t = mkTransport("site-t4");
|
||||
it("T7 拆分:停留移交 stay.ts——本模块不再产生 page_stay;pagehide 只 flush(true) 送走队列", async () => {
|
||||
window.history.pushState({}, "", "/p-t7-split");
|
||||
const t = mkTransport("site-t7-split");
|
||||
startPageTracking(t);
|
||||
document.dispatchEvent(new Event("pointerdown"));
|
||||
await vi.advanceTimersByTimeAsync(3000); // 1s interval 走 3 拍
|
||||
await vi.advanceTimersByTimeAsync(3000); // 旧实现此处会累计 3000ms
|
||||
|
||||
window.dispatchEvent(new Event("pagehide")); // 触发 close
|
||||
expect(fetchMock).not.toHaveBeenCalled(); // close 走 flush(true) → sendBeacon 而非 fetch
|
||||
const bodies = await beaconBodies("site-t4");
|
||||
window.dispatchEvent(new Event("pagehide")); // 卸载路径保留:flush(true)
|
||||
expect(fetchMock).not.toHaveBeenCalled(); // 走 sendBeacon 而非 fetch
|
||||
const bodies = await beaconBodies("site-t7-split");
|
||||
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("30s 无活动不累计:单次活动后空闲 35s → active_ms 恰为 29000 后停止", async () => {
|
||||
window.history.pushState({}, "", "/pidle");
|
||||
const t = mkTransport("site-idle");
|
||||
startPageTracking(t);
|
||||
document.dispatchEvent(new Event("pointerdown")); // 仅此一次活动
|
||||
await vi.advanceTimersByTimeAsync(35_000);
|
||||
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
const bodies = await beaconBodies("site-idle");
|
||||
expect(bodies).toHaveLength(1);
|
||||
const stay = bodies[0].events.find((e) => e.event_name === "page_stay")!;
|
||||
// tick 于 +1s..+29s 时 Date.now()-lastAct<30000 成立(+30s 起等于 30000 不成立)
|
||||
expect(stay.properties.active_ms).toBe(29000);
|
||||
});
|
||||
|
||||
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
|
||||
expect(bodies[0].events.map((e) => e.event_name)).toEqual(["page_view"]); // 无 page_stay
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
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"); // 失活悬置属周期结算
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user