Files
fangke/sdk/test/page.test.ts
T
2026-09-18 12:45:59 +08:00

269 lines
12 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 { 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<typeof vi.fn>;
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<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;
}
/** 模拟浏览器 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<string, any>) => 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);
});
});