diff --git a/sdk/package.json b/sdk/package.json index f4a1568..71f6324 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "ctk-sdk", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "scripts": { diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 23f72d4..63ffb68 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -1,8 +1,9 @@ import { uuid } from "./util"; import { mergeConfig } from "./config"; import { Transport } from "./transport"; -import { startPageSession } from "./page"; +import { startPageSession, getPageSession } from "./page"; import { startStayTracking } from "./stay"; +import { startBusinessTracking } from "./business"; import { buildEnvContext, collectHighEntropyHints, pageContext } from "./context"; import { SDK_VERSION, BUILD_ID, SCHEMA_VERSION, CONFIG_VERSION } from "./types"; import type { CtkConfig } from "./config"; @@ -65,8 +66,17 @@ function dispatch(cmd: string, arg?: unknown) { ...ps.attribution(), }); startStayTracking(transport, ps); + // 0.2.1 接线(T5):业务事件自动监听(contact_click / form_* / product_view)。 + // 必须在 startPageSession 之后——emit 经 getPageSession() 取归属,无会话时 + // 守卫静默丢弃(business.test.ts 首例覆盖异常顺序)。幂等性依赖调用方守卫: + // init 的 !transport 条件已保证只调一次。product_view 的 init 判定读挂载时 + // 同步的 location.pathname——置于 startStayTracking 之后无时序敏感(首帧判定)。 + startBusinessTracking(transport); } else if (cmd === "track" && transport) { - transport.push(arg as object); + // track 补当前段归属(T5):宿主字段展开在前、归属在后——归属键权威(宿主 + // 伪造的 site_id 等被覆盖),其余宿主字段原样保留。PageSession 未启动时 + // attribution() 为 undefined,展开为无操作——保持原样走兜底组路径。 + transport.push({ ...(arg as object), ...getPageSession()?.attribution() }); } else if (cmd === "flush" && transport) { transport.flush(); } diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 4b14c47..575c6ac 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -9,7 +9,7 @@ export interface Attribution { epoch?: null; // V1.1 预留 grant_ref?: null; // V1.1 预留 } -export const SDK_VERSION = "0.2.0"; // 与 package.json 同步手维护 +export const SDK_VERSION = "0.2.1"; // 与 package.json 同步手维护 export const SCHEMA_VERSION = 2; export const CONFIG_VERSION = "1"; diff --git a/sdk/test/business.test.ts b/sdk/test/business.test.ts index fa2043d..0b968f9 100644 --- a/sdk/test/business.test.ts +++ b/sdk/test/business.test.ts @@ -427,6 +427,19 @@ describe("business / startBusinessTracking", () => { expect(ids).toEqual(["cf7-156", "cf7-42", "cf7-0"]); }); + it("form_id 回归(T4 审查遗留):11 位 ID 不截断匹配 → cf7-0(服务端 cf7-N 上限 10 位)", () => { + setUrl("/fid11"); + const t = mkTransport("biz-fid11"); + startPageSession(t); + startBusinessTracking(t); + const form = mkForm({ action: "#wpcf7-f12345678901" }); + trusted("focusin", form.querySelector("input")!); + t.flush(); + const fss = fetchedEvents("biz-fid11").filter((e) => e.event_name === "form_start"); + expect(fss).toHaveLength(1); + expect(fss[0].properties.form_id).toBe("cf7-0"); // (?!\d) 拒绝截断——按解析失败归 cf7-0 + }); + // ---- product_view(spec §1.3:非锚定段匹配 + onSegmentChange 切段) ---- it("product_view:init 时 /product/slug/ 判定 → inferred=true rule_version=v1 + 顶层归属", () => { diff --git a/sdk/test/index.test.ts b/sdk/test/index.test.ts index d70f84a..5ed97ad 100644 --- a/sdk/test/index.test.ts +++ b/sdk/test/index.test.ts @@ -2,9 +2,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // index.ts 是 IIFE 打包入口:模块加载即读取全局队列、替换 g.ctk 并重放。 // 隔离手段:vi.resetModules() + 动态 import,每用例拿到全新模块级 transport 状态。 -// startPageSession/startStayTracking 注册的 document/window 监听跨用例存留, -// 用独立 siteId 过滤批次。0.2.0 接线(T10):初始 page_view 由 startPageSession -// 发(seq=1),sdk_loaded 随后入队(seq=2)——队列顺序 [page_view, sdk_loaded]。 +// startPageSession/startStayTracking/startBusinessTracking 注册的 document/window +// 监听跨用例存留,用独立 siteId 过滤批次。0.2.0 接线(T10):初始 page_view 由 +// startPageSession 发(seq=1),sdk_loaded 随后入队(seq=2)。0.2.1 接线(T5): +// startBusinessTracking 挂业务监听(产品页路径下 init 队列追加 product_view)、 +// track 补当前段归属(不再走兜底组)。 type Cmd = [string, unknown?]; type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] }; @@ -33,6 +35,26 @@ function bodiesOf(siteId: string) { .filter((b: { site_id: string }) => b.site_id === siteId); } +/** URL 重置:走原型方法绕开历史用例的 page.ts pushState 包装——不触发旧模块 + * 切段杂事件(同 business.test.ts setUrl 模式) */ +function setUrl(url: string) { + const proto = Object.getPrototypeOf(window.history) as { + replaceState: (data: unknown, unused: string, url?: string | URL | null) => void; + }; + proto.replaceState.call(window.history, {}, "", url); +} + +/** beacon 路径(contact_click 的 flush(true))批次汇总(按信封 site_id 过滤) */ +async function beaconBodiesOf(siteId: string) { + const out: Record[] = []; + const beacon = (navigator as unknown as { sendBeacon: ReturnType }).sendBeacon; + 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; +} + describe("index 入口", () => { beforeEach(() => { localStorage.clear(); @@ -64,7 +86,7 @@ describe("index 入口", () => { 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.2.0"); + expect(loaded.properties.sdk_version).toBe("0.2.1"); // build_id 由 vitest.config define 注入 "vitest";无 define 环境兜底 "unknown" expect(loaded.properties.build_id).toBe("vitest"); expect(loaded.properties.schema_version).toBe(2); @@ -95,6 +117,76 @@ describe("index 入口", () => { expect(evs[0].page_id).toBe(evs[1].page_id); // 同段归属 }); + it("track 补归属(T5):init 后 track 事件信封带顶层归属(四元组+seq+V1.1 预留位),不再走兜底组", async () => { + await loadSdk([["init", { siteId: "site-track", endpoint: "/e", flushMs: 60000 }]]); + g.ctk!("track", { + event_id: "e-track-1", + event_name: "cta_click", + occurred_at: new Date().toISOString(), + path: "/anywhere", + title: "t", + properties: { k: 1 }, + }); + g.ctk!("flush"); + + const bodies = bodiesOf("site-track"); + // track 补当前段归属 → 与 page_view/sdk_loaded 同四元组,单组子批 + // (兜底组路径只剩 PageSession 未启动的异常接线场景) + expect(bodies).toHaveLength(1); + const evs = bodies[0].events; + expect(evs.map((e: { event_name: string }) => e.event_name)).toEqual([ + "page_view", + "sdk_loaded", + "cta_click", + ]); + const ev = evs[2] as Record; + expect(ev.event_id).toBe("e-track-1"); // 宿主字段原样保留(归属展开在后只补/覆盖归属键) + expect(ev.properties).toEqual({ k: 1 }); + expect(ev.site_id).toBe("site-track"); + expect(ev.visitor_id).toMatch(UUID_RE); + expect(ev.session_id).toMatch(UUID_RE); + expect(ev.page_id).toBe(evs[0].page_id); // 与初始 page_view 同段 + expect(ev.seq).toBe(3); // page_view=1、sdk_loaded=2 之后 + expect(ev.purpose).toBe(null); // V1.1 预留位从快照展开 + expect(ev.epoch).toBe(null); + expect(ev.grant_ref).toBe(null); + }); + + it("init 接线 business(T5):产品页路径下队列 [page_view,sdk_loaded,product_view];wa.me 点击 → contact_click beacon 冒烟", async () => { + setUrl("/product/hydraulic-press-100t/"); + await loadSdk([["init", { siteId: "site-biz", endpoint: "/e", flushMs: 60000 }]]); + g.ctk!("flush"); + + const bodies = bodiesOf("site-biz"); + expect(bodies).toHaveLength(1); // 三事件同四元组 → 单组子批 + const evs = bodies[0].events; + expect(evs.map((e: { event_name: string }) => e.event_name)).toEqual([ + "page_view", + "sdk_loaded", + "product_view", + ]); + const pv = evs[2] as Record; + expect(pv.properties).toEqual({ product_id: "hydraulic-press-100t", inferred: true, rule_version: "v1" }); + expect(pv.page_id).toBe(evs[0].page_id); // init 同步判定归当前段 + + // business 监听已装:点击 wa.me 链接 → contact_click 入队并 flush(true) beacon 送达 + const a = document.createElement("a"); + a.href = "https://wa.me/8613800138000"; + document.body.appendChild(a); + try { + a.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + } finally { + a.remove(); + } + const beacons = await beaconBodiesOf("site-biz"); + expect(beacons).toHaveLength(1); + const cc = beacons[0].events[0]; + expect(cc.event_name).toBe("contact_click"); + expect(cc.properties).toEqual({ channel: "whatsapp" }); + expect(cc.page_id).toBe(pv.page_id); // 归属同段 + expect(cc.site_id).toBe("site-biz"); + }); + it("批次 context 含设备环境 + referrer_domain/utm/path(首触归因链路)", async () => { Object.defineProperty(document, "referrer", { value: "https://news.example/a", @@ -224,21 +316,23 @@ describe("index 入口", () => { expect(g.ctk!.q).toBeUndefined(); // 桩(带 q)已被 dispatch 替换 g.ctk!("flush"); let bodies = bodiesOf("site-8"); - // T8 分组拆批:归属组(page_view+sdk_loaded 同四元组)+ 兜底组(track 宿主 - // 对象无归属顶层字段,信封走 identity 旧路径)——一次 flush 两个子批 - expect(bodies).toHaveLength(2); + // T5 起 track 补当前段归属:cta_click 与 page_view/sdk_loaded 同四元组 → + // 单组子批(兜底组只剩 PageSession 未启动的异常接线场景) + expect(bodies).toHaveLength(1); expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([ "page_view", "sdk_loaded", + "cta_click", ]); - expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click"]); - expect(bodies[1].events[0].properties).toEqual({ k: 1 }); + expect(bodies[0].events[2].properties).toEqual({ k: 1 }); + expect(bodies[0].events[2].event_id).toBe("e-custom"); // 宿主字段原样保留 // 重放结束后宿主新调用直接进 dispatch(不再排队丢失) g.ctk!("track", { ...trackEv, event_id: "e-2", event_name: "cta_click2" }); g.ctk!("flush"); bodies = bodiesOf("site-8"); - expect(bodies).toHaveLength(3); - expect(bodies[2].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]); + expect(bodies).toHaveLength(2); + expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]); + expect(bodies[1].events[0].site_id).toBe("site-8"); // dispatch 直调同补归属 }); });