From 6432435c93c65e00c3ce3bdf95605ffdc0bfd1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 18 Sep 2026 14:07:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E4=BC=A0=E8=BE=93=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E4=BF=A1=E5=B0=81+=E6=B7=B7=E5=90=88=E6=8B=86?= =?UTF-8?q?=E6=89=B9+beacon=E9=A2=84=E7=AE=97+=E5=93=8D=E5=BA=94=E8=AE=A1?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- sdk/src/transport.ts | 153 +++++++++++++++++++++----- sdk/test/transport.test.ts | 213 ++++++++++++++++++++++++++++++++++++- 2 files changed, 334 insertions(+), 32 deletions(-) diff --git a/sdk/src/transport.ts b/sdk/src/transport.ts index 694a2f2..e91e98b 100644 --- a/sdk/src/transport.ts +++ b/sdk/src/transport.ts @@ -1,20 +1,40 @@ import { uuid } from "./util"; import { getVisitorId, getSessionId } from "./identity"; import { getPageSession } from "./page"; +import { SDK_VERSION, SCHEMA_VERSION, BUILD_ID } from "./types"; import type { CtkConfig } from "./config"; export type TransportConfig = Required; -// 批量传输:满 batchSize 或 flushMs 到期即发;4xx(除 429)不重试; -// 指数退避 + 抖动重试;重试耗尽按本批事件数计 dropped;queueMax 溢出丢最旧。 +/** 子批组:同 (visitor_id, session_id, page_id) 快照归属的事件聚合(组内 FIFO) */ +interface Group { + visitor_id: string; + session_id: string; + events: object[]; +} + +// 批量传输(schema v2):满 batchSize 或 flushMs 到期即发;信封归属从事件顶层 +// 快照字段读取(flush 不再重读身份——D02 的传输侧配套);混合归属队列按 +// (visitor,session,page) 拆子批,组间顺序发送、组内独立重试(组A 耗尽不清扫 +// 组B 的重试资格);无归属字段的事件(track 宿主对象——公开 API 不能静默丢) +// 归入兜底组,信封走 identity 旧路径。beacon 载荷超 60KB 预算降级 fetch +// keepalive;4xx(除 429)不重试;指数退避+抖动重试,耗尽按组内事件数计 +// dropped;queueMax 溢出丢最旧。响应计数(T3 契约)accepted/duplicated/ +// rejected 累计 + lastServerTime,仅内存诊断(阶段B sdk_diag 消费),解析 +// 失败绝不影响送达判定(不重试不丢弃)。 // 红线:任何异常不得冒泡到宿主页(定时器/事件回调内的异常无人捕获)。 // 审查留档(Task 14 结论):index.ts 的 track 分支已引入宿主可控对象—— -// stringify 抛错(如循环引用)时已换出的批次按事件数计 dropped。 +// stringify 抛错(如循环引用)时已换出的组按事件数计 dropped。 export class Transport { private q: object[] = []; private timer: ReturnType | null = null; - private retry = 0; dropped = 0; + /** 响应计数(内存诊断,阶段B sdk_diag 消费;不入队不上报) */ + accepted = 0; + duplicated = 0; + rejected = 0; + /** 最近一次服务端墙钟(ISO 串,供 offset 估计) */ + lastServerTime: string | null = null; constructor(public cfg: TransportConfig) {} push(ev: object) { @@ -43,39 +63,93 @@ export class Transport { this.timer = null; } if (!this.q.length) return; - let n = 0; + const events = this.q; + this.q = []; // 换出即刻:后续 push 进新队;stringify 抛错时本组已丢,须计 dropped + // 按快照归属分组(key=visitor:session:page,取事件顶层字段;组内保持 FIFO, + // 组序=首现顺序)。归属字段校验 typeof string:track 宿主对象字段不可信, + // 非串值(含缺失)一律视为无归属走兜底组。 + const groups: Group[] = []; + const indexOf = new Map(); + let fallback: object[] | null = null; + for (const ev of events) { + let v: unknown, s: unknown, p: unknown; + try { + v = (ev as { visitor_id?: unknown }).visitor_id; + s = (ev as { session_id?: unknown }).session_id; + p = (ev as { page_id?: unknown }).page_id; + } catch { + // 宿主 getter 抛错(track 可控对象红线):视为无归属走兜底组—— + // stringify 时同 getter 再抛 → 兜底组按事件数落丢,归属组不受波及 + (fallback ??= []).push(ev); + continue; + } + if (typeof v === "string" && typeof s === "string" && typeof p === "string") { + const key = `${v}:${s}:${p}`; + const existing = indexOf.get(key); + if (existing === undefined) { + indexOf.set(key, groups.length); + groups.push({ visitor_id: v, session_id: s, events: [ev] }); + } else { + groups[existing].events.push(ev); + } + } else { + (fallback ??= []).push(ev); + } + } + if (fallback) { + // 兜底组(track 兼容):信封走 identity 旧路径——公开 API 不能静默丢。 + // 惰性构造:全队列带归属字段时零 identity 调用(快照信封语义)。 + try { + groups.push({ + visitor_id: getVisitorId(), + session_id: getSessionId(), + events: fallback, + }); + } catch { + this.dropped += fallback.length; // identity 已内部降级,此处仅极端环境兜底 + } + } + for (const g of groups) this.dispatch(g, beacon); + } + + /** 单组构 v2 信封并发送;本组任何异常按组内事件数计 dropped,不波及其他组。 */ + private dispatch(g: Group, beacon: boolean) { + const n = g.events.length; try { - const events = this.q; - this.q = []; - n = events.length; // 换出后先记数:stringify 抛错时本批已丢,须计 dropped const body = JSON.stringify({ batch_id: uuid(), site_id: this.cfg.siteId, - visitor_id: getVisitorId(), - session_id: getSessionId(), + schema_version: SCHEMA_VERSION, + sdk_version: SDK_VERSION, + build_id: BUILD_ID, + visitor_id: g.visitor_id, + session_id: g.session_id, sent_at: new Date().toISOString(), // 由 index.ts(Task 15)注入;未注入时 undefined——JSON 序列化自然缺键,照发 context: (globalThis as { __ctk_ctx?: unknown }).__ctk_ctx, - events, + events: g.events, }); if (beacon && navigator.sendBeacon) { - // 返回 false=浏览器拒收(队列满/载荷超 64KB 等)→ 整批丢,须计 dropped - const ok = navigator.sendBeacon( - this.cfg.endpoint, - new Blob([body], { type: "text/plain" }) - ); - if (!ok) this.dropped += n; - return; + const blob = new Blob([body], { type: "text/plain" }); + // 60KB 发送预算(为浏览器信标 64KB 硬限留余量):超限降级 fetch keepalive + if (blob.size <= 60_000) { + // 返回 false=浏览器拒收(队列满/载荷超限)→ 整组丢,须计 dropped + if (!navigator.sendBeacon(this.cfg.endpoint, blob)) this.dropped += n; + return; + } } - this.send(body, events.length); + this.send(body, n); } catch { // 定时器/事件回调内的任何异常不得冒泡到宿主页; - // stringify 抛错时批次已换出(宿主可控对象),按事件数计丢弃 + // stringify 抛错时本组已换出(宿主可控对象),按事件数计丢弃 this.dropped += n; } } - private async send(body: string, count: number) { + /** 组内独立重试(retry 参数传递——组A 耗尽不清扫组B 的资格): + * 4xx(非 429)不重试;退避重放同 body(同 event_id,服务端幂等去重); + * 耗尽按本组事件数计 dropped。 */ + private async send(body: string, count: number, retry = 0) { try { const res = await fetch(this.cfg.endpoint, { method: "POST", @@ -84,21 +158,46 @@ export class Transport { body, }); if (res.status >= 400 && res.status < 500 && res.status !== 429) { - this.retry = 0; return; // 4xx 参数/权限错不重试(403 重试只会掩盖配置错误) } if (!res.ok) throw new Error(String(res.status)); - this.retry = 0; + await this.tally(res); // 计数解析自包异常——失败不触发重试(批已送达) } catch { - if (this.retry++ < this.cfg.maxRetry) { - const delay = 500 * 2 ** this.retry * (0.8 + Math.random() * 0.4); + if (retry < this.cfg.maxRetry) { + const delay = 500 * 2 ** (retry + 1) * (0.8 + Math.random() * 0.4); setTimeout(() => { - this.send(body, count).catch(() => {}); + this.send(body, count, retry + 1).catch(() => {}); }, delay); // 重放同一 body 字符串——同 event_id 重放,服务端幂等去重 } else { - this.retry = 0; this.dropped += count; // 按事件数计丢弃 } } } + + /** 响应计数(T3 契约 {code, data:{accepted,duplicated,rejected,request_id, + * server_time}, message}):全程自包异常——计数是诊断附庸,绝不反过来 + * 影响发送语义(不重试不丢弃)。旧测试桩/异常环境无 json 方法时静默跳过。 */ + private async tally(res: Response) { + try { + const j = (res as { json?: () => Promise }).json; + if (typeof j !== "function") return; + const payload = (await j.call(res).catch(() => null)) as { + data?: { + accepted?: unknown; + duplicated?: unknown; + rejected?: unknown; + server_time?: unknown; + }; + } | null; + const d = payload && typeof payload === "object" ? payload.data : null; + if (!d || typeof d !== "object") return; + if (typeof d.accepted === "number") this.accepted += d.accepted; + if (typeof d.duplicated === "number") this.duplicated += d.duplicated; + if (Array.isArray(d.rejected)) this.rejected += d.rejected.length; + if (typeof d.server_time === "string" && d.server_time) + this.lastServerTime = d.server_time; + } catch { + /* 静默(spec §4.5) */ + } + } } diff --git a/sdk/test/transport.test.ts b/sdk/test/transport.test.ts index 5356ff7..c3c58bb 100644 --- a/sdk/test/transport.test.ts +++ b/sdk/test/transport.test.ts @@ -1,11 +1,26 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Transport } from "../src/transport"; import { mergeConfig } from "../src/config"; +import { SDK_VERSION, SCHEMA_VERSION, BUILD_ID } from "../src/types"; +import { getVisitorId, getSessionId } from "../src/identity"; + +// 快照信封验证(T8):identity 包一层 spy(委托真实实现,其余用例行为不变), +// 断言"全队列带归属字段的 flush 不再读身份"(信封归属=事件顶层快照)。 +vi.mock("../src/identity", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getVisitorId: vi.fn(actual.getVisitorId), + getSessionId: vi.fn(actual.getSessionId), + }; +}); // uuid v4 格式(crypto.randomUUID 或 Math.random 兜底均为该形状) const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -const res = (status: number) => ({ ok: status < 400, status }) as unknown as Response; +/** 可选 data:成功响应的 T3 契约体 {accepted,duplicated,rejected,request_id,server_time} */ +const res = (status: number, data?: unknown) => + ({ ok: status < 400, status, json: () => Promise.resolve(data ?? null) }) as unknown as Response; // 与 mergeConfig 默认值无关的测试专用 cfg(各用例按需覆盖) function mkCfg(over: Record = {}) { @@ -28,6 +43,8 @@ describe("transport", () => { vi.useFakeTimers(); // 默认连 Date 一起 fake fetchMock = vi.fn().mockResolvedValue(res(200)); vi.stubGlobal("fetch", fetchMock); + vi.mocked(getVisitorId).mockClear(); // identity spy 调用记录按用例隔离 + vi.mocked(getSessionId).mockClear(); }); afterEach(() => { vi.unstubAllGlobals(); // 还原 fetch / navigator 桩 @@ -81,7 +98,7 @@ describe("transport", () => { expect(body.context).toEqual({ device_type: "PC" }); // 引用透传而非拷贝拼接 }); - it("500 失败退避重试后成功:retry 归零,body 逐字节不变(同 event_id 重放)", async () => { + it("500 失败退避重试后成功:body 逐字节不变(同 event_id 重放)", async () => { fetchMock.mockResolvedValueOnce(res(500)).mockResolvedValueOnce(res(200)); const t = new Transport(mkCfg({ batchSize: 2 })); t.push({ event_id: "e1" }); @@ -91,7 +108,6 @@ describe("transport", () => { await vi.advanceTimersByTimeAsync(2000); // 第 1 次退避 500*2^1*jitter ∈ [800,1200] expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[1][1].body).toBe(fetchMock.mock.calls[0][1].body); - expect((t as unknown as { retry: number }).retry).toBe(0); }); it("400 不重试(参数错,重试无意义)", async () => { @@ -100,7 +116,6 @@ describe("transport", () => { t.push({ event_id: "e1" }); await vi.advanceTimersByTimeAsync(10000); expect(fetchMock).toHaveBeenCalledTimes(1); - expect((t as unknown as { retry: number }).retry).toBe(0); }); it("403 不重试(权限错,重试只会掩盖配置问题)", async () => { @@ -130,7 +145,6 @@ describe("transport", () => { await vi.advanceTimersByTimeAsync(2000); // 第 1 次重试(∈[800,1200])后再失败 expect(fetchMock).toHaveBeenCalledTimes(2); // 初发 1 + 重试 1,共 2 次 expect(t.dropped).toBe(2); // 按入批事件数计,而非按批计 - expect((t as unknown as { retry: number }).retry).toBe(0); // 归零供下批复用 }); it("queueMax 溢出丢最旧", () => { @@ -202,6 +216,195 @@ describe("transport", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(JSON.parse(fetchMock.mock.calls[0][1].body).events).toEqual([{ event_id: "e3" }]); }); + + // ---- T8:快照信封 / 混合拆批 / beacon 预算 / 响应计数 ---- + + it("flush 从队首事件快照取归属:信封 visitor/session=事件顶层字段,identity 零调用", () => { + const t = new Transport(mkCfg({ batchSize: 10 })); + t.push({ event_id: "e1", visitor_id: "v-snap", session_id: "s-snap", page_id: "p-snap" }); + t.push({ event_id: "e2", visitor_id: "v-snap", session_id: "s-snap", page_id: "p-snap" }); + t.flush(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getSessionId).not.toHaveBeenCalled(); // 快照归属——flush 不再读身份(消灭 D02 传输侧) + expect(getVisitorId).not.toHaveBeenCalled(); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.visitor_id).toBe("v-snap"); + expect(body.session_id).toBe("s-snap"); + }); + + it("混合归属队列拆子批:两组 (visitor,session,page) → 两次独立请求各含正确信封(组内 FIFO)", () => { + const t = new Transport(mkCfg({ batchSize: 10 })); + t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" }); + t.push({ event_id: "b1", visitor_id: "v2", session_id: "s2", page_id: "p2" }); + t.push({ event_id: "a2", visitor_id: "v1", session_id: "s1", page_id: "p1" }); + t.flush(); + expect(fetchMock).toHaveBeenCalledTimes(2); + const b1 = JSON.parse(fetchMock.mock.calls[0][1].body); + const b2 = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(b1.visitor_id).toBe("v1"); // 信封归属=组 key(组首事件快照) + expect(b1.session_id).toBe("s1"); + expect(b1.events.map((e: { event_id: string }) => e.event_id)).toEqual(["a1", "a2"]); // 组内 FIFO + expect(b2.visitor_id).toBe("v2"); + expect(b2.session_id).toBe("s2"); + expect(b2.events.map((e: { event_id: string }) => e.event_id)).toEqual(["b1"]); + expect(b1.batch_id).not.toBe(b2.batch_id); // 每子批独立 batch_id + }); + + it("无归属字段的兼容事件(track 宿主对象)走兜底组:信封从 identity 读,不静默丢", () => { + const t = new Transport(mkCfg({ batchSize: 10 })); + t.push({ event_id: "t1" }); // track 命令原样对象——无归属字段 + t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" }); + t.flush(); + expect(fetchMock).toHaveBeenCalledTimes(2); // 归属组 + 兜底组 + const b1 = JSON.parse(fetchMock.mock.calls[0][1].body); + const b2 = JSON.parse(fetchMock.mock.calls[1][1].body); // 兜底组排后 + expect(b1.visitor_id).toBe("v1"); + expect(b1.events).toEqual([{ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" }]); + expect(b2.visitor_id).toMatch(UUID_RE); // getVisitorId() 旧路径构造信封 + expect(b2.session_id).toMatch(UUID_RE); // getSessionId() 旧路径 + expect(b2.events).toEqual([{ event_id: "t1" }]); // 公开 API 不能静默丢 + expect(getSessionId).toHaveBeenCalledTimes(1); // 惰性求值:仅兜底组读一次 + }); + + it("子批独立重试:组A 500 失败重试,组B 200 不重试", async () => { + const t = new Transport(mkCfg({ batchSize: 10 })); + t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" }); + t.push({ event_id: "b1", visitor_id: "v2", session_id: "s2", page_id: "p2" }); + fetchMock + .mockResolvedValueOnce(res(500)) // 组A 初发 + .mockResolvedValueOnce(res(200)) // 组B(成功不重试) + .mockResolvedValueOnce(res(200)); // 组A 重试 + t.flush(); + expect(fetchMock).toHaveBeenCalledTimes(2); // 组间顺序:初发各一次 + await vi.advanceTimersByTimeAsync(2000); // 组A 退避 ∈[800,1200] + expect(fetchMock).toHaveBeenCalledTimes(3); // 仅组A 重试 + expect(fetchMock.mock.calls[2][1].body).toBe(fetchMock.mock.calls[0][1].body); // 同 body 重放 + expect(t.dropped).toBe(0); + }); + + it("子批独立重试资格:组A 耗尽不清扫组B 的重试计数(组B 仍可自救)", async () => { + // 抖动钳制:两组退避同为 1000ms——fake timer 同刻按调度序(A 先)触发, + // 否则 Math.random 会让 A/B 重试顺序随机翻转(响应序列错绑到另一组) + const rnd = vi.spyOn(Math, "random").mockReturnValue(0.5); + const t = new Transport(mkCfg({ batchSize: 10, maxRetry: 1 })); + t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" }); + t.push({ event_id: "a2", visitor_id: "v1", session_id: "s1", page_id: "p1" }); + t.push({ event_id: "b1", visitor_id: "v2", session_id: "s2", page_id: "p2" }); + fetchMock + .mockResolvedValueOnce(res(500)) // A 初发 + .mockResolvedValueOnce(res(500)) // B 初发 + .mockResolvedValueOnce(res(500)) // A 重试 → 耗尽(dropped+=2) + .mockResolvedValueOnce(res(200)); // B 重试 → 成功 + t.flush(); + await vi.advanceTimersByTimeAsync(2000); // 两组退避均已到期 + expect(fetchMock).toHaveBeenCalledTimes(4); // 共享计数器下 B 会错失自己的重试(仅 3 次) + expect(t.dropped).toBe(2); // 仅组A 的 2 条落丢 + expect(JSON.parse(fetchMock.mock.calls[3][1].body).visitor_id).toBe("v2"); // B 的重试成功送达 + rnd.mockRestore(); + }); + + it("beacon 载荷超 60KB 预算 → 降级 fetch keepalive;失败耗尽按事件数计 dropped", async () => { + const beacon = vi.fn().mockReturnValue(true); + vi.stubGlobal("navigator", { sendBeacon: beacon }); + fetchMock.mockResolvedValue(res(500)); // 降级后恒失败 + const t = new Transport(mkCfg({ batchSize: 10, maxRetry: 0 })); + t.push({ + event_id: "big", + visitor_id: "v1", + session_id: "s1", + page_id: "p1", + properties: { blob: "x".repeat(65_000) }, // 信封+事件 > 60KB + }); + t.flush(true); + expect(beacon).not.toHaveBeenCalled(); // 超预算不走信标 + expect(fetchMock).toHaveBeenCalledTimes(1); + const init = fetchMock.mock.calls[0][1]; + expect(init.keepalive).toBe(true); + await vi.advanceTimersByTimeAsync(100); + expect(t.dropped).toBe(1); // fetch 亦失败(maxRetry=0 耗尽)按事件数计 + }); + + it("响应解析:accepted/duplicated/rejected 累计进 counts,server_time 记录(T3 契约)", async () => { + const t = new Transport(mkCfg({ batchSize: 1 })); + fetchMock.mockResolvedValueOnce( + res(200, { + code: 200, + data: { + accepted: 2, + duplicated: 1, + rejected: [{ index: 0, event_id: "e1", reason: "invalid_field" }], + request_id: "r1", + server_time: "2026-09-18T10:00:00+00:00", + }, + message: "ok", + }) + ); + t.push({ event_id: "e1", visitor_id: "v1", session_id: "s1", page_id: "p1" }); + await vi.advanceTimersByTimeAsync(0); // 微任务冲刷(json promise) + expect(t.accepted).toBe(2); + expect(t.duplicated).toBe(1); + expect(t.rejected).toBe(1); // rejected 明细数组长度累计 + expect(t.lastServerTime).toBe("2026-09-18T10:00:00+00:00"); // 供 offset 估计 + + fetchMock.mockResolvedValueOnce( + res(200, { + code: 200, + data: { accepted: 1, duplicated: 0, rejected: [], request_id: "r2", server_time: "2026-09-18T10:00:05+00:00" }, + message: "ok", + }) + ); + t.push({ event_id: "e2", visitor_id: "v1", session_id: "s1", page_id: "p1" }); + await vi.advanceTimersByTimeAsync(0); + expect(t.accepted).toBe(3); // 跨批累计 + expect(t.rejected).toBe(1); + expect(t.lastServerTime).toBe("2026-09-18T10:00:05+00:00"); // 最新值 + }); + + it("响应体非 JSON 不影响送达判定:不重试不丢弃,计数不动", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.reject(new Error("not json")), + } as unknown as Response); + const t = new Transport(mkCfg({ batchSize: 1 })); + t.push({ event_id: "e1", visitor_id: "v1", session_id: "s1", page_id: "p1" }); + await vi.advanceTimersByTimeAsync(2000); + expect(fetchMock).toHaveBeenCalledTimes(1); // 计数解析失败不触发重试(批已送达) + expect(t.dropped).toBe(0); + expect(t.accepted).toBe(0); + }); + + it("v2 信封形态:恒 schema_version=2 + sdk_version + build_id(sent_at 保留)", () => { + const t = new Transport(mkCfg({ batchSize: 1 })); + t.push({ event_id: "e1", visitor_id: "v1", session_id: "s1", page_id: "p1" }); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.schema_version).toBe(SCHEMA_VERSION); // types.ts 单一来源 + expect(body.schema_version).toBe(2); // v1 兼容形态不再产生 + expect(body.sdk_version).toBe(SDK_VERSION); + expect(body.build_id).toBe(BUILD_ID); // vitest define 注入 "vitest" + expect(body.sent_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); // sent_at 已有保留 + }); + + it("宿主对象归属字段 getter 抛错:不冒泡,兜底组落丢,归属组照发", () => { + const t = new Transport(mkCfg({ batchSize: 10 })); + const evil = { event_id: "evil" } as { event_id: string; visitor_id?: string }; + Object.defineProperty(evil, "visitor_id", { + // 分组读取/stringify 都会触发——宿主可控对象红线(spec §4.5) + get() { + throw new Error("getter boom"); + }, + enumerable: true, + }); + t.push(evil); + t.push({ event_id: "ok" }); // 与 evil 同落兜底组(无归属字段) + t.push({ event_id: "safe", visitor_id: "v1", session_id: "s1", page_id: "p1" }); + expect(() => t.flush()).not.toThrow(); // 红线:异常不得冒泡 + expect(t.dropped).toBe(2); // 兜底组(evil+ok)stringify 抛错按组内事件数计 + expect(fetchMock).toHaveBeenCalledTimes(1); // 归属组不受宿主坏对象波及 + expect(JSON.parse(fetchMock.mock.calls[0][1].body).events).toEqual([ + { event_id: "safe", visitor_id: "v1", session_id: "s1", page_id: "p1" }, + ]); + }); }); describe("mergeConfig", () => {