From dc13ae6e0eace2cee6beae0d8b266df8f651b042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 15 Sep 2026 16:28:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E9=A1=B5=E9=9D=A2=E6=B5=8F?= =?UTF-8?q?=E8=A7=88/=E6=9C=89=E6=95=88=E5=81=9C=E7=95=99/=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=85=A5=E5=8F=A3=EF=BC=8C=E6=89=93=E5=8C=85=E2=89=A4?= =?UTF-8?q?15KB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - events.ts:page_view/page_stay(有效停留=可见+30s 内有活动), close 双触发(hidden+pagehide)靠 activeMs 清零不重发 - index.ts:IIFE 入口,队列重放前先替换 g.ctk=dispatch; 首触归因——__ctk_ctx 除设备环境外带 referrer_domain/utm/path - transport.ts:stringify 抛错(track 宿主可控对象)按已换出 批次事件数计 dropped(Task 14 审查加固) - demo 双页互跳;gzip 2209B ≤ 15360B;测试 35→44 Co-Authored-By: Claude Code --- sdk/demo/index.html | 5 ++ sdk/demo/page2.html | 5 ++ sdk/src/events.ts | 65 ++++++++++++++ sdk/src/index.ts | 54 +++++++++++ sdk/src/transport.ts | 10 ++- sdk/test/events.test.ts | 145 ++++++++++++++++++++++++++++++ sdk/test/index.test.ts | 177 +++++++++++++++++++++++++++++++++++++ sdk/test/transport.test.ts | 16 ++++ 8 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 sdk/demo/index.html create mode 100644 sdk/demo/page2.html create mode 100644 sdk/src/events.ts create mode 100644 sdk/src/index.ts create mode 100644 sdk/test/events.test.ts create mode 100644 sdk/test/index.test.ts diff --git a/sdk/demo/index.html b/sdk/demo/index.html new file mode 100644 index 0000000..bece85a --- /dev/null +++ b/sdk/demo/index.html @@ -0,0 +1,5 @@ +CTK Demo 1 + + +

Demo 页 1

去第 2 页 diff --git a/sdk/demo/page2.html b/sdk/demo/page2.html new file mode 100644 index 0000000..fb7565b --- /dev/null +++ b/sdk/demo/page2.html @@ -0,0 +1,5 @@ +CTK Demo 2 + + +

Demo 页 2

