@drip-apex/sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19283 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19267 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16636 -13533
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +28 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2071 -0
  128. package/src/consent-evaluation-borlabs.test.ts +41 -0
  129. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  130. package/src/consent-evaluation.ts +151 -0
  131. package/src/css.test.ts +30 -0
  132. package/src/css.ts +120 -6
  133. package/src/cwv.test.ts +60 -77
  134. package/src/debug-loader.test.ts +214 -0
  135. package/src/debug-loader.ts +41 -0
  136. package/src/decide-assignments.test.ts +196 -0
  137. package/src/decide-assignments.ts +204 -0
  138. package/src/delivery-timing.test.ts +104 -0
  139. package/src/delivery-timing.ts +89 -0
  140. package/src/early-interaction-capture.test.ts +451 -0
  141. package/src/early-interaction-capture.ts +230 -0
  142. package/src/early-reassert.test.ts +207 -0
  143. package/src/early-reassert.ts +152 -0
  144. package/src/edge-adoption.test.ts +435 -0
  145. package/src/edge-runtime.ts +30 -0
  146. package/src/editor/api.test.ts +109 -0
  147. package/src/editor/api.ts +34 -3
  148. package/src/editor/controller.test.ts +297 -14
  149. package/src/editor/controller.ts +69 -5
  150. package/src/editor/index.test.ts +2 -0
  151. package/src/editor/index.ts +1 -0
  152. package/src/editor/publication-result.ts +58 -0
  153. package/src/editor/types.ts +10 -1
  154. package/src/editor/view-kit.ts +2 -1
  155. package/src/editor/view.test.ts +108 -0
  156. package/src/editor/view.ts +49 -7
  157. package/src/endpoint.test.ts +15 -0
  158. package/src/endpoint.ts +10 -0
  159. package/src/exclusion-groups.ts +8 -18
  160. package/src/flags.test.ts +88 -1
  161. package/src/flags.ts +29 -29
  162. package/src/global-runtime-reconciler.test.ts +271 -0
  163. package/src/goal-eligibility.test.ts +29 -0
  164. package/src/goal-eligibility.ts +17 -0
  165. package/src/goals.test.ts +144 -1
  166. package/src/goals.ts +97 -18
  167. package/src/guards.test.ts +99 -1
  168. package/src/guards.ts +34 -15
  169. package/src/handover-cache-order.test.ts +6 -3
  170. package/src/handover.test.ts +154 -8
  171. package/src/handover.ts +122 -12
  172. package/src/hash.ts +29 -54
  173. package/src/holdout.test.ts +26 -0
  174. package/src/hydration.test.ts +73 -2
  175. package/src/hydration.ts +18 -9
  176. package/src/index.ts +130 -7287
  177. package/src/init-analytics.test.ts +1694 -0
  178. package/src/init-anti-flicker.test.ts +1211 -0
  179. package/src/init-cart-attributes.test.ts +2003 -0
  180. package/src/init-config-resolution.test.ts +999 -0
  181. package/src/init-consent.test.ts +971 -0
  182. package/src/init-empty.test.ts +11 -5406
  183. package/src/init-integrations.test.ts +846 -0
  184. package/src/init-page-activation.test.ts +5064 -0
  185. package/src/init-qa-loading.test.ts +740 -0
  186. package/src/init-redirects.test.ts +2156 -0
  187. package/src/init-test-helpers.ts +287 -0
  188. package/src/inline-bootstrap.test.ts +184 -0
  189. package/src/inline-bootstrap.ts +73 -0
  190. package/src/js.test.ts +51 -1
  191. package/src/js.ts +86 -2
  192. package/src/money.test.ts +55 -1
  193. package/src/money.ts +104 -2
  194. package/src/money.web-parity.contract.test.ts +85 -0
  195. package/src/mutate.test.ts +474 -10
  196. package/src/mutate.ts +739 -230
  197. package/src/page-trigger.test.ts +218 -8
  198. package/src/performance.ts +10 -0
  199. package/src/persistence-decoders.ts +18 -0
  200. package/src/public-api.ts +1828 -0
  201. package/src/qa-assistant-state.test.ts +249 -3
  202. package/src/qa-assistant-state.ts +146 -6
  203. package/src/qa-assistant-view.test.ts +220 -2
  204. package/src/qa-assistant-view.ts +102 -5
  205. package/src/react/index.test.tsx +37 -0
  206. package/src/react/index.tsx +19 -3
  207. package/src/reassert-sentinel.test.ts +155 -1
  208. package/src/reassert-sentinel.ts +168 -8
  209. package/src/redirect.test.ts +541 -0
  210. package/src/redirect.ts +572 -0
  211. package/src/replay-recorder-entry.ts +115 -0
  212. package/src/replay-signal-state.ts +100 -0
  213. package/src/replay-signals.test.ts +249 -0
  214. package/src/replay-signals.ts +151 -0
  215. package/src/replay.test.ts +764 -0
  216. package/src/replay.ts +514 -0
  217. package/src/router.test.ts +61 -2
  218. package/src/router.ts +50 -12
  219. package/src/runtime-state.ts +507 -0
  220. package/src/runtime.test.ts +174 -7
  221. package/src/runtime.ts +88 -14
  222. package/src/sanitize.test.ts +34 -0
  223. package/src/sanitize.ts +23 -12
  224. package/src/shadow.test.ts +187 -0
  225. package/src/shadow.ts +99 -0
  226. package/src/shopify-cart.test.ts +733 -23
  227. package/src/shopify-cart.ts +402 -49
  228. package/src/storefront-vitals.test.ts +217 -0
  229. package/src/storefront-vitals.ts +206 -0
  230. package/src/surface-signals.ts +6 -0
  231. package/src/targeting-conformance.test.ts +9 -0
  232. package/src/targeting-diagnostics.ts +38 -8
  233. package/src/targeting-parity-corpus.json +559 -0
  234. package/src/targeting-parity-corpus.test.ts +18 -0
  235. package/src/targeting.detailed.test.ts +105 -0
  236. package/src/targeting.test.ts +211 -0
  237. package/src/targeting.ts +88 -207
  238. package/src/track.test.ts +147 -0
  239. package/src/track.ts +125 -26
  240. package/src/types.ts +120 -13
  241. package/src/wait.test.ts +23 -0
  242. package/src/wait.ts +129 -13
  243. package/src/weekday-delivery.test.ts +56 -0
  244. package/src/weekday-schedule-parity-corpus.json +92 -0
  245. package/src/weekday-schedule.test.ts +69 -0
  246. package/src/weekday-schedule.ts +20 -0
