import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Transport } from "../src/transport"; import { mergeConfig } from "../src/config"; import { startPageSession, getPageSession } from "../src/page"; // PageSession(spec §1.1):事件入队时冻结归属快照(flush 不再重读身份); // 会话恢复(idle>30min)三个检查点(交互 / visibilitychange→visible / flush 前); // bfcache 复活沿用 page_id;SPA 规范化 path(location.pathname)变化才切段。 // 注意:startPageSession 安装的监听/history 包装跨用例存留(hooksInstalled 模块级), // 但所有回调引用调用时的模块 current——旧用例监听只作用到最新段;每用例独立 // siteId 并按 site_id 过滤已 flush 事件,防旧 transport 串扰(沿 events.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}$/; let fetchMock: ReturnType; function stubNavigator() { 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: vi.fn().mockReturnValue(true), }); } function mkTransport(siteId: string) { // flushMs 拉大到 24h:快进 35min 绝不触发定时器 flush,fetch 只由显式 flush 驱动 return new Transport( mergeConfig({ siteId, endpoint: "/api/collect/batch", batchSize: 50, flushMs: 86_400_000 }) ); } /** 汇总本 site 的全部已 flush 事件(跨多次 flush,免疫队列节奏) */ function flushedEvents(siteId: string) { const out: Record[] = []; 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; } /** 模拟浏览器 back/forward:URL 变化不经包装(直接走原型方法)+ 派发 popstate */ function browserNav(url: string) { const proto = Object.getPrototypeOf(window.history) as { pushState: (data: unknown, title: string, url?: string | URL | null) => void; }; proto.pushState.call(window.history, {}, "", url); window.dispatchEvent(new Event("popstate")); } describe("PageSession / startPageSession", () => { 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(); delete (document as { hidden?: boolean }).hidden; // 还原 happy-dom 原型 getter }); it("事件入队时冻结归属:flush 延迟且期间会话超时,已拿到的快照仍带旧 session_id", () => { const t = mkTransport("site-freeze"); const ps = startPageSession(t); expect(ps.pageId).toMatch(UUID_RE); const frozen = ps.attribution(); // 模拟业务事件入队时取的归属快照 vi.advanceTimersByTime(35 * 60_000); // flush 延迟,期间会话超时 document.dispatchEvent(new Event("pointerdown")); // 新交互 → resume(sid 换新) const after = ps.attribution(); expect(after.session_id).not.toBe(frozen.session_id); // 之后的新事件归新会话 expect(frozen.session_id).toMatch(UUID_RE); // 旧快照不被追溯改写(消灭 D02 归属漂移) expect(frozen.page_id).toBe(after.page_id); // 同页段:page_id 不变 expect(frozen.seq).toBeLessThan(after.seq); // seq 继续推进 t.flush(); const sr = flushedEvents("site-freeze").find((e) => e.event_name === "session_resume")!; expect(sr.session_id).toBe(after.session_id); // resume 事件归新会话 expect(sr.properties.prev_session_id).toBe(frozen.session_id); }); it("会话恢复:空闲>30min 后交互 → session_resume(prev_session_id/reason)+ 新 sid", () => { const t = mkTransport("site-resume"); const ps = startPageSession(t); const sidOld = ps.attribution().session_id; vi.advanceTimersByTime(30 * 60_000); // 恰 30min:未超 IDLE(> 判定),交互只续期不恢复 document.dispatchEvent(new Event("pointerdown")); expect(ps.attribution().session_id).toBe(sidOld); t.flush(); expect(flushedEvents("site-resume").some((e) => e.event_name === "session_resume")).toBe(false); vi.advanceTimersByTime(31 * 60_000); // 距上次活动 31min:下次交互触发 resume document.dispatchEvent(new Event("keydown")); const sidNew = ps.attribution().session_id; expect(sidNew).not.toBe(sidOld); t.flush(); const evts = flushedEvents("site-resume"); const srs = evts.filter((e) => e.event_name === "session_resume"); expect(srs).toHaveLength(1); // 边界续期那次交互不产生 resume const sr = srs[0]; expect(sr.properties).toEqual({ prev_session_id: sidOld, reason: "idle_timeout" }); expect(sr.session_id).toBe(sidNew); expect(sr.page_id).toBe(ps.pageId); // resume 发生在本页段内 expect(sr.site_id).toBe("site-resume"); expect(sr.visitor_id).toMatch(UUID_RE); const pv = evts.find((e) => e.event_name === "page_view")!; expect(sr.page_id).toBe(pv.page_id); // 与本段 page_view 同 page_id }); it("恢复检查点三处:交互 / visibilitychange→visible / flush 前,各自独立触发 resume", () => { // (1) 交互检查点 let t = mkTransport("site-cp-interact"); let ps = startPageSession(t); const before1 = ps.attribution().session_id; vi.advanceTimersByTime(31 * 60_000); expect(ps.attribution().session_id).toBe(before1); // 时间流逝本身不恢复:须有检查点 document.dispatchEvent(new Event("pointerdown")); expect(ps.attribution().session_id).not.toBe(before1); // (2) visibilitychange→visible 检查点(hidden 期间不推进活动:隐藏 35min 后回前台即恢复) t = mkTransport("site-cp-visible"); ps = startPageSession(t); const before2 = ps.attribution().session_id; Object.defineProperty(document, "hidden", { value: true, configurable: true }); document.dispatchEvent(new Event("visibilitychange")); // hidden → 不检查不刷新活动 vi.advanceTimersByTime(35 * 60_000); expect(ps.attribution().session_id).toBe(before2); // 隐藏期间无检查点、隐藏不算活动 delete (document as { hidden?: boolean }).hidden; // 恢复可见 document.dispatchEvent(new Event("visibilitychange")); // visible → maybeResume expect(ps.attribution().session_id).not.toBe(before2); t.flush(); const sr = flushedEvents("site-cp-visible").find((e) => e.event_name === "session_resume")!; expect(sr.properties).toEqual({ prev_session_id: before2, reason: "idle_timeout" }); // (3) flush 前检查点(T8 由 transport 于 flush 前调用——同一 API) t = mkTransport("site-cp-flush"); ps = startPageSession(t); const before3 = ps.attribution().session_id; vi.advanceTimersByTime(31 * 60_000); ps.maybeResume(); expect(ps.attribution().session_id).not.toBe(before3); }); it("bfcache:pageshow persisted=true → closed 复位、沿用原 page_id、seq 继续、不产生新 page_view", () => { const t = mkTransport("site-bf"); const ps = startPageSession(t); const a1 = ps.attribution(); // seq=2(page_view 已占本段 seq=1) window.dispatchEvent(new Event("pagehide")); // 进 bfcache:段关闭 expect(ps.isClosed).toBe(true); const ev = new Event("pageshow"); Object.defineProperty(ev, "persisted", { value: true }); window.dispatchEvent(ev); expect(getPageSession()).toBe(ps); // 同一实例复活(不新建段) expect(ps.isClosed).toBe(false); const a2 = ps.attribution(); expect(a2.page_id).toBe(a1.page_id); // 沿用原 page_id expect(a2.seq).toBe(a1.seq + 1); // seq 继续,不重置 expect(a2.session_id).toBe(a1.session_id); // 未超时:sid 不变 t.flush(); expect(flushedEvents("site-bf").filter((e) => e.event_name === "page_view")).toHaveLength(1); }); it("bfcache 停留超 30min:复活时补 session_resume(沿用原 page_id,仍不产生新 page_view)", () => { const t = mkTransport("site-bf-late"); const ps = startPageSession(t); const sid = ps.attribution().session_id; vi.advanceTimersByTime(31 * 60_000); window.dispatchEvent(new Event("pagehide")); const ev = new Event("pageshow"); Object.defineProperty(ev, "persisted", { value: true }); window.dispatchEvent(ev); t.flush(); const evts = flushedEvents("site-bf-late"); const sr = evts.find((e) => e.event_name === "session_resume")!; expect(sr.properties).toEqual({ prev_session_id: sid, reason: "idle_timeout" }); expect(sr.page_id).toBe(ps.pageId); expect(evts.filter((e) => e.event_name === "page_view")).toHaveLength(1); }); it("SPA:pushState path 变化切段(新 page_id+page_view);query/replaceState/hash 变化不切;popstate path 变化切段", () => { window.history.pushState({}, "", "/spa-a"); // 设初始路径(切段杂事件落旧 transport,按 site 过滤) const t = mkTransport("site-spa"); const ps1 = startPageSession(t); expect(ps1.path).toBe("/spa-a"); window.history.pushState({}, "", "/spa-b?utm_source=spa"); // path 变化 → 切段 const ps2 = getPageSession()!; expect(ps2).not.toBe(ps1); expect(ps1.isClosed).toBe(true); expect(ps2.path).toBe("/spa-b"); expect(ps2.pageId).not.toBe(ps1.pageId); window.history.pushState({}, "", "/spa-b?utm_source=spa2"); // 仅 query 变化 → 不切 expect(getPageSession()).toBe(ps2); window.history.replaceState({}, "", "/spa-b?utm_source=spa2&clean=1"); // replaceState 地址清洗 → 不切 expect(getPageSession()).toBe(ps2); window.history.pushState({}, "", "/spa-b#anchor"); // hash 变化 → 不切 expect(getPageSession()).toBe(ps2); browserNav("/spa-a"); // 浏览器 back:URL 回退 + popstate → path 变化切段 const ps3 = getPageSession()!; expect(ps3).not.toBe(ps2); expect(ps3.path).toBe("/spa-a"); browserNav("/spa-a#h"); // popstate 但 path 未变(hash 回退)→ 不切 expect(getPageSession()).toBe(ps3); t.flush(); const pvs = flushedEvents("site-spa").filter((e) => e.event_name === "page_view"); expect(pvs).toHaveLength(3); // 初始 + /spa-b + 回退 /spa-a expect(pvs.map((e) => e.page_id)).toEqual([ps1.pageId, ps2.pageId, ps3.pageId]); // 顶层归属字段完整(T8 transport 按四元组分组拆子批) expect(pvs[1].site_id).toBe("site-spa"); expect(pvs[1].visitor_id).toMatch(UUID_RE); expect(pvs[1].session_id).toMatch(UUID_RE); expect(pvs[1].seq).toBe(1); // 新段 seq 从 1 起(page_view 是段内首事件) // page_view 来源字段沿 events.ts 形态(referrer/utm/nav 信号) expect(pvs[1].properties.utm).toEqual({ utm_source: "spa" }); expect(pvs[1].properties.nav_type).toBe(""); expect(pvs[1].properties.prerendering).toBe(false); }); it("seq 页内单调递增且每次快照独立;切段后新段从 1 重新计", () => { const t = mkTransport("site-seq"); const ps = startPageSession(t); const seqs = [ps.attribution().seq, ps.attribution().seq, ps.attribution().seq]; expect(seqs).toEqual([2, 3, 4]); // page_view 已占本段 seq=1 const a = ps.attribution(); const b = ps.attribution(); expect(a).not.toBe(b); // 每次调用返回全新冻结快照(无共享可变引用) expect(a.seq).toBe(5); expect(b.seq).toBe(6); window.history.pushState({}, "", "/seq-next"); // 切段 const ps2 = getPageSession()!; expect(ps2.attribution().seq).toBe(2); // 新段 page_view 占 1,业务事件从 2 起 t.flush(); const pvs = flushedEvents("site-seq").filter((e) => e.event_name === "page_view"); expect(pvs.map((e: Record) => e.seq)).toEqual([1, 1]); // 两段各自由 1 起 }); it("close 幂等且触发 onClose 钩子(尾部停留结算由 stay.ts T7 协作);closed 段不再 resume", () => { const t = mkTransport("site-close"); const ps = startPageSession(t); const hook = vi.fn(); ps.onClose(hook); vi.advanceTimersByTime(31 * 60_000); ps.close(); ps.close(); // hidden+pagehide 连续触发:幂等 expect(hook).toHaveBeenCalledTimes(1); expect(ps.isClosed).toBe(true); ps.maybeResume(); // closed 段不再发 resume t.flush(); expect(flushedEvents("site-close").some((e) => e.event_name === "session_resume")).toBe(false); }); it("onClose 晚注册于 close 之后立即补触发;revive 后再 close 再次触发(bfcache 周期结算)", () => { const t = mkTransport("site-late-hook"); const ps = startPageSession(t); ps.close(); // 先关闭(T7 初始化与 pagehide/路由切换竞态) const hook = vi.fn(); ps.onClose(hook); // 晚注册:立即触发一次,不丢失本次结算时机 expect(hook).toHaveBeenCalledTimes(1); ps.revive(); // bfcache 复活:监听器不因 revive 清空 ps.close(); expect(hook).toHaveBeenCalledTimes(2); // 新周期的 close 再次触发 }); it("flush 前检查点(Transport.flush 预埋):hidden 期间 flush 刷活 ctk_act 后仍能恢复", () => { const t = mkTransport("site-flushcp"); const ps = startPageSession(t); const sid = ps.attribution().session_id; Object.defineProperty(document, "hidden", { value: true, configurable: true }); vi.advanceTimersByTime(31 * 60_000); t.flush(); // 信封 getSessionId() 会 touchActivity——若无 flush 前检查点,空闲被无限续期 delete (document as { hidden?: boolean }).hidden; document.dispatchEvent(new Event("visibilitychange")); // 用户回前台 expect(ps.attribution().session_id).not.toBe(sid); // 恢复未被抑制(OT01) const sr = flushedEvents("site-flushcp").find((e) => e.event_name === "session_resume")!; expect(sr.properties.prev_session_id).toBe(sid); }); });