diff --git a/sdk/src/events.ts b/sdk/src/events.ts index d0c38f7..78a957f 100644 --- a/sdk/src/events.ts +++ b/sdk/src/events.ts @@ -6,7 +6,8 @@ import { uuid } from "./util"; // 读一次 Navigation Entry 即冻结进 page_view——nav_type ∈ navigate/reload/ // back_forward(浏览器不给则空串);activationStart>0 = 页面由预渲染激活 // (AI 搜索/聚合器预加载的强指纹)。任何环境异常静默(spec §4.5 红线)。 -function navSignals(): { nav_type: string; prerendering: boolean } { +// T6 起导出供 page.ts 的 page_view 复用(T7 拆分后保留于此)。 +export function navSignals(): { nav_type: string; prerendering: boolean } { try { const nav = performance.getEntriesByType("navigation")[0] as { type?: string; activationStart?: number } | undefined; diff --git a/sdk/src/identity.ts b/sdk/src/identity.ts index fb37860..09baeda 100644 --- a/sdk/src/identity.ts +++ b/sdk/src/identity.ts @@ -1,7 +1,8 @@ import { uuid } from "./util"; const K = { vid: "ctk_vid", sid: "ctk_sid", act: "ctk_act" }; -const IDLE = 30 * 60_000; +/** 会话空闲阈值(30min):getSessionId 惰性续期与 PageSession.resume 判定共用 */ +export const IDLE = 30 * 60_000; // localStorage 可能抛异常(隐私模式/禁存储,Safari ITP):统一走带内存降级的 store, // 任何路径都不得让异常冒泡到宿主页(spec §4.5 红线) @@ -36,6 +37,12 @@ export function touchActivity() { store.set(K.act, String(Date.now())); } +/** 最近一次活动时间(ctk_act 墙钟 ms,无记录为 0)——多标签共享活动记录的读端: + * 别的标签有活动时本标签的会话也不应被判空闲(spec §5 多标签语义) */ +export function lastActivityAt(): number { + return +(store.get(K.act) || 0); +} + export function getSessionId(): string { const last = +(store.get(K.act) || 0); let s = store.get(K.sid); @@ -46,3 +53,13 @@ export function getSessionId(): string { touchActivity(); return s; } + +/** 显式开启新会话(PageSession resume 流程用):生成新 sid 落存储并刷新活动, + * 保持 ctk_sid 的 localStorage 一致性(多标签共享)。与 getSessionId 的 + * "读时惰性续期"相对——v2 会话切换由 SDK 显式 resume 事件驱动。 */ +export function newSessionId(): string { + const s = uuid(); + store.set(K.sid, s); + touchActivity(); + return s; +} diff --git a/sdk/src/page.ts b/sdk/src/page.ts new file mode 100644 index 0000000..29ff0bd --- /dev/null +++ b/sdk/src/page.ts @@ -0,0 +1,287 @@ +import type { Transport } from "./transport"; +import type { Attribution } from "./types"; +import { pageContext } from "./context"; +import { navSignals } from "./events"; +import { uuid } from "./util"; +import { + getVisitorId, + getSessionId, + newSessionId, + lastActivityAt, + touchActivity, + IDLE, +} from "./identity"; + +// PageSession(spec §1.1):一个"页面段"的归属上下文。段 = 初始加载或 SPA 规范化 +// path 变化之间的一段。事件入队时经 attribution() 冻结四元组快照(seq 自增), +// flush 不再重读身份——会话恢复只影响之后的快照,不追溯已冻结事件(消灭 D02)。 +// 红线:任何回调/定时器异常静默,绝不冒泡宿主页(spec §4.5)。 +export class PageSession { + readonly pageId: string; + /** 本段创建时的规范化 path(location.pathname,天然无 query/hash)——SPA 切段比对基准 */ + readonly path: string; + private sessionId: string; // resume 时换新;attribution() 快照读取 + private seqCounter = 0; + // resume 判定用墙钟(与 identity 的 IDLE 语义一致,Date.now——多标签经 ctk_act + // 共享活动记录;fake timers 可控)。hidden 期间不算活动:touch 只由真实交互触发, + // visible/flush 前的检查点只查不刷。 + private lastActivity = Date.now(); + private _closed = false; + private closeListeners: Array<() => void> = []; + + constructor(private t: Transport) { + this.pageId = uuid(); + this.path = (() => { + try { + return location.pathname; + } catch { + return ""; + } + })(); + this.sessionId = getSessionId(); // 创建即冻结本段起始会话(并刷新共享活动) + } + + get isClosed(): boolean { + return this._closed; + } + + /** 归属快照:每次调用 seq 自增并返回冻结的四元组(+V1.1 预留 null 字段位)。 + * 返回全新对象,之后的 resume/close 不影响已拿到的快照。closed 段仍可调用 + * (尾部停留结算需要本段归属)。 */ + attribution(): Attribution { + this.seqCounter += 1; + return { + site_id: this.t.cfg.siteId, + visitor_id: getVisitorId(), + session_id: this.sessionId, + page_id: this.pageId, + seq: this.seqCounter, + purpose: null, + epoch: null, + grant_ref: null, + }; + } + + /** 活动刷新 + 空闲超时检查:先查后刷(先刷则检查永不出界)。 */ + touch(): void { + try { + this.maybeResume(); + this.lastActivity = Date.now(); + touchActivity(); + } catch { + /* 静默(spec §4.5) */ + } + } + + /** 恢复检查点(visibilitychange→visible / flush 前):只查不刷—— + * hidden 期间经过的时间照算空闲,"变可见"本身不构成活动。 */ + maybeResume(): void { + try { + if (this._closed) return; + // 多标签:别的标签的活动(ctk_act)也续本会话——取两者较近的一次 + const last = Math.max(this.lastActivity, lastActivityAt()); + if (Date.now() - last > IDLE) this.resume("idle_timeout"); + } catch { + /* 静默(spec §4.5) */ + } + } + + /** 页面段关闭(路由切换/pagehide):只置位 + 通知钩子;尾部停留由 stay.ts(T7) + * 经 onClose 协作结算。close 幂等(hidden 后紧跟 pagehide 可能连续触发)。 */ + close(): void { + try { + if (this._closed) return; + this._closed = true; + for (const fn of this.closeListeners) { + try { + fn(); + } catch { + /* 静默(spec §4.5) */ + } + } + } catch { + /* 静默(spec §4.5) */ + } + } + + /** bfcache 复活(pageshow persisted=true):closed 复位,pageId/seq 原样继续。 */ + revive(): void { + try { + this._closed = false; + } catch { + /* 静默(spec §4.5) */ + } + } + + onClose(fn: () => void): void { + this.closeListeners.push(fn); + } + + /** 本段 page_view(初始加载 / SPA 新段);bfcache 复活不调此方法(不产生新 PV)。 */ + emitPageView(): void { + try { + if (this._closed) return; + const pc = pageContext(); + this.push( + "page_view", + { + referrer: pc.referrer, + referrer_domain: pc.referrer_domain, + utm: pc.utm, + ...navSignals(), + }, + pc + ); + } catch { + /* 静默(spec §4.5) */ + } + } + + private resume(reason: string): void { + const prev = this.sessionId; + this.sessionId = newSessionId(); // 同步 identity 的 ctk_sid(localStorage 多标签一致性) + this.lastActivity = Date.now(); + this.push("session_resume", { prev_session_id: prev, reason }); + } + + // 事件结构沿 events.ts 形态(event_id/event_name/occurred_at/path/title/properties) + // + 顶层归属字段(Attribution 展开——T8 transport 按四元组分组拆子批) + private push( + event_name: string, + properties: Record, + pc = pageContext() + ): void { + const a = this.attribution(); + this.t.push({ + event_id: uuid(), + event_name, + occurred_at: new Date().toISOString(), + path: pc.path, + title: pc.title, + properties, + site_id: a.site_id, + visitor_id: a.visitor_id, + session_id: a.session_id, + page_id: a.page_id, + seq: a.seq, + purpose: null, + epoch: null, + grant_ref: null, + }); + } +} + +// ---- 模块级单例与宿主 hook 安装(T10 由 index.ts 接线;本模块独立可测) ---- +let current: PageSession | null = null; +let activeTransport: Transport | null = null; +let hooksInstalled = false; + +export function getPageSession(): PageSession | null { + return current; +} + +export function startPageSession(t: Transport): PageSession { + activeTransport = t; + current?.close(); // 重复 init:先收掉旧段(幂等) + current = new PageSession(t); + current.emitPageView(); // 初始 PV 属于新段(会话若已空闲,getSessionId 已换新,无需伪造 resume) + installHooks(); + return current; +} + +/** SPA 路由变化统一入口(pushState/replaceState/popstate 共用): + * 仅规范化 path(location.pathname,天然无 query/hash)变化才切段—— + * query 变化/hash 变化/同 path 的 replaceState(地址清洗)不切(防重复 PV,文档 4.1.5)。 */ +function onRouteChange(): void { + try { + if (!activeTransport) return; + const np = location.pathname; + if (current && !current.isClosed && current.path === np) return; + current?.close(); + current = new PageSession(activeTransport); + current.emitPageView(); + } catch { + /* 静默(spec §4.5) */ + } +} + +/** 安装宿主 hook。所有回调引用模块级 current/activeTransport(调用时读取), + * 重复 startPageSession 只更新引用不重复安装;监听器跨用例存留也只作用到最新段。 + * 红线:每个回调整体 try/catch 静默(spec §4.5)。 */ +function installHooks(): void { + if (hooksInstalled) return; + hooksInstalled = true; + try { + // wrap pushState/replaceState:宿主调用参数原样透传,orig 自身的异常必须原样 + // 冒泡(那是宿主自己的 pushState 调用,吞掉会改变宿主行为)——只静默埋点部分 + for (const name of ["pushState", "replaceState"] as const) { + const orig = history[name].bind(history); + history[name] = (( + data: unknown, + unused: string, + url?: string | URL | null + ) => { + orig(data, unused, url); + try { + onRouteChange(); + } catch { + /* 静默(spec §4.5) */ + } + }) as typeof history.pushState; + } + } catch { + /* history 不可写等环境异常:SPA 切段降级失效,不冒泡(spec §4.5) */ + } + try { + window.addEventListener("popstate", () => { + try { + onRouteChange(); + } catch { + /* 静默 */ + } + }); + // bfcache 恢复(OT02):内存上下文仍在 → 沿用原 page_id 复活,不产生新 PV; + // bfcache 停留可能超 30min → 复活后补查 resume + window.addEventListener("pageshow", (e) => { + try { + if ((e as PageTransitionEvent).persisted && current) { + current.revive(); + current.maybeResume(); + } + } catch { + /* 静默 */ + } + }); + window.addEventListener("pagehide", () => { + try { + current?.close(); + } catch { + /* 静默 */ + } + }); + // visible 恢复检查点(hidden → 不检查不刷新:隐藏期间不算活动) + document.addEventListener("visibilitychange", () => { + try { + if (!document.hidden) current?.maybeResume(); + } catch { + /* 静默 */ + } + }); + // 交互监听沿 events.ts 现有四种(spec §4.3 活动定义)——交互即 touch 检查点 + ["pointerdown", "keydown", "wheel", "touchstart"].forEach((name) => + document.addEventListener( + name, + () => { + try { + current?.touch(); + } catch { + /* 静默 */ + } + }, + { passive: true, capture: true } + ) + ); + } catch { + /* 静默(spec §4.5):addEventListener 不可用(异常环境)——降级无交互检查点 */ + } +} diff --git a/sdk/test/page.test.ts b/sdk/test/page.test.ts new file mode 100644 index 0000000..6525194 --- /dev/null +++ b/sdk/test/page.test.ts @@ -0,0 +1,268 @@ +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); + }); +});