feat(sdk): 传输快照信封+混合拆批+beacon预算+响应计数
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+208
-5
@@ -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<typeof import("../src/identity")>();
|
||||
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<string, unknown> = {}) {
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user