返回第 1 页 diff --git a/sdk/src/events.ts b/sdk/src/events.ts new file mode 100644 index 0000000..06936af --- /dev/null +++ b/sdk/src/events.ts @@ -0,0 +1,65 @@ +import type { Transport } from "./transport"; +import { pageContext } from "./context"; +import { uuid } from "./util"; + +// 有效停留(spec §4.3):页面可见且 30s 内有交互活动才按秒累计 activeMs; +// 隐藏(visibilitychange→hidden)或离开(pagehide)时发 page_stay 并 flush(true)。 +// close 可能连续触发(hidden 后紧跟 pagehide):activeMs 清零后不再重发 stay。 +// 红线:任何回调异常不得冒泡到宿主页(spec §4.5)。 +export function startPageTracking(t: Transport) { + let activeMs = 0; + let lastAct = Date.now(); + const act = () => { + lastAct = Date.now(); + }; + ["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 传入复用 + const emit = ( + event_name: string, + properties: Record = {}, + pc = pageContext() + ) => { + t.push({ + event_id: uuid(), + event_name, + occurred_at: new Date().toISOString(), + path: pc.path, + title: pc.title, + properties, + }); + }; + const pc = pageContext(); + emit( + "page_view", + { referrer: pc.referrer, referrer_domain: pc.referrer_domain, utm: pc.utm }, + pc + ); + 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 { + /* 静默 */ + } + }; + document.addEventListener("visibilitychange", () => { + if (document.hidden) close(); + }); + window.addEventListener("pagehide", close); +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts new file mode 100644 index 0000000..84efd7a --- /dev/null +++ b/sdk/src/index.ts @@ -0,0 +1,54 @@ +import { uuid } from "./util"; +import { mergeConfig } from "./config"; +import { Transport } from "./transport"; +import { startPageTracking } from "./events"; +import { buildEnvContext, pageContext } from "./context"; +import type { CtkConfig } from "./config"; + +// IIFE 打包入口(build.mjs):模块加载即接管全局 ctk 命令队列并重放。 +// 红线:dispatch 内任何异常静默,绝不冒泡到宿主页(spec §4.5)。 +type Cmd = [string, unknown?]; +type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] }; +const g = globalThis as unknown as { + ctk?: CtkFn; + __ctk_ctx?: unknown; +}; +const q: Cmd[] = (g.ctk?.q as Cmd[] | undefined) || []; +let transport: Transport | null = null; + +function dispatch(cmd: string, arg?: unknown) { + try { + if (cmd === "init" && !transport) { + const cfg = mergeConfig((arg ?? {}) as CtkConfig); + // 首触归因:批次 context 除设备环境外必须带 referrer_domain/utm/path—— + // 服务端只从 context 读首触,缺了则首触/会话来源恒空 + const pc = pageContext(); + g.__ctk_ctx = { + ...buildEnvContext(), + referrer_domain: pc.referrer_domain, + utm: pc.utm, + path: pc.path, + }; + transport = new Transport(cfg); + transport.push({ + event_id: uuid(), + event_name: "sdk_loaded", + occurred_at: new Date().toISOString(), + path: pc.path, + title: pc.title, + properties: { sdk_version: "0.1.0" }, + }); + startPageTracking(transport); + } else if (cmd === "track" && transport) { + transport.push(arg as object); + } else if (cmd === "flush" && transport) { + transport.flush(); + } + } catch { + /* 任何异常静默,绝不冒泡到宿主页(spec §4.5) */ + } +} + +// 必须先替换全局入口再重放队列:重放期间宿主新调用直接进 dispatch,不再排队 +g.ctk = dispatch; +q.forEach(([c, a]) => dispatch(c, a)); diff --git a/sdk/src/transport.ts b/sdk/src/transport.ts index 4683a8f..113bc66 100644 --- a/sdk/src/transport.ts +++ b/sdk/src/transport.ts @@ -7,8 +7,8 @@ export type TransportConfig = Required; // 批量传输:满 batchSize 或 flushMs 到期即发;4xx(除 429)不重试; // 指数退避 + 抖动重试;重试耗尽按本批事件数计 dropped;queueMax 溢出丢最旧。 // 红线:任何异常不得冒泡到宿主页(定时器/事件回调内的异常无人捕获)。 -// 审查留档:events 目前均为 SDK 自产可序列化对象;若未来引入受控外的 events -// 来源,需处理 stringify 抛错时已换出批次的计数(当前静默丢弃不计 dropped)。 +// 审查留档(Task 14 结论):index.ts 的 track 分支已引入宿主可控对象—— +// stringify 抛错(如循环引用)时已换出的批次按事件数计 dropped。 export class Transport { private q: object[] = []; private timer: ReturnType | null = null; @@ -33,9 +33,11 @@ export class Transport { this.timer = null; } if (!this.q.length) return; + let n = 0; try { const events = this.q; this.q = []; + n = events.length; // 换出后先记数:stringify 抛错时本批已丢,须计 dropped const body = JSON.stringify({ batch_id: uuid(), site_id: this.cfg.siteId, @@ -55,7 +57,9 @@ export class Transport { } this.send(body, events.length); } catch { - // 定时器/事件回调内的任何异常不得冒泡到宿主页 + // 定时器/事件回调内的任何异常不得冒泡到宿主页; + // stringify 抛错时批次已换出(宿主可控对象),按事件数计丢弃 + this.dropped += n; } } diff --git a/sdk/test/events.test.ts b/sdk/test/events.test.ts new file mode 100644 index 0000000..d441b26 --- /dev/null +++ b/sdk/test/events.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Transport } from "../src/transport"; +import { mergeConfig } from "../src/config"; +import { startPageTracking } from "../src/events"; + +// 有效停留(spec §4.3):可见 + 30s 内有活动才按秒累计。 +// 注意:startPageTracking 在 document/window 上注册的监听跨用例存留(匿名函数无法摘除), +// 故每个用例用独立 siteId,并按 body.site_id 过滤断言,避免旧 tracker 的 close 串扰计数。 +// happy-dom 的 navigator.sendBeacon 会发真实网络请求——beforeEach 里整体 stubGlobal 替换 +// (Task 14 注释警示),凡 close→flush(true) 路径均不得触网。 + +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; +let beacon: ReturnType; + +function stubNavigator() { + 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", + hardwareConcurrency: 8, + sendBeacon: beacon, + }); +} + +function mkTransport(siteId: string) { + return new Transport( + mergeConfig({ siteId, endpoint: "/api/collect/batch", batchSize: 10, flushMs: 60000 }) + ); +} + +/** 从 beacon 调用里取指定 site 的请求体(过滤掉旧用例 tracker 的串扰批次) */ +async function beaconBodies(siteId: string) { + const out = []; + 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); + } + return out as { site_id: string; events: { event_name: string; path: string; properties: Record }[] }[]; +} + +describe("events / startPageTracking", () => { + 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(); // 旧 tracker 的 1s interval 挂在各自 fake clock 上,随钟一起废弃 + delete (document as { hidden?: boolean }).hidden; // 还原 happy-dom 原型 getter + delete (document as { referrer?: string }).referrer; + }); + + it("page_view 含 path/title/referrer_domain/utm(path 同源清洗)", async () => { + document.title = "落地页"; + Object.defineProperty(document, "referrer", { + value: "https://news.example/article/1", + configurable: true, + }); + window.history.pushState({}, "", "/land?utm_source=ns&utm_medium=cpc&email=a@b.c"); + const t = mkTransport("site-pv"); + startPageTracking(t); + t.flush(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const pv = body.events.find((e: { event_name: string }) => e.event_name === "page_view"); + expect(pv.event_id).toMatch(UUID_RE); + expect(pv.occurred_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(pv.path).toBe("/land?utm_source=ns&utm_medium=cpc"); // email 参数被清洗,utm 保留 + expect(pv.title).toBe("落地页"); + expect(pv.properties).toEqual({ + referrer: "https://news.example/article/1", + referrer_domain: "news.example", + utm: { utm_source: "ns", utm_medium: "cpc" }, + }); + }); + + it("有效停留:可见 + pointerdown 后 3s → close 发 page_stay(active_ms=3000)", async () => { + window.history.pushState({}, "", "/p4"); + const t = mkTransport("site-t4"); + startPageTracking(t); + document.dispatchEvent(new Event("pointerdown")); + await vi.advanceTimersByTimeAsync(3000); // 1s interval 走 3 拍 + + window.dispatchEvent(new Event("pagehide")); // 触发 close + expect(fetchMock).not.toHaveBeenCalled(); // close 走 flush(true) → sendBeacon 而非 fetch + const bodies = await beaconBodies("site-t4"); + 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("隐藏→恢复→再隐藏:两段 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 + }); +}); diff --git a/sdk/test/index.test.ts b/sdk/test/index.test.ts new file mode 100644 index 0000000..533e674 --- /dev/null +++ b/sdk/test/index.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +// index.ts 是 IIFE 打包入口:模块加载即读取全局队列、替换 g.ctk 并重放。 +// 隔离手段:vi.resetModules() + 动态 import,每用例拿到全新模块级 transport 状态。 +// startPageTracking 注册的 document/window 监听跨用例存留,用独立 siteId 过滤批次。 + +type Cmd = [string, unknown?]; +type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] }; +const g = globalThis as unknown as { + ctk?: CtkFn; + __ctk_ctx?: Record; +}; +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; + +/** 模拟宿主snippet:先以桩函数排队,再动态加载 index 模块(触发重放) */ +async function loadSdk(q: Cmd[] = []) { + vi.resetModules(); + const stub = (cmd: string, arg?: unknown) => { + q.push([cmd, arg]); + }; + stub.q = q; + g.ctk = stub; + await import("../src/index"); +} + +function bodiesOf(siteId: string) { + return fetchMock.mock.calls + .map((c) => JSON.parse(c[1].body as string)) + .filter((b: { site_id: string }) => b.site_id === siteId); +} + +describe("index 入口", () => { + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response); + vi.stubGlobal("fetch", fetchMock); + // happy-dom 的 sendBeacon 会发真实网络请求——整体替换(Task 14 注释警示) + 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", + hardwareConcurrency: 8, + sendBeacon: vi.fn().mockReturnValue(true), + }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + delete g.__ctk_ctx; + delete (document as { hidden?: boolean }).hidden; + delete (document as { referrer?: string }).referrer; + }); + + it("init 后 sdk_loaded 入队(含 sdk_version)", async () => { + await loadSdk([["init", { siteId: "site-1", endpoint: "/e", flushMs: 60000 }]]); + g.ctk!("flush"); + + const bodies = bodiesOf("site-1"); + expect(bodies).toHaveLength(1); + const loaded = bodies[0].events.find((e: { event_name: string }) => e.event_name === "sdk_loaded"); + expect(loaded).toBeTruthy(); + expect(loaded.properties.sdk_version).toBe("0.1.0"); + expect(loaded.event_id).toMatch(UUID_RE); + }); + + it("批次 context 含设备环境 + referrer_domain/utm/path(首触归因链路)", async () => { + Object.defineProperty(document, "referrer", { + value: "https://news.example/a", + configurable: true, + }); + window.history.pushState({}, "", "/land?utm_source=ns&utm_campaign=m1"); + await loadSdk([["init", { siteId: "site-2", endpoint: "/e", flushMs: 60000 }]]); + + // __ctk_ctx 四类键齐全:设备环境 + referrer_domain + utm + path + const ctx = g.__ctk_ctx!; + expect(ctx.device_type).toBe("PC"); + expect(ctx.os).toBe("Windows"); + expect(ctx.browser).toBe("Chrome"); + expect(ctx.language).toBe("zh-CN"); + expect(typeof ctx.timezone).toBe("string"); + expect(ctx.screen).toMatch(/^\d+x\d+$/); + expect(ctx.referrer_domain).toBe("news.example"); + expect(ctx.utm).toEqual({ utm_source: "ns", utm_campaign: "m1" }); + expect(ctx.path).toBe("/land?utm_source=ns&utm_campaign=m1"); + + g.ctk!("flush"); // 原样透传进批次 body.context + const bodies = bodiesOf("site-2"); + expect(bodies).toHaveLength(1); + expect(bodies[0].context).toEqual(ctx); + }); + + it("localStorage 抛异常:15s 定时 flush 无未捕获异常,身份降级内存后事件照常发出", async () => { + vi.stubGlobal("localStorage", { + getItem: () => { + throw new Error("denied"); + }, + setItem: () => { + throw new Error("denied"); + }, + removeItem: () => { + throw new Error("denied"); + }, + clear: () => { + throw new Error("denied"); + }, + }); + const unhandled: unknown[] = []; + const onRej = (e: unknown) => unhandled.push(e); + // tsconfig 无 @types/node,经 globalThis 取 process(vitest node worker 内必在) + const proc = ( + globalThis as { + process?: { + on(ev: "unhandledRejection", cb: (e: unknown) => void): void; + off(ev: "unhandledRejection", cb: (e: unknown) => void): void; + }; + } + ).process; + proc?.on("unhandledRejection", onRej); + await loadSdk([["init", { siteId: "site-7", endpoint: "/e", flushMs: 15000 }]]); + + let syncErr: unknown; + try { + await vi.advanceTimersByTimeAsync(15000); // 定时 flush 到期 + } catch (e) { + syncErr = e; // fake timers 会把定时回调内的同步异常抛到 advance 调用方 + } + proc?.off("unhandledRejection", onRej); + expect(syncErr).toBeUndefined(); + expect(unhandled).toEqual([]); + + const bodies = bodiesOf("site-7"); + expect(bodies).toHaveLength(1); + expect(bodies[0].visitor_id).toMatch(UUID_RE); // 内存降级身份仍可用 + expect(bodies[0].session_id).toMatch(UUID_RE); + expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([ + "sdk_loaded", + "page_view", + ]); + }); + + it("init 前队列重放:排队的 track 事件入队,g.ctk 已替换为 dispatch", async () => { + const trackEv = { + event_id: "e-custom", + event_name: "cta_click", + occurred_at: new Date().toISOString(), + path: "/x", + title: "t", + properties: { k: 1 }, + }; + await loadSdk([ + ["init", { siteId: "site-8", endpoint: "/e", flushMs: 60000 }], + ["track", trackEv], + ]); + + expect(typeof g.ctk).toBe("function"); + expect(g.ctk!.q).toBeUndefined(); // 桩(带 q)已被 dispatch 替换 + g.ctk!("flush"); + let bodies = bodiesOf("site-8"); + expect(bodies).toHaveLength(1); + expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([ + "sdk_loaded", + "page_view", + "cta_click", + ]); + expect(bodies[0].events[2].properties).toEqual({ k: 1 }); + + // 重放结束后宿主新调用直接进 dispatch(不再排队丢失) + g.ctk!("track", { ...trackEv, event_id: "e-2", event_name: "cta_click2" }); + g.ctk!("flush"); + bodies = bodiesOf("site-8"); + expect(bodies).toHaveLength(2); + expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]); + }); +}); diff --git a/sdk/test/transport.test.ts b/sdk/test/transport.test.ts index 7a0887b..689574c 100644 --- a/sdk/test/transport.test.ts +++ b/sdk/test/transport.test.ts @@ -176,6 +176,22 @@ describe("transport", () => { await vi.advanceTimersByTimeAsync(100); expect(t.dropped).toBe(1); // maxRetry=0 耗尽 → 按事件数丢弃 }); + + it("stringify 抛错(track 引入宿主可控对象):已换出批次按事件数计 dropped", () => { + const t = new Transport(mkCfg({ batchSize: 10, flushMs: 60000 })); + const bad: { event_id: string; self?: unknown } = { event_id: "bad" }; + bad.self = bad; // 循环引用 → JSON.stringify 抛 TypeError + t.push(bad); + t.push({ event_id: "ok" }); + expect(() => t.flush()).not.toThrow(); + expect(t.dropped).toBe(2); // 换出的 2 条全部落丢,不能静默不计 + expect(fetchMock).not.toHaveBeenCalled(); + + t.push({ event_id: "e3" }); // 队列已换出,后续事件照常 + t.flush(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).events).toEqual([{ event_id: "e3" }]); + }); }); describe("mergeConfig", () => {