diff --git a/sdk/src/business.ts b/sdk/src/business.ts new file mode 100644 index 0000000..8d4b05c --- /dev/null +++ b/sdk/src/business.ts @@ -0,0 +1,205 @@ +import type { Transport } from "./transport"; +import { pageContext } from "./context"; +import { uuid } from "./util"; +import { getPageSession, onSegmentChange } from "./page"; + +// 业务事件自动监听(spec §1.1-§1.3 / §1.5,阶段B):咨询点击(contact_click)、 +// CF7 表单全过程(form_start / form_submit / form_error / form_submit_result + +// _ctk_sid 隐藏字段)、产品页推导(product_view)。DOM 监听全部 document 级 +// capture 委托——submit capture 先于 CF7 在 form 冒泡阶段的拦截执行,_ctk_sid +// 写入先于 FormData 构造(§1.5 时序确定性)。 +// 事件结构 = events 形态 + 当前段 attribution() 顶层展开(同 stay.ts 模式); +// 归属在 emit 时读取 getPageSession()——段切换(onSegmentChange 在新段创建后 +// 触发)后的事件自然归新段。红线:任何回调异常 try/catch 静默(spec §4.5)。 + +/** 产品页段匹配(spec §1.3,非锚定):路径段数组含 "product" 且其后一段存在且 + * 非空 → 该段为 slug(截断 ≤100)。非锚定覆盖 /{lang}/product/{slug}/ 多语言 + * 前缀(实测 9 语言;锚定会静默丢失约半数产品页)。slug 须过服务端 _PROD_RE + * 同构格式(首字符字母数字、[A-Za-z0-9._-]≤99)——百分号编码等非约定 slug + * 服务端必拒,客户端即不发(省无谓 reject 噪声)。 */ +const PROD_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; +function matchProduct(path: string): string | null { + const segs = path.split("/").filter(Boolean); + const i = segs.indexOf("product"); + if (i >= 0 && i + 1 < segs.length && segs[i + 1]) { + const slug = segs[i + 1].slice(0, 100); + return PROD_SLUG_RE.test(slug) ? slug : null; + } + return null; +} + +/** CF7 unit-tag → form_id(spec §1.2 规整):action/id 内 + * /(?:^|[-#])wpcf7-f(\d{1,10})(?!\d)/ → cf7-{ID},不暴露完整内部串; + * (?!\d) 防 11+ 位被截断匹配(服务端 cf7-N 上限 10 位,超限按解析失败归 cf7-0)。 */ +function cf7FormId(form: HTMLFormElement): string { + try { + const sources = [form.getAttribute("action") || "", form.id || ""]; + for (const src of sources) { + const m = /(?:^|[-#])wpcf7-f(\d{1,10})(?!\d)/.exec(src); + if (m) return `cf7-${m[1]}`; + } + } catch { + /* 静默(spec §4.5) */ + } + return "cf7-0"; +} + +export function startBusinessTracking(t: Transport): void { + // 事件入队:emit 时读取当前段(未启动 PageSession 的异常接线顺序无从归属,不发); + // pageContext() 单次取用复用于 path/title(同一事件内字段一致) + const emit = (event_name: string, properties: Record): void => { + try { + const ps = getPageSession(); + if (!ps) return; + const pc = pageContext(); + const a = ps.attribution(); + t.push({ + event_id: uuid(), + event_name, + occurred_at: new Date().toISOString(), + path: pc.path, + title: pc.title, + properties, + site_id: a.site_id, + visitor_id: a.visitor_id, + session_id: a.session_id, + page_id: a.page_id, + seq: a.seq, + // V1.1 预留字段从快照展开(非字面量)——将来 attribution() 填真实值时自动携带 + purpose: a.purpose, + epoch: a.epoch, + grant_ref: a.grant_ref, + }); + } catch { + /* 静默(spec §4.5) */ + } + }; + + // ---- 1) contact_click(spec §1.1):链接协议前缀判定,命中即 beacon flush ---- + const WHATSAPP_HTTP = /^https?:\/\/(?:wa\.me|api\.whatsapp\.com)\//i; + const WHATSAPP_SCHEME = /^whatsapp:\/\//i; + document.addEventListener( + "click", + (e) => { + try { + const link = (e.target as Element | null)?.closest?.("a[href]"); + if (!link) return; + const href = link.getAttribute("href") || ""; + let channel: string | null = null; + if (WHATSAPP_HTTP.test(href) || WHATSAPP_SCHEME.test(href)) channel = "whatsapp"; + else if (/^tel:/i.test(href)) channel = "phone"; + else if (/^mailto:/i.test(href)) channel = "email"; + if (!channel) return; + emit("contact_click", { channel }); + t.flush(true); // 跳转窗口内保证发出(沿 v1 卸载 beacon 路径) + } catch { + /* 静默(spec §4.5) */ + } + }, + { capture: true } + ); + + // ---- 2) form_*(spec §1.2 / §1.5):CF7 全过程状态机 ---- + const started = new WeakSet(); // form_start 去重(每表单实例一次) + const lastSubmission = new WeakMap(); // error/result 引用的最近 submission_id + + /** e.target 所在的 CF7 表单(class 含 wpcf7-form),非 CF7 返回 null */ + const cf7Form = (target: EventTarget | null): HTMLFormElement | null => { + try { + const f = (target as Element | null)?.closest?.("form") as HTMLFormElement | null; + if (f && f.classList && f.classList.contains("wpcf7-form")) return f; + } catch { + /* 静默(spec §4.5) */ + } + return null; + }; + + /** _ctk_sid 隐藏字段:找已有(宿主/先前注入)否则新建空占位(§1.5:提交前 + * 字段恒存在,值由 submit 捕获填充)。DOM API 构造——不碰 innerHTML。 */ + const ensureCtkSid = (form: HTMLFormElement): HTMLInputElement => { + const existing = form.querySelector('input[name="_ctk_sid"]'); + if (existing) return existing; + const inp = document.createElement("input"); + inp.type = "hidden"; + inp.name = "_ctk_sid"; + form.appendChild(inp); + return inp; + }; + + // form_start:.wpcf7-form 内首次 isTrusted focusin/input(合成事件不构成真实 + // "用户开始");WeakSet 保证每表单实例只发一次 + const onStart = (e: Event) => { + try { + if (!e.isTrusted) return; + const form = cf7Form(e.target); + if (!form || started.has(form)) return; + started.add(form); + ensureCtkSid(form); + emit("form_start", { form_id: cf7FormId(form) }); + } catch { + /* 静默(spec §4.5) */ + } + }; + document.addEventListener("focusin", onStart, { capture: true }); + document.addEventListener("input", onStart, { capture: true }); + + // form_submit:原生 submit 的 document capture 委托——先于 CF7 冒泡阶段拦截 + // (preventDefault + FormData 构造),_ctk_sid 写入确定性(§1.5);每次尝试 + // 独立 submission_id + document.addEventListener( + "submit", + (e) => { + try { + const form = cf7Form(e.target); + if (!form) return; + const sid = uuid(); + lastSubmission.set(form, sid); + ensureCtkSid(form).value = sid; + emit("form_submit", { form_id: cf7FormId(form), submission_id: sid }); + } catch { + /* 静默(spec §4.5) */ + } + }, + { capture: true } + ); + + // wpcf7statuschanged(CF7 6.x 状态机,bubbles:true):终态映射—— + // invalid/unaccepted → form_error("invalid");spam → form_error("spam"); + // mail_sent/mail_failed → form_submit_result。init/validating/sending/resetting + // 等中间态不产生事件。submission_id 引用本表单最近一次 submit(无则空串, + // 服务端 _SUBMIT_RE 允许空) + document.addEventListener("wpcf7statuschanged", (e) => { + try { + const form = cf7Form(e.target); + if (!form) return; + const status = String((e as CustomEvent).detail?.status ?? ""); + const form_id = cf7FormId(form); + const submission_id = lastSubmission.get(form) ?? ""; + if (status === "invalid" || status === "unaccepted" || status === "spam") { + emit("form_error", { + form_id, + submission_id, + error_code: status === "spam" ? "spam" : "invalid", + // 上限 99:服务端 field_count 契约 0-99,病态多字段表单钳制而非必拒 + field_count: Math.min(99, form.querySelectorAll(".wpcf7-not-valid-tip").length), + }); + } else if (status === "mail_sent" || status === "mail_failed") { + emit("form_submit_result", { form_id, submission_id, result_code: status }); + } + } catch { + /* 静默(spec §4.5) */ + } + }, { capture: true }); + + // ---- 3) product_view(spec §1.3):init 同步判定 + SPA 切段回调 ---- + const emitProduct = () => { + try { + const slug = matchProduct(location.pathname); + if (slug) emit("product_view", { product_id: slug, inferred: true, rule_version: "v1" }); + } catch { + /* 静默(spec §4.5) */ + } + }; + emitProduct(); // init 时对当前段同步判定 + onSegmentChange(() => emitProduct()); // SPA 切段后新段判定(触发点在 onRouteChange 新段创建后) +} diff --git a/sdk/src/page.ts b/sdk/src/page.ts index 2435fff..76dd9e6 100644 --- a/sdk/src/page.ts +++ b/sdk/src/page.ts @@ -192,6 +192,22 @@ export function getPageSession(): PageSession | null { return current; } +// ---- 段切换监听(spec §1.3):触发点钉死在 onRouteChange 新段创建之后—— +// 不在 close() 内触发(close 还覆盖 pagehide/重复 init,放在那里会在卸载时 +// 重复发 product_view)。模块级注册表:跨 startPageSession 存留;单个回调 +// 异常静默且不阻断后续回调(红线 spec §4.5)。 ---- +const segmentListeners = new Set<(ps: PageSession) => void>(); + +/** 注册 SPA 段切换回调(business.ts T4 消费:新段 path 判定 product_view 等)。 + * 初始段不触发——init 时的同步判定由消费方自理(startPageSession 不回调)。 */ +export function onSegmentChange(fn: (ps: PageSession) => void): void { + try { + segmentListeners.add(fn); + } catch { + /* 静默(spec §4.5):fn 不可哈希等异常环境——降级无该监听 */ + } +} + export function startPageSession(t: Transport): PageSession { activeTransport = t; current?.close(); // 重复 init:先收掉旧段(幂等) @@ -212,6 +228,16 @@ function onRouteChange(): void { current?.close(); current = new PageSession(activeTransport); current.emitPageView(); + // 段切换回调(spec §1.3):新段创建 + PV 之后触发;本地引用防回调内重入切段 + // 拿错段;逐个 try/catch——单个回调异常不冒泡不阻断后续(红线 spec §4.5)。 + const ps = current; + for (const fn of segmentListeners) { + try { + fn(ps); + } catch { + /* 静默(spec §4.5) */ + } + } } catch { /* 静默(spec §4.5) */ } diff --git a/sdk/test/business.test.ts b/sdk/test/business.test.ts new file mode 100644 index 0000000..fa2043d --- /dev/null +++ b/sdk/test/business.test.ts @@ -0,0 +1,562 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Transport } from "../src/transport"; +import { mergeConfig } from "../src/config"; +import { startPageSession, getPageSession, onSegmentChange } from "../src/page"; +import type { PageSession } from "../src/page"; +import { startBusinessTracking } from "../src/business"; + +// business.ts(spec §1.1-§1.3 / §1.5):contact_click(链接协议前缀 + beacon flush)、 +// CF7 全过程(form_start 去重 + _ctk_sid 空占位 / submit capture 生成 submission_id / +// wpcf7statuschanged 终态映射)、product_view(路径段匹配 + onSegmentChange 切段)。 +// 事件属性恰合服务端白名单(constants.py EVENT_PROP_WHITELIST + _biz_props_ok 值域: +// channel 枚举 / form_id cf7-N / submission_id uuid4 小写 / product_id slug≤100)。 +// 注意:startPageSession/startBusinessTracking 安装的监听与 history 包装跨用例存留 +// (模块级 hooksInstalled),旧用例监听把事件推入旧 transport(信封 site 不同——按 +// envelope site_id 过滤免疫);每用例独立 siteId(沿 page.test.ts / stay.test.ts 模式)。 +// isTrusted:happy-dom 合成事件默认 false——dispatch 前 defineProperty 绕过(同 +// page.test.ts persisted 模式),并保留 isTrusted=false 不触发的守护断言。 + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const UUID4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[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", + sendBeacon: beacon, + }); +} + +function mkTransport(siteId: string) { + // flushMs 拉大到 24h:业务事件只入队,flush 全部由用例显式驱动(beacon 除外) + return new Transport( + mergeConfig({ siteId, endpoint: "/api/collect/batch", batchSize: 50, flushMs: 86_400_000 }) + ); +} + +/** fetch 路径(显式 t.flush())事件汇总(按信封 site_id 过滤,免疫旧 transport 串扰) */ +function fetchedEvents(siteId: string) { + const out: Record[] = []; + 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; +} + +/** beacon 路径(contact_click 的 flush(true))事件汇总 */ +async function beaconEvents(siteId: string) { + const out: Record[] = []; + 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.events); + } + return out; +} + +/** URL 重置:走原型方法绕开 page.ts 的 pushState 包装——不触发切段杂事件 */ +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); +} + +function mkLink(href: string) { + const a = document.createElement("a"); + a.href = href; + document.body.appendChild(a); + return a; +} + +function mkForm(opts: { action?: string; id?: string } = {}) { + const form = document.createElement("form"); + if (opts.action !== undefined) form.setAttribute("action", opts.action); + if (opts.id !== undefined) form.id = opts.id; + form.className = "wpcf7-form init"; // CF7 真实形态(class 含 wpcf7-form) + const input = document.createElement("input"); + input.name = "your-name"; + form.appendChild(input); + document.body.appendChild(form); + return form; +} + +function ctkSidField(form: HTMLFormElement) { + return form.querySelector('input[name="_ctk_sid"]') as HTMLInputElement | null; +} + +function click(el: Element) { + el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); +} + +/** isTrusted=true 的合成事件(happy-dom 默认 false) */ +function trusted(type: string, el: Element) { + const ev = new Event(type, { bubbles: true }); + Object.defineProperty(ev, "isTrusted", { value: true }); + el.dispatchEvent(ev); +} + +function submit(form: HTMLFormElement) { + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); +} + +/** CF7 6.x 状态事件(bubbles:true + detail.status——线上 6.1.6 源码实测形态) */ +function cf7status(form: HTMLFormElement, status: string) { + form.dispatchEvent( + new CustomEvent("wpcf7statuschanged", { detail: { status }, bubbles: true }) + ); +} + +describe("business / startBusinessTracking", () => { + 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(); + document.body.innerHTML = ""; // 清掉本用例挂的链接/表单 + }); + + // 必须为本文件首例:startBusinessTracking 先于 startPageSession(异常接线顺序) + // ——getPageSession() 为 null 时无从归属,静默不发。依赖模块级 current 尚为 null。 + it("未启动 PageSession 时静默不发(接线顺序守卫)", () => { + const t = mkTransport("biz-nops"); + startBusinessTracking(t); + click(mkLink("https://wa.me/8613800138000")); + const form = mkForm({ action: "#wpcf7-f156-p20-o1" }); + trusted("focusin", form.querySelector('input[name="your-name"]')!); + submit(form); + cf7status(form, "mail_sent"); + t.flush(); + expect(fetchedEvents("biz-nops")).toHaveLength(0); + }); + + // ---- page.ts onSegmentChange(spec §1.3:触发点钉死在 onRouteChange 新段创建后) ---- + + it("onSegmentChange:切段后以新段回调;初始 startPageSession/同 path 不触发;回调 throw 不冒泡不阻断后续", () => { + setUrl("/seg-a"); + const t = mkTransport("biz-seg"); + startPageSession(t); + const seen: PageSession[] = []; + onSegmentChange(() => { + throw new Error("listener boom"); // 先注册:异常不得阻断后续回调 + }); + onSegmentChange((ps) => seen.push(ps)); + startBusinessTracking(t); // business 也注册段回调(产品页判定) + + expect(seen).toEqual([]); // 初始 startPageSession 不触发——init 判定由消费方自理 + window.history.pushState({}, "", "/seg-a?x=1"); // path 未变(仅 query)→ 不切 + expect(seen).toEqual([]); + expect(() => window.history.pushState({}, "", "/seg-b")).not.toThrow(); // thrower 不冒泡 + expect(seen).toHaveLength(1); + expect(seen[0]).toBe(getPageSession()); // 回调收到的是新段(创建之后) + expect(seen[0]!.path).toBe("/seg-b"); + }); + + // ---- contact_click(spec §1.1) ---- + + it("contact_click:wa.me/api.whatsapp.com/whatsapp:// 三形态(含 http)→ whatsapp + flush(true) beacon", async () => { + setUrl("/cc-wa"); + const t = mkTransport("biz-cc-wa"); + startPageSession(t); + startBusinessTracking(t); + click(mkLink("https://wa.me/8613800138000")); + click(mkLink("https://api.whatsapp.com/send?phone=8613800138000")); + click(mkLink("whatsapp://send?phone=8613800138000")); + click(mkLink("http://wa.me/8613800138000")); // https? 两形态 + + const ccs = (await beaconEvents("biz-cc-wa")).filter((e) => e.event_name === "contact_click"); + expect(ccs).toHaveLength(4); // flush(true) 被调——beacon 路径送达(非 fetch) + expect(ccs.map((e) => e.properties)).toEqual( + Array(4).fill({ channel: "whatsapp" }) // 属性恰为白名单单键 + ); + expect(ccs[0].event_id).toMatch(UUID_RE); + expect(ccs[0].occurred_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(ccs[0].path).toBe("/cc-wa"); + expect(ccs[0].title).toBe(document.title); + // 顶层归属完整(events 形态 + Attribution 展开,同 stay.ts) + expect(ccs[0].site_id).toBe("biz-cc-wa"); + expect(ccs[0].visitor_id).toMatch(UUID_RE); + expect(ccs[0].session_id).toMatch(UUID_RE); + expect(ccs[0].page_id).toBe(getPageSession()!.pageId); + expect(typeof ccs[0].seq).toBe("number"); + expect(ccs[0].purpose).toBe(null); + expect(ccs[0].epoch).toBe(null); + expect(ccs[0].grant_ref).toBe(null); + }); + + it("contact_click:tel:/mailto:(大小写不敏感)→ phone/email", async () => { + setUrl("/cc-tel"); + const t = mkTransport("biz-cc-tel"); + startPageSession(t); + startBusinessTracking(t); + click(mkLink("tel:+8613800138000")); + click(mkLink("mailto:sales@example.com")); + click(mkLink("MAILTO:sales@example.com")); // 大小写不敏感 + + const ccs = (await beaconEvents("biz-cc-tel")).filter((e) => e.event_name === "contact_click"); + expect(ccs.map((e) => e.properties)).toEqual([ + { channel: "phone" }, + { channel: "email" }, + { channel: "email" }, + ]); + }); + + it("contact_click:非链接点击/普通 http 链接/相对链接不触发", async () => { + setUrl("/cc-none"); + const t = mkTransport("biz-cc-none"); + startPageSession(t); + startBusinessTracking(t); + t.flush(); // 先排空 page_view(此后 beacon 只可能由 contact_click 的 flush 触发) + const div = document.createElement("div"); + document.body.appendChild(div); + click(div); + click(mkLink("https://example.com/some-page")); + click(mkLink("/relative/page")); + + t.flush(); + expect(fetchedEvents("biz-cc-none").some((e) => e.event_name === "contact_click")).toBe(false); + expect((await beaconEvents("biz-cc-none")).some((e) => e.event_name === "contact_click")).toBe(false); + }); + + it("contact_click:链接内子元素点击经 closest 委托命中", async () => { + setUrl("/cc-child"); + const t = mkTransport("biz-cc-child"); + startPageSession(t); + startBusinessTracking(t); + const a = mkLink("https://wa.me/8613800138000"); + const span = document.createElement("span"); + a.appendChild(span); + click(span); // e.target 是 span——closest("a[href]") 委托 + + const ccs = (await beaconEvents("biz-cc-child")).filter((e) => e.event_name === "contact_click"); + expect(ccs).toHaveLength(1); + expect(ccs[0].properties).toEqual({ channel: "whatsapp" }); + }); + + // ---- form_*(spec §1.2 / §1.5,CF7 6.x 状态机) ---- + + it("form_start:isTrusted focusin → 事件 + _ctk_sid 空占位;合成 focus 不触发;同表单第二次不重发", () => { + setUrl("/fs"); + const t = mkTransport("biz-fs"); + startPageSession(t); + startBusinessTracking(t); + const form = mkForm({ action: "#wpcf7-f156-p20-o1" }); + const input = form.querySelector('input[name="your-name"]')!; + const plain = document.createElement("form"); // 非 CF7 表单:不触发 + const plainInput = document.createElement("input"); + plain.appendChild(plainInput); + document.body.appendChild(plain); + + trusted("focusin", plainInput); // 非 .wpcf7-form:无事件 + input.dispatchEvent(new Event("focusin", { bubbles: true })); // isTrusted=false:不触发 + t.flush(); + expect(fetchedEvents("biz-fs").some((e) => e.event_name === "form_start")).toBe(false); + + trusted("focusin", input); // 首次真实聚焦 + t.flush(); + const fss = fetchedEvents("biz-fs").filter((e) => e.event_name === "form_start"); + expect(fss).toHaveLength(1); + expect(fss[0].properties).toEqual({ form_id: "cf7-156" }); // 属性恰为白名单单键 + expect(fss[0].page_id).toBe(getPageSession()!.pageId); + expect(fss[0].site_id).toBe("biz-fs"); + const field = ctkSidField(form); + expect(field).toBeTruthy(); // _ctk_sid 空占位已注入(DOM API) + expect(field!.type).toBe("hidden"); + expect(field!.value).toBe(""); + + trusted("focusin", input); // 同表单第二次:WeakSet 去重 + const input2 = document.createElement("input"); + input2.name = "your-email"; + form.appendChild(input2); + trusted("focusin", input2); // 同表单另一字段:仍是同一 form 实例 + t.flush(); + expect(fetchedEvents("biz-fs").filter((e) => e.event_name === "form_start")).toHaveLength(1); + }); + + it("form_start:isTrusted input 事件同触发(focusin 缺席路径)", () => { + setUrl("/fs-input"); + const t = mkTransport("biz-fs-input"); + startPageSession(t); + startBusinessTracking(t); + const form = mkForm({ action: "#wpcf7-f156-p20-o1" }); + + trusted("input", form.querySelector('input[name="your-name"]')!); + t.flush(); + const fss = fetchedEvents("biz-fs-input").filter((e) => e.event_name === "form_start"); + expect(fss).toHaveLength(1); + expect(fss[0].properties).toEqual({ form_id: "cf7-156" }); + expect(ctkSidField(form)).toBeTruthy(); + }); + + it("form_submit:原生 submit → submission_id 生成 + _ctk_sid 值更新 + 事件引用同 ID;两次提交两个不同 ID", () => { + setUrl("/fsub"); + const t = mkTransport("biz-fsub"); + startPageSession(t); + startBusinessTracking(t); + const form = mkForm({ action: "#wpcf7-f156-p20-o1" }); + trusted("focusin", form.querySelector('input[name="your-name"]')!); // 先 start 注入占位 + + submit(form); + t.flush(); + let subs = fetchedEvents("biz-fsub").filter((e) => e.event_name === "form_submit"); + expect(subs).toHaveLength(1); + expect(subs[0].properties.form_id).toBe("cf7-156"); + const sid1 = subs[0].properties.submission_id; + expect(sid1).toMatch(UUID4_RE); // uuid4 小写(服务端 _SUBMIT_RE) + expect(ctkSidField(form)!.value).toBe(sid1); // 隐藏字段值 === 事件引用的 ID + expect(Object.keys(subs[0].properties).sort()).toEqual(["form_id", "submission_id"]); + + const form2 = mkForm({ action: "#wpcf7-f156-p20-o1" }); // 未经 form_start 直接 submit:找/建字段 + submit(form2); + submit(form); // 同表单第二次提交:新 submission_id + t.flush(); + subs = fetchedEvents("biz-fsub").filter((e) => e.event_name === "form_submit"); + expect(subs).toHaveLength(3); + const sid2 = subs[2].properties.submission_id; + expect(sid2).toMatch(UUID4_RE); + expect(sid2).not.toBe(sid1); // 每次尝试独立 + expect(ctkSidField(form)!.value).toBe(sid2); // 字段随最近一次提交更新 + expect(ctkSidField(form2)!.value).toMatch(UUID4_RE); // submit 时自建字段并填值 + }); + + it("form_error:invalid/unaccepted→invalid、spam→spam;field_count 数 .wpcf7-not-valid-tip;引用最近 submission_id;中间态无事件", () => { + setUrl("/ferr"); + const t = mkTransport("biz-ferr"); + startPageSession(t); + startBusinessTracking(t); + const form = mkForm({ action: "#wpcf7-f156-p20-o1" }); + + cf7status(form, "invalid"); // 无前置 submit → submission_id 空串(服务端允许空) + t.flush(); + let errs = fetchedEvents("biz-ferr").filter((e) => e.event_name === "form_error"); + expect(errs).toHaveLength(1); + expect(errs[0].properties).toEqual({ + form_id: "cf7-156", + submission_id: "", + error_code: "invalid", + field_count: 0, + }); + + submit(form); // 生成当次 submission_id + form.insertAdjacentHTML( + "beforeend", + 'e1e2' + ); + cf7status(form, "unaccepted"); // spec §1.2 映射 → invalid(勿漏) + t.flush(); + errs = fetchedEvents("biz-ferr").filter((e) => e.event_name === "form_error"); + expect(errs).toHaveLength(2); + expect(errs[1].properties.error_code).toBe("invalid"); + expect(errs[1].properties.field_count).toBe(2); // 从 DOM .wpcf7-not-valid-tip 计数 + expect(errs[1].properties.submission_id).toBe(ctkSidField(form)!.value); // 引用最近 submission_id + expect(errs[1].properties.submission_id).toMatch(UUID4_RE); + + cf7status(form, "spam"); + t.flush(); + errs = fetchedEvents("biz-ferr").filter((e) => e.event_name === "form_error"); + expect(errs).toHaveLength(3); + expect(errs[2].properties).toEqual({ + form_id: "cf7-156", + submission_id: errs[1].properties.submission_id, + error_code: "spam", + field_count: 2, + }); + + for (const s of ["init", "validating", "sending", "resetting"]) cf7status(form, s); + t.flush(); + const all = fetchedEvents("biz-ferr"); + expect(all.filter((e) => e.event_name === "form_error")).toHaveLength(3); // 中间态不产生事件 + expect(all.filter((e) => e.event_name === "form_submit_result")).toHaveLength(0); + }); + + it("form_submit_result:mail_sent/mail_failed → result_code;引用最近 submission_id", () => { + setUrl("/fres"); + const t = mkTransport("biz-fres"); + startPageSession(t); + startBusinessTracking(t); + const form = mkForm({ action: "#wpcf7-f156-p20-o1" }); + + submit(form); + const sid1 = ctkSidField(form)!.value; + cf7status(form, "mail_sent"); + submit(form); // 第二次尝试 → 新 ID + const sid2 = ctkSidField(form)!.value; + expect(sid2).not.toBe(sid1); + cf7status(form, "mail_failed"); + t.flush(); + + const rs = fetchedEvents("biz-fres").filter((e) => e.event_name === "form_submit_result"); + expect(rs).toHaveLength(2); + expect(rs[0].properties).toEqual({ + form_id: "cf7-156", + submission_id: sid1, + result_code: "mail_sent", + }); + expect(rs[1].properties).toEqual({ + form_id: "cf7-156", + submission_id: sid2, + result_code: "mail_failed", + }); + }); + + it("form_id 规整:action #wpcf7-fN-pM-oK → cf7-N;action 解析不出走 form.id;都失败 → cf7-0", () => { + setUrl("/fid"); + const t = mkTransport("biz-fid"); + startPageSession(t); + startBusinessTracking(t); + const f1 = mkForm({ action: "#wpcf7-f156-p20-o1" }); + trusted("focusin", f1.querySelector("input")!); + const f2 = mkForm({ action: "/contact-us/", id: "wpcf7-f42-o1" }); // id 兜底路径 + trusted("focusin", f2.querySelector("input")!); + const f3 = mkForm({ action: "/contact-us/" }); // 无 id:解析不出 + trusted("focusin", f3.querySelector("input")!); + t.flush(); + const ids = fetchedEvents("biz-fid") + .filter((e) => e.event_name === "form_start") + .map((e) => e.properties.form_id); + expect(ids).toEqual(["cf7-156", "cf7-42", "cf7-0"]); + }); + + // ---- product_view(spec §1.3:非锚定段匹配 + onSegmentChange 切段) ---- + + it("product_view:init 时 /product/slug/ 判定 → inferred=true rule_version=v1 + 顶层归属", () => { + setUrl("/product/hydraulic-press-100t/"); + const t = mkTransport("biz-pv-init"); + const ps = startPageSession(t); + startBusinessTracking(t); + t.flush(); + const pvs = fetchedEvents("biz-pv-init").filter((e) => e.event_name === "product_view"); + expect(pvs).toHaveLength(1); + const pv = pvs[0]; + expect(pv.properties).toEqual({ + product_id: "hydraulic-press-100t", + inferred: true, + rule_version: "v1", + }); + expect(pv.event_id).toMatch(UUID_RE); + expect(pv.occurred_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(pv.path).toBe("/product/hydraulic-press-100t/"); + expect(pv.title).toBe(document.title); + expect(pv.site_id).toBe("biz-pv-init"); + expect(pv.visitor_id).toMatch(UUID_RE); + expect(pv.session_id).toMatch(UUID_RE); + expect(pv.page_id).toBe(ps.pageId); + expect(typeof pv.seq).toBe("number"); + expect(pv.purpose).toBe(null); // V1.1 预留从快照展开 + expect(pv.epoch).toBe(null); + expect(pv.grant_ref).toBe(null); + }); + + it("product_view:/de/product/slug/ 多语言前缀命中(非锚定段匹配);slug 截断 ≤100", () => { + setUrl("/de/product/flachbett-drucker/"); + let t = mkTransport("biz-pv-de"); + startPageSession(t); + startBusinessTracking(t); + t.flush(); + let pvs = fetchedEvents("biz-pv-de").filter((e) => e.event_name === "product_view"); + expect(pvs).toHaveLength(1); + expect(pvs[0].properties.product_id).toBe("flachbett-drucker"); + + setUrl("/product/" + "a".repeat(150) + "/"); // 超 100 截断(对齐服务端 _PROD_RE 上限) + t = mkTransport("biz-pv-long"); + startPageSession(t); + startBusinessTracking(t); + t.flush(); + pvs = fetchedEvents("biz-pv-long").filter((e) => e.event_name === "product_view"); + expect(pvs).toHaveLength(1); + expect(pvs[0].properties.product_id).toHaveLength(100); + }); + + it("product_view:/product-category/suv/、/products/x/、裸 /product/ 不触发(段匹配精确性)", () => { + setUrl("/product-category/suv/"); + let t = mkTransport("biz-pv-cat"); + startPageSession(t); + startBusinessTracking(t); + setUrl("/products/x/"); // 复数段 ≠ "product"(精确段匹配) + t = mkTransport("biz-pv-plural"); + startPageSession(t); + startBusinessTracking(t); + setUrl("/product/"); // "product" 后无 slug 段 + t = mkTransport("biz-pv-bare"); + startPageSession(t); + startBusinessTracking(t); + setUrl("/product/%E4%B8%AD%E6%96%87/"); // 百分号编码 slug:服务端 _PROD_RE 必拒——客户端即不发 + t = mkTransport("biz-pv-enc"); + startPageSession(t); + startBusinessTracking(t); + t.flush(); + expect(fetchedEvents("biz-pv-cat").some((e) => e.event_name === "product_view")).toBe(false); + expect(fetchedEvents("biz-pv-plural").some((e) => e.event_name === "product_view")).toBe(false); + expect(fetchedEvents("biz-pv-bare").some((e) => e.event_name === "product_view")).toBe(false); + expect(fetchedEvents("biz-pv-enc").some((e) => e.event_name === "product_view")).toBe(false); + }); + + it("form_error:field_count 钳制上限 99(服务端 0-99 契约)", () => { + setUrl("/ferr-cap"); + const t = mkTransport("biz-ferr-cap"); + startPageSession(t); + startBusinessTracking(t); + const form = mkForm({ action: "#wpcf7-f156-p20-o1" }); + for (let i = 0; i < 105; i++) { + const tip = document.createElement("span"); + tip.className = "wpcf7-not-valid-tip"; + form.appendChild(tip); + } + cf7status(form, "invalid"); + t.flush(); + const errs = fetchedEvents("biz-ferr-cap").filter((e) => e.event_name === "form_error"); + expect(errs).toHaveLength(1); + expect(errs[0].properties.field_count).toBe(99); // 105 → 钳 99,不落服务端拒绝区 + }); + + it("product_view:onSegmentChange 切段到产品页 → 事件带新 page_id;切段到非产品页不发", () => { + setUrl("/pv-spa-home"); + const t = mkTransport("biz-pv-spa"); + const ps1 = startPageSession(t); + startBusinessTracking(t); + t.flush(); + expect(fetchedEvents("biz-pv-spa").some((e) => e.event_name === "product_view")).toBe(false); + + window.history.pushState({}, "", "/pv-spa-mid/"); // 切段到非产品页 + t.flush(); + expect(fetchedEvents("biz-pv-spa").some((e) => e.event_name === "product_view")).toBe(false); + + window.history.pushState({}, "", "/ar/product/suv-ar/"); // 切段到产品页(阿语前缀) + const ps2 = getPageSession()!; + expect(ps2).not.toBe(ps1); + t.flush(); + const pvs = fetchedEvents("biz-pv-spa").filter((e) => e.event_name === "product_view"); + expect(pvs).toHaveLength(1); + expect(pvs[0].properties).toEqual({ product_id: "suv-ar", inferred: true, rule_version: "v1" }); + expect(pvs[0].page_id).toBe(ps2.pageId); // 事件带新 page_id + expect(pvs[0].page_id).not.toBe(ps1.pageId); + }); + + // ---- 红线(spec §4.5):监听器内异常不冒泡宿主 ---- + + it("红线:emit 内部异常(t.push 抛错)不冒泡——click/focusin/submit/status 各路径", () => { + setUrl("/red"); + const t = mkTransport("biz-red"); + startPageSession(t); + startBusinessTracking(t); + vi.spyOn(t, "push").mockImplementation(() => { + throw new Error("push boom"); + }); + const form = mkForm({ action: "#wpcf7-f156-p20-o1" }); + expect(() => click(mkLink("https://wa.me/8613800138000"))).not.toThrow(); + expect(() => trusted("focusin", form.querySelector("input")!)).not.toThrow(); + expect(() => submit(form)).not.toThrow(); + expect(() => cf7status(form, "mail_sent")).not.toThrow(); + expect(() => window.history.pushState({}, "", "/red-next")).not.toThrow(); // 段回调 emit 异常同静默 + }); +});