@@ -0,0 +1,1694 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ RECENT_MONDAY_NOON, documentListeners, windowListeners, appendedAntiFlickerStyle, configPayloadHash, flushDynamicImports, installCookieJar, installMutationObserverStub, makeClassList, makeStorage, readCookieValue, readDecodedDripContext, registerInitTestLifecycle, verifiedHandoverResponse,
4
+ } from "./init-test-helpers";
5
+
6
+ describe("init analytics", () => {
7
+ registerInitTestLifecycle();
8
+
9
+
10
+ it.each([
11
+ "https://events.test/base/events",
12
+ "https://events.test/base/v1/events",
13
+ "https://events.test/base/v1/events/",
14
+ ])("preserves endpoint path segments while canonicalizing %s", async (endpoint) => {
15
+ const { flush, init } = await import("./index");
16
+ init({
17
+ antiFlickerMode: "off",
18
+ endpoint,
19
+ experiments: [],
20
+ shopId: "shop_123",
21
+ trackingStart: "immediate",
22
+ });
23
+ await flush();
24
+ expect(vi.mocked(globalThis.fetch).mock.calls[0]?.[0]).toBe(
25
+ "https://events.test/base/v1/events",
26
+ );
27
+ });
28
+
29
+
30
+ it("emits one install conflict without duplicating pageview or sdk perf events", async () => {
31
+ const { flush, init } = await import("./index");
32
+ const baseConfig = {
33
+ antiFlickerMode: "off" as const,
34
+ endpoint: "https://events.test",
35
+ experiments: [],
36
+ pageTrigger: { defaultMode: "manual" as const },
37
+ runtimeProfile: "full",
38
+ sdkDeliveryMode: "canary",
39
+ sdkDeliverySource: "external",
40
+ sdkPolicyRevision: "sdk_test1234",
41
+ shopId: "shop_123",
42
+ trackingStart: "immediate" as const,
43
+ };
44
+
45
+ init({ ...baseConfig, installSurface: "direct_script", sdkVersion: "0.1.0+first" });
46
+ init({ ...baseConfig, installSurface: "shopify_app_embed", sdkVersion: "0.1.0+second" });
47
+ init({ ...baseConfig, installSurface: "legacy_shopify_redirect", sdkVersion: "0.1.0+third" });
48
+ await flush();
49
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
50
+
51
+ const fetchMock = vi.mocked(globalThis.fetch);
52
+ const events = fetchMock.mock.calls.flatMap((call) => {
53
+ const body = JSON.parse(String(call[1]?.body));
54
+ return body.events as Array<{ type: string; data: Record<string, unknown> }>;
55
+ });
56
+ expect(events.filter((event) => event.type === "sdk_perf")).toHaveLength(1);
57
+ expect(events.find((event) => event.type === "sdk_perf")?.data.perf_event_kind).toBe("init");
58
+ expect(events.filter((event) => event.type === "pageview")).toHaveLength(1);
59
+ expect(events.filter((event) => event.type === "sdk_install_conflict")).toEqual([
60
+ expect.objectContaining({
61
+ data: expect.objectContaining({
62
+ first_install_surface: "direct_script",
63
+ attempted_install_surface: "shopify_app_embed",
64
+ sdk_version: "0.1.0+second",
65
+ runtime_profile: "full",
66
+ }),
67
+ }),
68
+ ]);
69
+ });
70
+
71
+
72
+ it("uses hydrated proxy delivery for a fresh runtime-event tracker and retries direct once", async () => {
73
+ const fetchMock = vi.mocked(globalThis.fetch);
74
+ fetchMock
75
+ .mockResolvedValueOnce(new Response(null, { status: 502 }))
76
+ .mockResolvedValueOnce(new Response(null, { status: 202 }));
77
+ const { __test, init } = await import("./index");
78
+ const capturedBeforeHydration = {
79
+ antiFlickerMode: "off" as const,
80
+ endpoint: "https://events.test",
81
+ experiments: [],
82
+ projectPrerequisites: { startTrackingRule: "return false;" },
83
+ shopId: "shop_123",
84
+ trackingStart: "immediate" as const,
85
+ };
86
+ init({ ...capturedBeforeHydration, proxyEventDelivery: { enabled: true } });
87
+ fetchMock.mockClear();
88
+ fetchMock
89
+ .mockResolvedValueOnce(new Response(null, { status: 502 }))
90
+ .mockResolvedValueOnce(new Response(null, { status: 202 }));
91
+
92
+ __test!.sendRuntimeEventNow(capturedBeforeHydration, "collection_viewed", { collection_id: "c1" }, "visitor-1");
93
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
94
+
95
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("https://plain.test/apps/apex/events");
96
+ expect(fetchMock.mock.calls[1]?.[0]).toBe("https://events.test/v1/events");
97
+ expect(String(fetchMock.mock.calls[1]?.[1]?.body)).toContain('"delivery_downgrade":"proxy_failed"');
98
+ expect(String(fetchMock.mock.calls[1]?.[1]?.body)).toContain('"type":"collection_viewed"');
99
+ });
100
+
101
+
102
+ it("tracks virtual funnel steps once per session by default", async () => {
103
+ const { flush, init, trackStep } = await import("./index");
104
+
105
+ init({
106
+ antiFlickerMode: "off",
107
+ endpoint: "https://events.test",
108
+ experiments: [],
109
+ pageTrigger: { defaultMode: "manual" },
110
+ shopId: "shop_123",
111
+ trackingStart: "immediate",
112
+ });
113
+ trackStep("postcode_entered", { field: "zip" });
114
+ trackStep("postcode_entered", { field: "zip" });
115
+ trackStep("delivery_method_seen", { oncePerSession: false, method: "express" });
116
+ trackStep("delivery_method_seen", { oncePerSession: false, method: "standard" });
117
+ await flush();
118
+
119
+ const fetchMock = vi.mocked(globalThis.fetch);
120
+ expect(fetchMock).toHaveBeenCalledTimes(1);
121
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
122
+ const steps = body.events.filter((event: { type: string }) => event.type === "funnel_step");
123
+ expect(steps).toHaveLength(3);
124
+ expect(steps.map((event: { data: { step_name: string } }) => event.data.step_name)).toEqual([
125
+ "postcode_entered",
126
+ "delivery_method_seen",
127
+ "delivery_method_seen",
128
+ ]);
129
+ expect(steps[0].data).toMatchObject({
130
+ source: "virtual_step",
131
+ step_name: "postcode_entered",
132
+ path: "/",
133
+ field: "zip",
134
+ });
135
+ expect(steps[1].data).not.toHaveProperty("oncePerSession");
136
+ });
137
+
138
+
139
+ it("does not write funnel-step dedupe storage before consent allows tracking", async () => {
140
+ const { flush, init, setConsent, trackStep } = await import("./index");
141
+
142
+ init({
143
+ antiFlickerMode: "off",
144
+ consentMode: { enabled: true },
145
+ endpoint: "https://events.test",
146
+ experiments: [],
147
+ hasConsent: false,
148
+ pageTrigger: { defaultMode: "manual" },
149
+ shopId: "shop_123",
150
+ trackingStart: "immediate",
151
+ });
152
+ trackStep("postcode_entered", { field: "zip" });
153
+ await flush();
154
+
155
+ expect(globalThis.fetch).not.toHaveBeenCalled();
156
+ expect((globalThis as any).sessionStorage.length).toBe(0);
157
+
158
+ setConsent(true);
159
+ trackStep("postcode_entered", { field: "zip" });
160
+ await flush();
161
+
162
+ const fetchMock = vi.mocked(globalThis.fetch);
163
+ expect(fetchMock).toHaveBeenCalledTimes(1);
164
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
165
+ const steps = body.events.filter((event: { type: string }) => event.type === "funnel_step");
166
+ expect(steps).toHaveLength(1);
167
+ expect(steps[0].data).toMatchObject({
168
+ source: "virtual_step",
169
+ step_name: "postcode_entered",
170
+ field: "zip",
171
+ });
172
+ expect((globalThis as any).sessionStorage.length).toBeGreaterThan(0);
173
+ });
174
+
175
+
176
+ it("uses a hard timeout when idle analytics has not fired yet", async () => {
177
+ const requestIdleCallback = vi.fn(() => 42);
178
+ const cancelIdleCallback = vi.fn();
179
+ (globalThis as any).requestIdleCallback = requestIdleCallback;
180
+ (globalThis as any).cancelIdleCallback = cancelIdleCallback;
181
+
182
+ const { flush, init } = await import("./index");
183
+
184
+ init({
185
+ antiFlickerMode: "off",
186
+ endpoint: "https://events.test",
187
+ experiments: [],
188
+ pageTrigger: { defaultMode: "manual" },
189
+ shopId: "shop_123",
190
+ trackingIdleTimeoutMs: 2000,
191
+ trackingStart: "idle",
192
+ });
193
+
194
+ expect(requestIdleCallback).toHaveBeenCalledTimes(1);
195
+ expect(globalThis.fetch).not.toHaveBeenCalled();
196
+
197
+ await vi.advanceTimersByTimeAsync(2000);
198
+ await flush();
199
+
200
+ const fetchMock = vi.mocked(globalThis.fetch);
201
+ expect(fetchMock).toHaveBeenCalledTimes(1);
202
+ expect(cancelIdleCallback).toHaveBeenCalledWith(42);
203
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
204
+ expect(body.events.map((event: { type: string }) => event.type)).toEqual([
205
+ "sdk_perf",
206
+ "pageview",
207
+ ]);
208
+ });
209
+
210
+
211
+ it("attributes public track events to the active assignment by default", async () => {
212
+ const { flush, init, track } = await import("./index");
213
+
214
+ init({
215
+ antiFlickerMode: "off",
216
+ endpoint: "https://events.test",
217
+ experiments: [
218
+ {
219
+ id: "exp_123",
220
+ status: "running",
221
+ trafficAllocation: 1,
222
+ variations: [
223
+ {
224
+ id: "var_123",
225
+ weight: 1,
226
+ mutations: [],
227
+ },
228
+ ],
229
+ },
230
+ ],
231
+ shopId: "shop_123",
232
+ trackingStart: "immediate",
233
+ });
234
+ track("plain_site_primary_click", { source: "onboarding_probe" });
235
+ await flush();
236
+
237
+ const fetchMock = vi.mocked(globalThis.fetch);
238
+ expect(fetchMock).toHaveBeenCalledTimes(1);
239
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
240
+ const customEvent = body.events.find((event: { type: string }) => event.type === "plain_site_primary_click");
241
+ expect(customEvent).toMatchObject({
242
+ experimentId: "exp_123",
243
+ variationId: "var_123",
244
+ url: "https://plain.test/",
245
+ });
246
+ });
247
+
248
+
249
+ it("strips redirect assignment carry before the opt-out guard returns", async () => {
250
+ const carriedUrl = new URL("https://plain.test/products/destination?keep=1");
251
+ carriedUrl.searchParams.set(
252
+ "_apex_assignment",
253
+ JSON.stringify({
254
+ e: "exp_redirect",
255
+ v: "landing",
256
+ epoch: 1,
257
+ handover: "visitor_source",
258
+ }),
259
+ );
260
+ (globalThis as any).location.href = carriedUrl.href;
261
+ (globalThis as any).location.search = carriedUrl.search;
262
+ (globalThis as any).localStorage.setItem("drip_optout", "1");
263
+
264
+ const { init } = await import("./index");
265
+ expect(
266
+ init({
267
+ antiFlickerMode: "off",
268
+ endpoint: "https://events.test",
269
+ experiments: [],
270
+ shopId: "shop_123",
271
+ trackingStart: "immediate",
272
+ }),
273
+ ).toEqual([]);
274
+
275
+ const cleanedHref = vi.mocked(globalThis.history.replaceState).mock
276
+ .calls[0]?.[2];
277
+ expect(cleanedHref).toBeDefined();
278
+ const cleanedUrl = new URL(String(cleanedHref));
279
+ expect(cleanedUrl.searchParams.has("_apex_assignment")).toBe(false);
280
+ expect(cleanedUrl.searchParams.get("keep")).toBe("1");
281
+ expect(
282
+ (globalThis as any).localStorage.getItem("drip_assignments_v1"),
283
+ ).toBeNull();
284
+ expect(
285
+ (globalThis as any).sessionStorage.getItem(
286
+ "drip_redirect_assignment_pending_v1",
287
+ ),
288
+ ).toBeNull();
289
+ });
290
+
291
+
292
+ it("normalizes valid trackRevenue currency and flags invalid currency", async () => {
293
+ const { flush, init, trackRevenue } = await import("./index");
294
+
295
+ init({
296
+ antiFlickerMode: "off",
297
+ endpoint: "https://events.test",
298
+ experiments: [
299
+ {
300
+ id: "exp_revenue",
301
+ status: "running",
302
+ trafficAllocation: 1,
303
+ variations: [{ id: "var_revenue", weight: 1, mutations: [] }],
304
+ },
305
+ ],
306
+ shopId: "shop_123",
307
+ trackingStart: "immediate",
308
+ });
309
+ trackRevenue(10, { currency: " eur ", orderId: "order-1" });
310
+ trackRevenue(11, { currency: "euro", orderId: "order-2" });
311
+ await flush();
312
+
313
+ const fetchMock = vi.mocked(globalThis.fetch);
314
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
315
+ const revenueEvents = body.events.filter((event: { data?: { goalId?: string } }) =>
316
+ event.data?.goalId === "__revenue__"
317
+ );
318
+ expect(revenueEvents).toHaveLength(2);
319
+ expect(revenueEvents[0].data).toMatchObject({
320
+ revenue: 10,
321
+ currency: "EUR",
322
+ orderId: "order-1",
323
+ });
324
+ expect(revenueEvents[1].data).toMatchObject({
325
+ revenue: 11,
326
+ orderId: "order-2",
327
+ currency_raw: "euro",
328
+ money_error: "invalid_currency",
329
+ });
330
+ expect(revenueEvents[1].data).not.toHaveProperty("currency");
331
+ });
332
+
333
+
334
+ it("shares one event ID across each trackRevenue assignment fanout", async () => {
335
+ const { flush, init, trackRevenue } = await import("./index");
336
+
337
+ init({
338
+ antiFlickerMode: "off",
339
+ endpoint: "https://events.test",
340
+ experiments: [
341
+ {
342
+ id: "exp_revenue_fanout_a",
343
+ status: "running",
344
+ trafficAllocation: 1,
345
+ variations: [{ id: "var_revenue_fanout_a", weight: 1, mutations: [] }],
346
+ },
347
+ {
348
+ id: "exp_revenue_fanout_b",
349
+ status: "running",
350
+ trafficAllocation: 1,
351
+ variations: [{ id: "var_revenue_fanout_b", weight: 1, mutations: [] }],
352
+ },
353
+ ],
354
+ shopId: "shop_123",
355
+ trackingStart: "immediate",
356
+ });
357
+
358
+ trackRevenue(12);
359
+ trackRevenue(13);
360
+ await flush();
361
+
362
+ const body = JSON.parse(
363
+ String(vi.mocked(globalThis.fetch).mock.calls[0]?.[1]?.body),
364
+ );
365
+ const revenueEvents = body.events.filter(
366
+ (event: { data?: { goalId?: string } }) =>
367
+ event.data?.goalId === "__revenue__",
368
+ ) as Array<{ event_id: string; data: { revenue: number } }>;
369
+ const firstCallIds = revenueEvents
370
+ .filter((event) => event.data.revenue === 12)
371
+ .map((event) => event.event_id);
372
+ const secondCallIds = revenueEvents
373
+ .filter((event) => event.data.revenue === 13)
374
+ .map((event) => event.event_id);
375
+
376
+ expect(firstCallIds).toHaveLength(2);
377
+ expect(secondCallIds).toHaveLength(2);
378
+ expect(firstCallIds[0]).toEqual(expect.any(String));
379
+ expect(secondCallIds[0]).toEqual(expect.any(String));
380
+ expect(new Set(firstCallIds)).toHaveLength(1);
381
+ expect(new Set(secondCallIds)).toHaveLength(1);
382
+ expect(secondCallIds[0]).not.toBe(firstCallIds[0]);
383
+ });
384
+
385
+
386
+ it("emits one unattributed cohort-tagged revenue event for a holdout visitor", async () => {
387
+ const { isHoldoutVisitor } = await import("./hash");
388
+ const userId = Array.from({ length: 1000 }, (_, index) => `revenue-holdout-${index}`)
389
+ .find((candidate) =>
390
+ isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, candidate)
391
+ )!;
392
+ const { flush, getAssignments, init, trackRevenue } = await import("./index");
393
+
394
+ init({
395
+ antiFlickerMode: "off",
396
+ endpoint: "https://events.test",
397
+ experiments: [{
398
+ id: "exp_revenue_holdout",
399
+ status: "running",
400
+ trafficAllocation: 1,
401
+ variations: [{ id: "var_revenue_holdout", weight: 1, mutations: [] }],
402
+ }],
403
+ holdout_v1_enabled: true,
404
+ holdout_pct: 10,
405
+ shopId: "shop_123",
406
+ trackingStart: "immediate",
407
+ userId,
408
+ });
409
+ expect(getAssignments()).toEqual([]);
410
+
411
+ trackRevenue(35, { currency: "EUR", orderId: "holdout-order" });
412
+ await flush();
413
+
414
+ const body = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[1]?.body));
415
+ const revenueEvents = body.events.filter((event: { data?: { goalId?: string } }) =>
416
+ event.data?.goalId === "__revenue__"
417
+ );
418
+ expect(revenueEvents).toHaveLength(1);
419
+ expect(revenueEvents[0]).toMatchObject({
420
+ type: "goal",
421
+ data: {
422
+ cohort: "holdout",
423
+ currency: "EUR",
424
+ goalId: "__revenue__",
425
+ orderId: "holdout-order",
426
+ revenue: 35,
427
+ },
428
+ });
429
+ expect(revenueEvents[0]).not.toHaveProperty("experimentId");
430
+ expect(revenueEvents[0]).not.toHaveProperty("variationId");
431
+ });
432
+
433
+
434
+ it("keeps per-assignment cohort-tagged revenue for an active visitor", async () => {
435
+ const { isHoldoutVisitor } = await import("./hash");
436
+ const userId = Array.from({ length: 1000 }, (_, index) => `revenue-active-${index}`)
437
+ .find((candidate) =>
438
+ !isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, candidate)
439
+ )!;
440
+ const { flush, init, trackRevenue } = await import("./index");
441
+
442
+ init({
443
+ antiFlickerMode: "off",
444
+ endpoint: "https://events.test",
445
+ experiments: [{
446
+ id: "exp_revenue_active",
447
+ status: "running",
448
+ trafficAllocation: 1,
449
+ variations: [{ id: "var_revenue_active", weight: 1, mutations: [] }],
450
+ }],
451
+ holdout_v1_enabled: true,
452
+ holdout_pct: 10,
453
+ shopId: "shop_123",
454
+ trackingStart: "immediate",
455
+ userId,
456
+ });
457
+ trackRevenue(42, { orderId: "active-order" });
458
+ await flush();
459
+
460
+ const body = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[1]?.body));
461
+ const revenueEvents = body.events.filter((event: { data?: { goalId?: string } }) =>
462
+ event.data?.goalId === "__revenue__"
463
+ );
464
+ expect(revenueEvents).toHaveLength(1);
465
+ expect(revenueEvents[0]).toMatchObject({
466
+ type: "goal",
467
+ experimentId: "exp_revenue_active",
468
+ variationId: "var_revenue_active",
469
+ data: {
470
+ cohort: "active",
471
+ goalId: "__revenue__",
472
+ orderId: "active-order",
473
+ revenue: 42,
474
+ },
475
+ });
476
+ });
477
+
478
+
479
+ it("emits one unattributed cohort-tagged revenue event for an active visitor without an assignment", async () => {
480
+ const { isHoldoutVisitor } = await import("./hash");
481
+ const userId = Array.from({ length: 1000 }, (_, index) => `revenue-unassigned-active-${index}`)
482
+ .find((candidate) =>
483
+ !isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, candidate)
484
+ )!;
485
+ const { flush, getAssignments, init, trackRevenue } = await import("./index");
486
+
487
+ init({
488
+ antiFlickerMode: "off",
489
+ endpoint: "https://events.test",
490
+ experiments: [{
491
+ id: "exp_revenue_unassigned_active",
492
+ status: "running",
493
+ trafficAllocation: 0,
494
+ variations: [{ id: "var_revenue_unassigned_active", weight: 1, mutations: [] }],
495
+ }],
496
+ holdout_v1_enabled: true,
497
+ holdout_pct: 10,
498
+ shopId: "shop_123",
499
+ trackingStart: "immediate",
500
+ userId,
501
+ });
502
+ expect(getAssignments()).toEqual([]);
503
+
504
+ trackRevenue(43, { currency: "EUR", orderId: "active-unassigned-order" });
505
+ await flush();
506
+
507
+ const body = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[1]?.body));
508
+ const revenueEvents = body.events.filter((event: { data?: { goalId?: string } }) =>
509
+ event.data?.goalId === "__revenue__"
510
+ );
511
+ expect(revenueEvents).toHaveLength(1);
512
+ expect(revenueEvents[0]).toMatchObject({
513
+ type: "goal",
514
+ data: {
515
+ cohort: "active",
516
+ currency: "EUR",
517
+ goalId: "__revenue__",
518
+ orderId: "active-unassigned-order",
519
+ revenue: 43,
520
+ },
521
+ });
522
+ expect(revenueEvents[0]).not.toHaveProperty("experimentId");
523
+ expect(revenueEvents[0]).not.toHaveProperty("variationId");
524
+ });
525
+
526
+
527
+ it("replays revenue and funnel steps while initial hydration defers tracker creation", async () => {
528
+ const { flush, hydrated, init, trackRevenue, trackStep } = await import("./index");
529
+
530
+ init({
531
+ antiFlickerMode: "off",
532
+ endpoint: "https://events.test",
533
+ experiments: [
534
+ {
535
+ id: "exp_hydration_revenue",
536
+ status: "running",
537
+ trafficAllocation: 1,
538
+ variations: [
539
+ {
540
+ id: "var_hydration_revenue",
541
+ weight: 1,
542
+ mutations: [],
543
+ js: "window.__hydrationRevenueApplied=true;",
544
+ },
545
+ ],
546
+ },
547
+ ],
548
+ shopId: "shop_123",
549
+ spa: { applyTiming: "after_hydration", hydrationTimeoutMs: 3_000 },
550
+ trackingStart: "immediate",
551
+ });
552
+
553
+ trackRevenue(49.99, { currency: "EUR", orderId: "held-order" });
554
+ trackStep("checkout_confirmation", { oncePerSession: false, source: "held-data-layer" });
555
+ expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
556
+
557
+ let flushResolved = false;
558
+ const pendingFlush = flush().then(() => { flushResolved = true; });
559
+ // Markerless quiet detector (#2213): the gate auto-resolves on this
560
+ // settled (mocked) DOM well before the 3,000ms hydrationTimeoutMs, with
561
+ // NO explicit hydrated() call. Pre-#2213 the detector never armed
562
+ // without a framework marker, so the gate held the full 3s — advancing
563
+ // only 300ms discriminates the two behaviors. The quiet-vs-mutating
564
+ // hold contract itself lives in hydration.test.ts (real DOM).
565
+ await Promise.resolve();
566
+ expect(flushResolved).toBe(false);
567
+
568
+ await vi.advanceTimersByTimeAsync(300);
569
+ await pendingFlush;
570
+ expect(flushResolved).toBe(true);
571
+ void hydrated; // explicit signal deliberately not used
572
+
573
+ const eventsCall = vi.mocked(globalThis.fetch).mock.calls.find(
574
+ (call) => call[0] === "https://events.test/v1/events",
575
+ );
576
+ const events = JSON.parse(String(eventsCall?.[1]?.body)).events;
577
+ expect(events).toEqual(expect.arrayContaining([
578
+ expect.objectContaining({
579
+ experimentId: "exp_hydration_revenue",
580
+ variationId: "var_hydration_revenue",
581
+ data: expect.objectContaining({
582
+ goalId: "__revenue__",
583
+ revenue: 49.99,
584
+ currency: "EUR",
585
+ orderId: "held-order",
586
+ }),
587
+ }),
588
+ expect.objectContaining({
589
+ type: "funnel_step",
590
+ experimentId: "exp_hydration_revenue",
591
+ variationId: "var_hydration_revenue",
592
+ data: expect.objectContaining({
593
+ source: "virtual_step",
594
+ step_name: "checkout_confirmation",
595
+ }),
596
+ }),
597
+ ]));
598
+ });
599
+
600
+
601
+ it("rejects invalid direct revenue while preserving zero and the positional amount", async () => {
602
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
603
+ const { flush, init, trackRevenue } = await import("./index");
604
+
605
+ init({
606
+ antiFlickerMode: "off",
607
+ endpoint: "https://events.test",
608
+ experiments: [
609
+ {
610
+ id: "exp_revenue_validation",
611
+ status: "running",
612
+ trafficAllocation: 1,
613
+ variations: [{ id: "var_revenue_validation", weight: 1, mutations: [] }],
614
+ },
615
+ ],
616
+ shopId: "shop_123",
617
+ trackingStart: "immediate",
618
+ });
619
+ trackRevenue(Number.NaN);
620
+ trackRevenue(Number.POSITIVE_INFINITY);
621
+ trackRevenue(Number.NEGATIVE_INFINITY);
622
+ trackRevenue(-1);
623
+ trackRevenue("12" as unknown as number);
624
+ trackRevenue(0, { orderId: "zero-order" });
625
+ trackRevenue(25, { revenue: 999, orderId: "positive-order" });
626
+ await flush();
627
+
628
+ const fetchMock = vi.mocked(globalThis.fetch);
629
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
630
+ const revenueEvents = body.events.filter((event: { data?: { goalId?: string } }) =>
631
+ event.data?.goalId === "__revenue__"
632
+ );
633
+ expect(revenueEvents).toHaveLength(2);
634
+ expect(revenueEvents.map((event: { data: { revenue: number } }) => event.data.revenue)).toEqual([
635
+ 0,
636
+ 25,
637
+ ]);
638
+ expect(revenueEvents[1].data).toMatchObject({
639
+ revenue: 25,
640
+ orderId: "positive-order",
641
+ });
642
+ expect(warn).toHaveBeenCalledTimes(5);
643
+ expect(warn).toHaveBeenCalledWith(
644
+ "[drip] trackRevenue ignored invalid revenue; expected a finite, non-negative number",
645
+ );
646
+ warn.mockRestore();
647
+ });
648
+
649
+
650
+ it("normalizes invalid custom event names while preserving raw-name goal matching", async () => {
651
+ const { flush, init, track } = await import("./index");
652
+
653
+ init({
654
+ antiFlickerMode: "off",
655
+ endpoint: "https://events.test",
656
+ experiments: [
657
+ {
658
+ id: "exp_custom_goal",
659
+ status: "running",
660
+ trafficAllocation: 1,
661
+ variations: [{ id: "var_custom_goal", weight: 1, mutations: [] }],
662
+ goals: [
663
+ {
664
+ id: "goal_signup",
665
+ type: "custom",
666
+ eventName: "click_to_ atc",
667
+ },
668
+ ],
669
+ },
670
+ ],
671
+ shopId: "shop_123",
672
+ trackingStart: "immediate",
673
+ });
674
+ track("click_to_ atc", { plan: "pro" });
675
+ await flush();
676
+
677
+ const fetchMock = vi.mocked(globalThis.fetch);
678
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
679
+ expect(body.events).toEqual(expect.arrayContaining([
680
+ expect.objectContaining({
681
+ type: "click_to__atc",
682
+ experimentId: "exp_custom_goal",
683
+ variationId: "var_custom_goal",
684
+ }),
685
+ expect.objectContaining({
686
+ type: "goal",
687
+ experimentId: "exp_custom_goal",
688
+ variationId: "var_custom_goal",
689
+ data: expect.objectContaining({
690
+ goalId: "goal_signup",
691
+ plan: "pro",
692
+ }),
693
+ }),
694
+ ]));
695
+ });
696
+
697
+
698
+ it("emits experiment-scoped pageview behavior events for every active assignment", async () => {
699
+ const { flush, init } = await import("./index");
700
+
701
+ init({
702
+ antiFlickerMode: "off",
703
+ endpoint: "https://events.test",
704
+ experiments: [
705
+ {
706
+ id: "exp_123",
707
+ status: "running",
708
+ trafficAllocation: 1,
709
+ variations: [{ id: "var_123", weight: 1, mutations: [] }],
710
+ },
711
+ {
712
+ id: "exp_456",
713
+ status: "running",
714
+ trafficAllocation: 1,
715
+ variations: [{ id: "var_456", weight: 1, mutations: [] }],
716
+ },
717
+ ],
718
+ shopId: "shop_123",
719
+ trackingStart: "immediate",
720
+ });
721
+ await flush();
722
+
723
+ const fetchMock = vi.mocked(globalThis.fetch);
724
+ expect(fetchMock).toHaveBeenCalledTimes(1);
725
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
726
+ const behaviorPageviews = body.events.filter(
727
+ (event: { type: string }) => event.type === "experiment_pageview",
728
+ );
729
+ const pageviews = body.events.filter((event: { type: string }) => event.type === "pageview");
730
+ expect(pageviews).toHaveLength(1);
731
+ expect(pageviews[0]).toMatchObject({
732
+ experimentId: "exp_123",
733
+ variationId: "var_123",
734
+ });
735
+ expect(behaviorPageviews).toHaveLength(2);
736
+ expect(behaviorPageviews.map((event: { experimentId: string; variationId: string }) => [
737
+ event.experimentId,
738
+ event.variationId,
739
+ ])).toEqual([
740
+ ["exp_123", "var_123"],
741
+ ["exp_456", "var_456"],
742
+ ]);
743
+ });
744
+
745
+
746
+ it("emits global and per-assignment page exit once across unload events", async () => {
747
+ Object.defineProperty(globalThis, "navigator", {
748
+ configurable: true,
749
+ value: { userAgent: "Mozilla/5.0", doNotTrack: "0", sendBeacon: undefined },
750
+ });
751
+ const { flush, init } = await import("./index");
752
+
753
+ init({
754
+ antiFlickerMode: "off",
755
+ endpoint: "https://events.test",
756
+ experiments: [
757
+ {
758
+ id: "exp_123",
759
+ status: "running",
760
+ trafficAllocation: 1,
761
+ variations: [{ id: "var_123", weight: 1, mutations: [] }],
762
+ },
763
+ {
764
+ id: "exp_456",
765
+ status: "running",
766
+ trafficAllocation: 1,
767
+ variations: [{ id: "var_456", weight: 1, mutations: [] }],
768
+ },
769
+ ],
770
+ shopId: "shop_123",
771
+ trackingStart: "immediate",
772
+ });
773
+
774
+ await vi.advanceTimersByTimeAsync(1250);
775
+ windowListeners.pagehide?.forEach((handler) => handler());
776
+ windowListeners.beforeunload?.forEach((handler) => handler());
777
+ await flush();
778
+
779
+ const fetchMock = vi.mocked(globalThis.fetch);
780
+ expect(fetchMock).toHaveBeenCalledTimes(1);
781
+ const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
782
+ const pageExitEvents = body.events.filter((event: { type: string }) => event.type === "page_exit");
783
+ const behaviorExits = body.events.filter(
784
+ (event: { type: string }) => event.type === "experiment_page_exit",
785
+ );
786
+ expect(pageExitEvents).toHaveLength(1);
787
+ expect(behaviorExits).toHaveLength(2);
788
+ expect(behaviorExits.every((event: { data?: { duration?: number } }) => event.data?.duration === 1)).toBe(true);
789
+ });
790
+
791
+
792
+ it("emits a shop-level page exit without identity for a contaminated-only assignment", async () => {
793
+ Object.defineProperty(globalThis, "navigator", {
794
+ configurable: true,
795
+ value: { userAgent: "Mozilla/5.0", doNotTrack: "0", sendBeacon: undefined },
796
+ });
797
+ const title = { textContent: "Control title" };
798
+ (globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
799
+ selector === "#product-title" ? [title] : []
800
+ ));
801
+ const { flush, init } = await import("./index");
802
+
803
+ init({
804
+ antiFlickerMode: "off",
805
+ endpoint: "https://events.test",
806
+ experiments: [{
807
+ id: "exp_contaminated_exit",
808
+ status: "running",
809
+ trafficAllocation: 1,
810
+ variations: [{
811
+ id: "var_contaminated_exit",
812
+ weight: 1,
813
+ mutations: [
814
+ { selector: "#product-title", action: "text", text: "Treatment title" },
815
+ { selector: "#missing-cta", action: "text", text: "Missing CTA" },
816
+ ],
817
+ }],
818
+ }],
819
+ shopId: "shop_123",
820
+ spa: { activationMode: "manual" },
821
+ trackingStart: "immediate",
822
+ });
823
+
824
+ await vi.advanceTimersByTimeAsync(1_250);
825
+ windowListeners.pagehide?.forEach((handler) => handler());
826
+ await flush();
827
+
828
+ const events = vi.mocked(globalThis.fetch).mock.calls
829
+ .filter((call) => call[0] === "https://events.test/v1/events")
830
+ .flatMap((call) => JSON.parse(String(call[1]?.body)).events);
831
+ const pageExits = events.filter((event: { type: string }) => event.type === "page_exit");
832
+ expect(pageExits).toHaveLength(1);
833
+ expect(pageExits[0]).not.toHaveProperty("experimentId");
834
+ expect(pageExits[0]).not.toHaveProperty("variationId");
835
+ expect(events.filter((event: { type: string }) => event.type === "experiment_page_exit")).toEqual([]);
836
+ });
837
+
838
+
839
+ it("renders assignments without persistent storage or tracking before consent", async () => {
840
+ const { flush, getAssignments, init } = await import("./index");
841
+
842
+ init({
843
+ antiFlickerMode: "off",
844
+ consentMode: { enabled: true },
845
+ endpoint: "https://events.test",
846
+ experiments: [
847
+ {
848
+ id: "exp_123",
849
+ status: "running",
850
+ trafficAllocation: 1,
851
+ variations: [{ id: "var_123", weight: 1, mutations: [] }],
852
+ },
853
+ ],
854
+ hasConsent: false,
855
+ shopId: "shop_123",
856
+ trackingStart: "immediate",
857
+ });
858
+
859
+ await flush();
860
+
861
+ expect(getAssignments()).toHaveLength(1);
862
+ expect(globalThis.fetch).not.toHaveBeenCalled();
863
+ expect((globalThis as any).localStorage.length).toBe(0);
864
+ expect((globalThis as any).localStorage.getItem("drip_exposures")).toBeNull();
865
+ expect((globalThis as any).sessionStorage.length).toBe(0);
866
+ expect((globalThis as any).document.cookie || "").not.toMatch(/drip_uid=[^;]/);
867
+ expect(readCookieValue("drip_ctx")).toBeNull();
868
+ });
869
+
870
+
871
+ it("persists the anonymous assignment identity and starts tracking after consent", async () => {
872
+ const { flush, getAssignments, init, setConsent } = await import("./index");
873
+ const shopifyPublish = vi.fn();
874
+ (globalThis as any).window.Shopify = { analytics: { publish: shopifyPublish } };
875
+
876
+ init({
877
+ antiFlickerMode: "off",
878
+ consentMode: { enabled: true },
879
+ endpoint: "https://events.test",
880
+ experiments: [
881
+ {
882
+ id: "exp_123",
883
+ status: "running",
884
+ trafficAllocation: 1,
885
+ variations: [{ id: "var_123", weight: 1, mutations: [] }],
886
+ },
887
+ ],
888
+ hasConsent: false,
889
+ shopId: "shop_123",
890
+ trackingStart: "immediate",
891
+ });
892
+ const beforeConsentHash = getAssignments()[0]?.hashValue;
893
+ expect(shopifyPublish).not.toHaveBeenCalled();
894
+
895
+ setConsent(true);
896
+ await flush();
897
+
898
+ const fetchMock = vi.mocked(globalThis.fetch);
899
+ expect(getAssignments()[0]?.hashValue).toBe(beforeConsentHash);
900
+ const eventsCall = fetchMock.mock.calls.find((call) => call[0] === "https://events.test/v1/events");
901
+ expect(eventsCall).toBeDefined();
902
+ const body = JSON.parse(String(eventsCall?.[1]?.body));
903
+ const visitorIds = new Set(body.events.map((event: { visitorId: string }) => event.visitorId));
904
+ expect(visitorIds.size).toBe(1);
905
+ expect((globalThis as any).localStorage.getItem("drip_uid")).toBe(Array.from(visitorIds)[0]);
906
+ expect(readDecodedDripContext()).toContain(`1|${Array.from(visitorIds)[0]}|`);
907
+ expect(body.events.map((event: { type: string }) => event.type)).toContain("experiment_viewed");
908
+ expect(body.events.map((event: { type: string }) => event.type)).toContain("pageview");
909
+ expect(body.events.map((event: { type: string }) => event.type)).not.toContain("sdk_perf");
910
+ expect(shopifyPublish).toHaveBeenCalledWith("drip_assignment_sync", expect.any(Object));
911
+ });
912
+
913
+
914
+ it("attaches signal context to experiment and personalization exposure events", async () => {
915
+ const { flush, init } = await import("./index");
916
+
917
+ init({
918
+ antiFlickerMode: "off",
919
+ attributes: {
920
+ "traffic.source": "paid",
921
+ "visitor.returning": true,
922
+ "cart.item_count": 2,
923
+ "quiz.answer": "Sensitive",
924
+ "cookie.value": "secret",
925
+ "url.path": "/products/secret",
926
+ },
927
+ endpoint: "https://events.test",
928
+ experiments: [
929
+ {
930
+ id: "exp_signals",
931
+ status: "running",
932
+ trafficAllocation: 1,
933
+ variations: [{ id: "var_signals", weight: 1, mutations: [] }],
934
+ },
935
+ {
936
+ id: "personalization:homepage-banner:rule_signals",
937
+ kind: "personalization",
938
+ status: "running",
939
+ trafficAllocation: 1,
940
+ personalization: {
941
+ id: "personalization_signals",
942
+ key: "homepage-banner",
943
+ ruleId: "rule_signals",
944
+ experienceId: "experience_signals",
945
+ surface: "home",
946
+ templateKey: "hero",
947
+ },
948
+ variations: [{ id: "experience_signals", weight: 1, mutations: [] }],
949
+ },
950
+ ],
951
+ shopId: "shop_123",
952
+ signals: [
953
+ { key: "quiz.answer", valueType: "string" },
954
+ { key: "cookie.value", valueType: "string" },
955
+ { key: "url.path", valueType: "string" },
956
+ ],
957
+ trackingStart: "immediate",
958
+ userId: "visitor_signals",
959
+ });
960
+ await flush();
961
+
962
+ const eventsCall = vi.mocked(globalThis.fetch).mock.calls.find((call) => call[0] === "https://events.test/v1/events");
963
+ const body = JSON.parse(String(eventsCall?.[1]?.body));
964
+ const experimentViewed = body.events.find((event: { type: string }) => event.type === "experiment_viewed");
965
+ const personalizationRendered = body.events.find((event: { type: string }) => event.type === "personalization_rendered");
966
+
967
+ expect(experimentViewed?.data.signals).toEqual({
968
+ cart_item_count: 2,
969
+ quiz_answer: "Sensitive",
970
+ traffic_source: "paid",
971
+ visitor_returning: true,
972
+ });
973
+ expect(personalizationRendered?.data.signals).toEqual(experimentViewed?.data.signals);
974
+ });
975
+
976
+
977
+ it.each([
978
+ ["QA", "?drip_qa=1", undefined],
979
+ ["non-production", "", { activeKey: "staging" }],
980
+ ])("suppresses storefront vitals for %s sessions", async (_label, search, environments) => {
981
+ vi.mocked(Math.random).mockReturnValue(0);
982
+ (globalThis as any).location.search = search;
983
+ (globalThis as any).location.href = `https://plain.test/${search}`;
984
+ // Typed params only: the beacon assertion below destructures mock.calls.
985
+ const sendBeacon = vi.fn((_url: string, _body?: BodyInit | null) => true);
986
+ Object.defineProperty(globalThis, "navigator", {
987
+ configurable: true,
988
+ value: { userAgent: "Mozilla/5.0", doNotTrack: "0", sendBeacon },
989
+ });
990
+ const { init } = await import("./index");
991
+
992
+ init({
993
+ antiFlickerMode: "off",
994
+ endpoint: "https://events.test",
995
+ environments,
996
+ experiments: [],
997
+ shopId: "shop_123",
998
+ storefrontVitalsSampleRate: 1,
999
+ trackingStart: "immediate",
1000
+ });
1001
+ for (const listener of windowListeners.pagehide ?? []) listener();
1002
+
1003
+ expect(sendBeacon.mock.calls.some(([url]) => String(url).endsWith("/storefront-vitals"))).toBe(false);
1004
+ });
1005
+
1006
+
1007
+ it("suppresses storefront vitals while the start-tracking prerequisite is closed", async () => {
1008
+ vi.mocked(Math.random).mockReturnValue(0);
1009
+ const sendBeacon = vi.fn(() => true);
1010
+ Object.defineProperty(globalThis, "navigator", {
1011
+ configurable: true,
1012
+ value: { userAgent: "Mozilla/5.0", doNotTrack: "0", sendBeacon },
1013
+ });
1014
+ const { init } = await import("./index");
1015
+
1016
+ init({
1017
+ antiFlickerMode: "off",
1018
+ endpoint: "https://events.test",
1019
+ experiments: [],
1020
+ projectPrerequisites: { startTrackingRule: "return false;" },
1021
+ shopId: "shop_123",
1022
+ storefrontVitalsSampleRate: 1,
1023
+ trackingStart: "immediate",
1024
+ });
1025
+ for (const listener of windowListeners.pagehide ?? []) listener();
1026
+
1027
+ expect(sendBeacon).not.toHaveBeenCalled();
1028
+ });
1029
+
1030
+
1031
+ it("starts storefront vitals after late consent and stops them on revocation", async () => {
1032
+ vi.mocked(Math.random).mockReturnValue(0);
1033
+ const sendBeacon = vi.fn(() => true);
1034
+ Object.defineProperty(globalThis, "navigator", {
1035
+ configurable: true,
1036
+ value: { userAgent: "Mozilla/5.0", doNotTrack: "0", sendBeacon },
1037
+ });
1038
+ const { init, setConsent } = await import("./index");
1039
+
1040
+ init({
1041
+ antiFlickerMode: "off",
1042
+ consentMode: { enabled: true },
1043
+ endpoint: "https://events.test",
1044
+ experiments: [],
1045
+ hasConsent: false,
1046
+ shopId: "shop_123",
1047
+ storefrontVitalsSampleRate: 1,
1048
+ trackingStart: "immediate",
1049
+ });
1050
+ setConsent(true);
1051
+ for (const listener of windowListeners.pagehide ?? []) listener();
1052
+ expect(sendBeacon).toHaveBeenCalledWith(
1053
+ "https://events.test/storefront-vitals",
1054
+ expect.any(Blob),
1055
+ );
1056
+
1057
+ sendBeacon.mockClear();
1058
+ setConsent(false);
1059
+ for (const listener of windowListeners.pagehide ?? []) listener();
1060
+ expect(sendBeacon).not.toHaveBeenCalled();
1061
+ });
1062
+
1063
+
1064
+ it("keeps storefront vitals observers running across an open-gate SPA route change", async () => {
1065
+ vi.mocked(Math.random).mockReturnValue(0);
1066
+ const observerRegistrations: Array<{
1067
+ disconnect: ReturnType<typeof vi.fn>;
1068
+ type: string;
1069
+ }> = [];
1070
+ vi.stubGlobal("PerformanceObserver", class PerformanceObserver {
1071
+ disconnect = vi.fn();
1072
+ observe = vi.fn((options: PerformanceObserverInit) => {
1073
+ observerRegistrations.push({
1074
+ disconnect: this.disconnect,
1075
+ type: options.type ?? options.entryTypes?.[0] ?? "",
1076
+ });
1077
+ });
1078
+ takeRecords = vi.fn(() => []);
1079
+ constructor(_callback: PerformanceObserverCallback) {}
1080
+ });
1081
+ const { init } = await import("./index");
1082
+
1083
+ init({
1084
+ antiFlickerMode: "off",
1085
+ endpoint: "https://events.test",
1086
+ experiments: [],
1087
+ shopId: "shop_123",
1088
+ spa: { routerMode: "history" },
1089
+ storefrontVitalsSampleRate: 1,
1090
+ trackingStart: "immediate",
1091
+ });
1092
+ const eventObserver = observerRegistrations.find(({ type }) => type === "event");
1093
+ expect(eventObserver).toBeDefined();
1094
+
1095
+ (globalThis as any).location.href = "https://plain.test/next";
1096
+ (globalThis as any).location.pathname = "/next";
1097
+ (globalThis as any).history.pushState({}, "", "/next");
1098
+
1099
+ expect(observerRegistrations.filter(({ type }) => type === "event")).toHaveLength(1);
1100
+ expect(eventObserver!.disconnect).not.toHaveBeenCalled();
1101
+ });
1102
+
1103
+
1104
+ it("labels flag and personalization assignments in diagnostics", async () => {
1105
+ const { getDiagnostics, init } = await import("./index");
1106
+
1107
+ init({
1108
+ antiFlickerMode: "off",
1109
+ endpoint: "https://events.test",
1110
+ experiments: [
1111
+ {
1112
+ id: "diagnostics-flag",
1113
+ kind: "feature_flag",
1114
+ status: "running",
1115
+ trafficAllocation: 1,
1116
+ featureFlag: {
1117
+ id: "flag_db_id",
1118
+ key: "diagnostics-flag",
1119
+ },
1120
+ variations: [
1121
+ {
1122
+ id: "off",
1123
+ weight: 0,
1124
+ mutations: [],
1125
+ value: false,
1126
+ isControl: true,
1127
+ },
1128
+ {
1129
+ id: "on",
1130
+ weight: 1,
1131
+ mutations: [],
1132
+ value: "enabled",
1133
+ },
1134
+ ],
1135
+ },
1136
+ {
1137
+ id: "diagnostics-personalization",
1138
+ kind: "personalization",
1139
+ status: "running",
1140
+ trafficAllocation: 1,
1141
+ personalization: {
1142
+ id: "personalization_db_id",
1143
+ key: "vip-homepage",
1144
+ ruleId: "rule_123",
1145
+ experienceId: "experience_123",
1146
+ surface: "homepage",
1147
+ templateKey: "hero",
1148
+ },
1149
+ variations: [
1150
+ {
1151
+ id: "vip",
1152
+ weight: 1,
1153
+ mutations: [],
1154
+ value: "vip",
1155
+ },
1156
+ ],
1157
+ },
1158
+ ],
1159
+ shopId: "shop_123",
1160
+ trackingStart: "immediate",
1161
+ });
1162
+
1163
+ expect(getDiagnostics().assignments).toEqual(expect.arrayContaining([
1164
+ expect.objectContaining({
1165
+ experimentId: "diagnostics-flag",
1166
+ featureFlagKey: "diagnostics-flag",
1167
+ kind: "feature_flag",
1168
+ value: "enabled",
1169
+ }),
1170
+ expect.objectContaining({
1171
+ experimentId: "diagnostics-personalization",
1172
+ kind: "personalization",
1173
+ personalizationExperienceId: "experience_123",
1174
+ personalizationKey: "vip-homepage",
1175
+ personalizationRuleId: "rule_123",
1176
+ personalizationSurface: "homepage",
1177
+ personalizationTemplateKey: "hero",
1178
+ value: "vip",
1179
+ }),
1180
+ ]));
1181
+ });
1182
+
1183
+
1184
+ it("captures configured URL parameter signals before assignment evaluation", async () => {
1185
+ (globalThis as any).location.href = "https://plain.test/?utm_content=creator_a";
1186
+ (globalThis as any).location.search = "?utm_content=creator_a";
1187
+
1188
+ const { getAssignments, getSignals, init } = await import("./index");
1189
+
1190
+ init({
1191
+ antiFlickerMode: "off",
1192
+ endpoint: "https://events.test",
1193
+ experiments: [
1194
+ {
1195
+ id: "exp_signal",
1196
+ status: "running",
1197
+ trafficAllocation: 1,
1198
+ targeting: {
1199
+ segmentRules: [
1200
+ {
1201
+ id: "segment_signal",
1202
+ rules: {
1203
+ version: 1,
1204
+ operator: "and",
1205
+ conditions: [
1206
+ { signal: "utm.content", operator: "equals", value: "creator_a" },
1207
+ ],
1208
+ },
1209
+ },
1210
+ ],
1211
+ },
1212
+ variations: [{ id: "var_signal", weight: 1, mutations: [] }],
1213
+ },
1214
+ ],
1215
+ shopId: "shop_123",
1216
+ signals: [
1217
+ {
1218
+ key: "utm.content",
1219
+ valueType: "string",
1220
+ ttlDays: 30,
1221
+ capture: { mode: "url_parameter", parameter: "utm_content" },
1222
+ },
1223
+ ],
1224
+ trackingStart: "immediate",
1225
+ });
1226
+
1227
+ expect(getSignals()["utm.content"]).toBe("creator_a");
1228
+ expect(getAssignments()).toHaveLength(1);
1229
+ });
1230
+
1231
+
1232
+ it("refreshes non-persistent URL parameter signals during SPA re-evaluation", async () => {
1233
+ (globalThis as any).location.href = "https://plain.test/";
1234
+ (globalThis as any).location.search = "";
1235
+
1236
+ const { getAssignments, getSignals, init, reInit } = await import("./index");
1237
+
1238
+ init({
1239
+ antiFlickerMode: "off",
1240
+ endpoint: "https://events.test",
1241
+ experiments: [
1242
+ {
1243
+ id: "exp_query_signal",
1244
+ status: "running",
1245
+ trafficAllocation: 1,
1246
+ targeting: {
1247
+ segmentRules: [
1248
+ {
1249
+ id: "segment_query_signal",
1250
+ rules: {
1251
+ version: 1,
1252
+ operator: "and",
1253
+ conditions: [
1254
+ { signal: "search.query", operator: "equals", value: "recovery" },
1255
+ ],
1256
+ },
1257
+ },
1258
+ ],
1259
+ },
1260
+ variations: [{ id: "var_query_signal", weight: 1, mutations: [] }],
1261
+ },
1262
+ ],
1263
+ shopId: "shop_123",
1264
+ signals: [
1265
+ {
1266
+ key: "search.query",
1267
+ valueType: "string",
1268
+ capture: { mode: "url_parameter", parameter: "q", persist: false },
1269
+ },
1270
+ ],
1271
+ trackingStart: "immediate",
1272
+ });
1273
+
1274
+ expect(getAssignments()).toHaveLength(0);
1275
+ expect(getSignals()["search.query"]).toBeUndefined();
1276
+
1277
+ (globalThis as any).location.href = "https://plain.test/search?q=recovery";
1278
+ (globalThis as any).location.search = "?q=recovery";
1279
+ reInit();
1280
+
1281
+ expect(getSignals()["search.query"]).toBe("recovery");
1282
+ expect(getAssignments()).toEqual([
1283
+ expect.objectContaining({ experimentId: "exp_query_signal" }),
1284
+ ]);
1285
+
1286
+ (globalThis as any).location.href = "https://plain.test/";
1287
+ (globalThis as any).location.search = "";
1288
+ reInit();
1289
+
1290
+ expect(getSignals()["search.query"]).toBeUndefined();
1291
+ expect(getAssignments()).toHaveLength(0);
1292
+ });
1293
+
1294
+
1295
+ it("captures configured event property signals from track calls", async () => {
1296
+ const { getAssignments, getSignals, init, reInit, track } = await import("./index");
1297
+
1298
+ init({
1299
+ antiFlickerMode: "off",
1300
+ endpoint: "https://events.test",
1301
+ experiments: [
1302
+ {
1303
+ id: "exp_event_property_signal",
1304
+ status: "running",
1305
+ trafficAllocation: 1,
1306
+ targeting: {
1307
+ segmentRules: [
1308
+ {
1309
+ id: "event-property",
1310
+ rules: {
1311
+ version: 1,
1312
+ operator: "and",
1313
+ conditions: [{ signal: "product.brand", operator: "equals", value: "Apex Goods" }],
1314
+ },
1315
+ },
1316
+ ],
1317
+ },
1318
+ variations: [{ id: "var_event_property_signal", weight: 1, mutations: [] }],
1319
+ },
1320
+ ],
1321
+ shopId: "shop_123",
1322
+ signals: [
1323
+ {
1324
+ key: "product.brand",
1325
+ valueType: "string",
1326
+ ttlDays: 30,
1327
+ capture: {
1328
+ mode: "event_property",
1329
+ eventName: "product_viewed",
1330
+ property: "brand",
1331
+ },
1332
+ },
1333
+ ],
1334
+ trackingStart: "immediate",
1335
+ });
1336
+
1337
+ expect(getAssignments()).toHaveLength(0);
1338
+ track("product_viewed", { brand: "Apex Goods" });
1339
+ reInit();
1340
+
1341
+ expect(getSignals()["product.brand"]).toBe("Apex Goods");
1342
+ expect(getAssignments()).toEqual([
1343
+ expect.objectContaining({ experimentId: "exp_event_property_signal" }),
1344
+ ]);
1345
+ });
1346
+
1347
+
1348
+ it("captures configured click selector signals and supports manual re-evaluation", async () => {
1349
+ const clickHandlers: Array<(event: { target: unknown }) => void> = [];
1350
+ (globalThis as any).document.addEventListener = vi.fn((type: string, handler: (event: { target: unknown }) => void) => {
1351
+ if (type === "click") clickHandlers.push(handler);
1352
+ });
1353
+
1354
+ const { getAssignments, getSignals, init, reInit } = await import("./index");
1355
+
1356
+ init({
1357
+ antiFlickerMode: "off",
1358
+ endpoint: "https://events.test",
1359
+ experiments: [
1360
+ {
1361
+ id: "exp_click_selector_signal",
1362
+ status: "running",
1363
+ trafficAllocation: 1,
1364
+ targeting: {
1365
+ segmentRules: [
1366
+ {
1367
+ id: "click-selector",
1368
+ rules: {
1369
+ version: 1,
1370
+ operator: "and",
1371
+ conditions: [{ signal: "interaction.intent", operator: "contains", value: "add" }],
1372
+ },
1373
+ },
1374
+ ],
1375
+ },
1376
+ variations: [{ id: "var_click_selector_signal", weight: 1, mutations: [] }],
1377
+ },
1378
+ ],
1379
+ shopId: "shop_123",
1380
+ signals: [
1381
+ {
1382
+ key: "interaction.intent",
1383
+ valueType: "string",
1384
+ capture: {
1385
+ mode: "click_selector",
1386
+ selector: "[data-action='add-cart']",
1387
+ source: "constant",
1388
+ value: "add_cart",
1389
+ },
1390
+ },
1391
+ ],
1392
+ trackingStart: "immediate",
1393
+ });
1394
+
1395
+ expect(getAssignments()).toHaveLength(0);
1396
+ clickHandlers.forEach((handler) => handler({
1397
+ target: {
1398
+ closest: (selector: string) => selector === "[data-action='add-cart']" ? {} : null,
1399
+ },
1400
+ }));
1401
+ reInit();
1402
+
1403
+ expect(getSignals()["interaction.intent"]).toBe("add_cart");
1404
+ expect(getAssignments()).toEqual([
1405
+ expect.objectContaining({ experimentId: "exp_click_selector_signal" }),
1406
+ ]);
1407
+ });
1408
+
1409
+
1410
+ it("captures dataLayer trigger signals and re-evaluates SPA targeting", async () => {
1411
+ const { getAssignments, getDiagnostics, getSignals, init } = await import("./index");
1412
+
1413
+ (globalThis as any).window.dataLayer = [];
1414
+ init({
1415
+ antiFlickerMode: "off",
1416
+ dataLayerTriggers: [
1417
+ {
1418
+ eventName: "virtPageView",
1419
+ mappings: [{ from: "page_type", to: "page.type", valueType: "string" }],
1420
+ trackPageview: true,
1421
+ },
1422
+ ],
1423
+ endpoint: "https://events.test",
1424
+ experiments: [
1425
+ {
1426
+ id: "exp_pdp",
1427
+ status: "running",
1428
+ targeting: {
1429
+ segmentRules: [
1430
+ {
1431
+ id: "segment_pdp",
1432
+ rules: {
1433
+ version: 1,
1434
+ operator: "and",
1435
+ conditions: [{ signal: "page.type", operator: "equals", value: "pdp" }],
1436
+ },
1437
+ },
1438
+ ],
1439
+ },
1440
+ trafficAllocation: 1,
1441
+ variations: [{ id: "var_pdp", weight: 1, mutations: [] }],
1442
+ },
1443
+ ],
1444
+ shopId: "shop_123",
1445
+ trackingStart: "immediate",
1446
+ });
1447
+
1448
+ expect(getAssignments()).toHaveLength(0);
1449
+ (globalThis as any).window.dataLayer.push({ event: "virtPageView", page_type: "home" });
1450
+ expect(getSignals()["page.type"]).toBe("home");
1451
+ expect(getAssignments()).toHaveLength(0);
1452
+ (globalThis as any).window.dataLayer.push({ event: "virtPageView", page_type: "pdp" });
1453
+ expect(getSignals()["page.type"]).toBe("pdp");
1454
+ expect(getAssignments()).toHaveLength(1);
1455
+ (globalThis as any).window.dataLayer.push({ event: "virtPageView", page_type: "home" });
1456
+ expect(getSignals()["page.type"]).toBe("home");
1457
+ expect(getAssignments()).toHaveLength(0);
1458
+ expect(getDiagnostics()).toMatchObject({
1459
+ spa: {
1460
+ lastDataLayerTrigger: {
1461
+ eventName: "virtPageView",
1462
+ dataLayerName: "dataLayer",
1463
+ mappedSignals: ["page.type"],
1464
+ },
1465
+ },
1466
+ signals: {
1467
+ sources: {
1468
+ "page.type": "data_layer",
1469
+ },
1470
+ },
1471
+ });
1472
+ });
1473
+
1474
+
1475
+ it("contains dataLayer observer failures and processes subsequent pushes", async () => {
1476
+ const { getDiagnostics, getSignals, init } = await import("./index");
1477
+ (globalThis as any).window.dataLayer = [];
1478
+ init({
1479
+ antiFlickerMode: "off",
1480
+ dataLayerTriggers: [{
1481
+ eventName: "virtPageView",
1482
+ mappings: [{ from: "page_type", to: "page.type", valueType: "string" }],
1483
+ }],
1484
+ endpoint: "https://events.test",
1485
+ experiments: [],
1486
+ shopId: "shop_123",
1487
+ trackingStart: "immediate",
1488
+ });
1489
+
1490
+ const broken = { event: "virtPageView" } as Record<string, unknown>;
1491
+ Object.defineProperty(broken, "page_type", { get: () => { throw new Error("bad page signal"); } });
1492
+ expect(() => (globalThis as any).window.dataLayer.push(broken)).not.toThrow();
1493
+ expect(() => (globalThis as any).window.dataLayer.push({ event: "virtPageView", page_type: "home" })).not.toThrow();
1494
+
1495
+ expect(getSignals()["page.type"]).toBe("home");
1496
+ expect(getDiagnostics().spa.observerErrors).toMatchObject({
1497
+ count: 1,
1498
+ last: { source: "data_layer", message: "bad page signal" },
1499
+ });
1500
+ });
1501
+
1502
+
1503
+ it("re-evaluates once after route observers return so post-pushState signals are current", async () => {
1504
+ vi.useFakeTimers();
1505
+ const onExperimentViewed = vi.fn();
1506
+ const { flush, getAssignments, init } = await import("./index");
1507
+ (globalThis as any).window.dataLayer = [];
1508
+ init({
1509
+ antiFlickerMode: "off",
1510
+ dataLayerTriggers: [{
1511
+ eventName: "virtPageView",
1512
+ mappings: [{ from: "page_type", to: "page.type", valueType: "string" }],
1513
+ reEvaluate: false,
1514
+ }],
1515
+ endpoint: "https://events.test",
1516
+ experiments: [{
1517
+ id: "exp_home_after_route",
1518
+ status: "running",
1519
+ targeting: { segmentRules: [{
1520
+ id: "home",
1521
+ rules: { version: 1, operator: "and", conditions: [{ signal: "page.type", operator: "equals", value: "home" }] },
1522
+ }] },
1523
+ trafficAllocation: 1,
1524
+ variations: [{ id: "var_home", weight: 1, mutations: [] }],
1525
+ }],
1526
+ onExperimentViewed,
1527
+ shopId: "shop_123",
1528
+ trackingStart: "immediate",
1529
+ });
1530
+
1531
+ (globalThis as any).location.href = "https://plain.test/home";
1532
+ (globalThis as any).location.pathname = "/home";
1533
+ (globalThis as any).history.pushState({}, "", "/home");
1534
+ (globalThis as any).window.dataLayer.push({ event: "virtPageView", page_type: "home" });
1535
+ expect(getAssignments()).toHaveLength(0);
1536
+ expect(onExperimentViewed).not.toHaveBeenCalled();
1537
+ await vi.advanceTimersByTimeAsync(0);
1538
+ expect(getAssignments()).toEqual([expect.objectContaining({ experimentId: "exp_home_after_route" })]);
1539
+ expect(onExperimentViewed).toHaveBeenCalledTimes(1);
1540
+ await flush();
1541
+ const eventsCall = vi.mocked(globalThis.fetch).mock.calls.find(
1542
+ (call) => call[0] === "https://events.test/v1/events",
1543
+ );
1544
+ const events = JSON.parse(String(eventsCall?.[1]?.body)).events;
1545
+ expect(events.filter((event: { type: string }) => event.type === "experiment_viewed")).toEqual([
1546
+ expect.objectContaining({
1547
+ experimentId: "exp_home_after_route",
1548
+ variationId: "var_home",
1549
+ }),
1550
+ ]);
1551
+ });
1552
+
1553
+
1554
+ it("normalizes real and numeric-key gtag event records without mutating them", async () => {
1555
+ const { getDiagnostics, getSignals, init } = await import("./index");
1556
+ (globalThis as any).window.dataLayer = [];
1557
+ init({
1558
+ antiFlickerMode: "off",
1559
+ dataLayerTriggers: [{
1560
+ eventName: "add_to_cart",
1561
+ mappings: [
1562
+ { from: "currency", to: "cart.currency" },
1563
+ { from: "value", to: "cart.value", valueType: "number" },
1564
+ ],
1565
+ reEvaluate: false,
1566
+ }],
1567
+ endpoint: "https://events.test",
1568
+ experiments: [],
1569
+ shopId: "shop_123",
1570
+ trackingStart: "immediate",
1571
+ });
1572
+
1573
+ (function (..._args: unknown[]) {
1574
+ (globalThis as any).window.dataLayer.push(arguments);
1575
+ })("event", "add_to_cart", { currency: "EUR", value: 495 });
1576
+ expect(getSignals()).toMatchObject({ "cart.currency": "EUR", "cart.value": 495 });
1577
+
1578
+ const observedRecord = {
1579
+ 0: "event",
1580
+ 1: "add_to_cart",
1581
+ 2: { currency: "USD", event: "wrong", value: 725 },
1582
+ };
1583
+ const originalKeys = Object.keys(observedRecord);
1584
+ (globalThis as any).window.dataLayer.push(observedRecord);
1585
+
1586
+ expect(getSignals()).toMatchObject({ "cart.currency": "USD", "cart.value": 725 });
1587
+ expect(Object.keys(observedRecord)).toEqual(originalKeys);
1588
+ expect(observedRecord).not.toHaveProperty("event");
1589
+ expect(getDiagnostics().spa.lastDataLayerTrigger).toMatchObject({
1590
+ eventName: "add_to_cart",
1591
+ gtagNormalized: true,
1592
+ });
1593
+ });
1594
+
1595
+
1596
+ it("ignores non-event gtag commands", async () => {
1597
+ const { getDiagnostics, init } = await import("./index");
1598
+ (globalThis as any).window.dataLayer = [];
1599
+ init({
1600
+ antiFlickerMode: "off",
1601
+ dataLayerTriggers: [{ eventName: "add_to_cart", reEvaluate: false }],
1602
+ endpoint: "https://events.test",
1603
+ experiments: [],
1604
+ shopId: "shop_123",
1605
+ trackingStart: "immediate",
1606
+ });
1607
+
1608
+ for (const command of ["consent", "config", "set", "js", "get"]) {
1609
+ (globalThis as any).window.dataLayer.push({
1610
+ 0: command,
1611
+ 1: "add_to_cart",
1612
+ 2: { event: "add_to_cart" },
1613
+ });
1614
+ }
1615
+
1616
+ expect(getDiagnostics().spa.lastDataLayerTrigger).toBeUndefined();
1617
+ });
1618
+
1619
+
1620
+ it("normalizes gtag records replayed from the last 50 pre-init entries", async () => {
1621
+ (globalThis as any).window.dataLayer = [
1622
+ { 0: "event", 1: "add_to_cart", 2: { currency: "EUR" } },
1623
+ ];
1624
+ const { getDiagnostics, getSignals, init } = await import("./index");
1625
+ init({
1626
+ antiFlickerMode: "off",
1627
+ dataLayerTriggers: [{
1628
+ eventName: "add_to_cart",
1629
+ mappings: [{ from: "currency", to: "cart.currency" }],
1630
+ reEvaluate: false,
1631
+ }],
1632
+ endpoint: "https://events.test",
1633
+ experiments: [],
1634
+ shopId: "shop_123",
1635
+ trackingStart: "immediate",
1636
+ });
1637
+
1638
+ expect(getSignals()["cart.currency"]).toBe("EUR");
1639
+ expect(getDiagnostics().spa.lastDataLayerTrigger).toMatchObject({ gtagNormalized: true });
1640
+ });
1641
+
1642
+
1643
+ it.each([
1644
+ {
1645
+ name: "trackEventName only",
1646
+ trigger: { trackEventName: "add_to_cart" },
1647
+ expectedTypes: ["add_to_cart"],
1648
+ },
1649
+ {
1650
+ name: "trackPageview only",
1651
+ trigger: { trackPageview: true },
1652
+ expectedTypes: ["pageview"],
1653
+ },
1654
+ {
1655
+ name: "neither tracking option",
1656
+ trigger: {},
1657
+ expectedTypes: [],
1658
+ },
1659
+ {
1660
+ name: "trackEventName and trackPageview",
1661
+ trigger: { trackEventName: "add_to_cart", trackPageview: true },
1662
+ expectedTypes: ["add_to_cart"],
1663
+ },
1664
+ ])("emits the configured dataLayer event with $name", async ({ trigger, expectedTypes }) => {
1665
+ const { flush, init } = await import("./index");
1666
+
1667
+ (globalThis as any).window.dataLayer = [];
1668
+ init({
1669
+ antiFlickerMode: "off",
1670
+ dataLayerTriggers: [
1671
+ {
1672
+ eventName: "add_to_cart",
1673
+ reEvaluate: false,
1674
+ ...trigger,
1675
+ },
1676
+ ],
1677
+ endpoint: "https://events.test",
1678
+ experiments: [],
1679
+ shopId: "shop_123",
1680
+ trackingStart: "immediate",
1681
+ });
1682
+ await flush();
1683
+ vi.mocked(globalThis.fetch).mockClear();
1684
+
1685
+ (globalThis as any).window.dataLayer.push({ event: "add_to_cart", sku: "sku_123" });
1686
+ await flush();
1687
+
1688
+ const events = vi.mocked(globalThis.fetch).mock.calls.flatMap((call) => {
1689
+ if (call[0] !== "https://events.test/v1/events") return [];
1690
+ return JSON.parse(String(call[1]?.body)).events as Array<{ type: string }>;
1691
+ });
1692
+ expect(events.map((event) => event.type)).toEqual(expectedTypes);
1693
+ });
1694
+ });