@drip-apex/sdk 0.1.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/_headers +3 -0
  4. package/dist/bundle-report.json +564 -0
  5. package/dist/drip.debug.js +2 -0
  6. package/dist/drip.debug.js.map +1 -0
  7. package/dist/drip.heatmap.js +2 -0
  8. package/dist/drip.heatmap.js.map +1 -0
  9. package/dist/drip.js +2 -0
  10. package/dist/drip.js.map +1 -0
  11. package/dist/drip.live.js +2 -0
  12. package/dist/drip.live.js.map +1 -0
  13. package/dist/editor.js +480 -0
  14. package/dist/editor.js.map +7 -0
  15. package/dist/index.mjs +645 -0
  16. package/dist/index.mjs.map +7 -0
  17. package/dist/metafiles/drip.debug.meta.json +973 -0
  18. package/dist/metafiles/drip.heatmap.meta.json +918 -0
  19. package/dist/metafiles/drip.live.meta.json +918 -0
  20. package/dist/metafiles/drip.meta.json +973 -0
  21. package/dist/metafiles/editor.meta.json +321 -0
  22. package/dist/metafiles/index.meta.json +1039 -0
  23. package/dist/metafiles/index.mjs.meta.json +78 -0
  24. package/dist/react/index.js +16973 -0
  25. package/dist/react/index.js.map +7 -0
  26. package/dist/sri.json +27 -0
  27. package/dist/types/analytics.d.ts +15 -0
  28. package/dist/types/antiflicker.d.ts +14 -0
  29. package/dist/types/assignment-store.d.ts +30 -0
  30. package/dist/types/audit.d.ts +9 -0
  31. package/dist/types/bucket.d.ts +2 -0
  32. package/dist/types/bucketing-cleanup.d.ts +5 -0
  33. package/dist/types/commerce-gift.d.ts +41 -0
  34. package/dist/types/commerce-price.d.ts +22 -0
  35. package/dist/types/commerce-shipping.d.ts +28 -0
  36. package/dist/types/css.d.ts +5 -0
  37. package/dist/types/cwv.d.ts +10 -0
  38. package/dist/types/data-layer-normalize.d.ts +1 -0
  39. package/dist/types/debug.d.ts +23 -0
  40. package/dist/types/define.d.ts +87 -0
  41. package/dist/types/devtools.d.ts +32 -0
  42. package/dist/types/dom-trigger.d.ts +3 -0
  43. package/dist/types/editor/api.d.ts +37 -0
  44. package/dist/types/editor/artifacts.d.ts +15 -0
  45. package/dist/types/editor/command-runtime.d.ts +48 -0
  46. package/dist/types/editor/controller.d.ts +97 -0
  47. package/dist/types/editor/device-preview.d.ts +23 -0
  48. package/dist/types/editor/index.d.ts +9 -0
  49. package/dist/types/editor/operator.d.ts +39 -0
  50. package/dist/types/editor/picker.d.ts +23 -0
  51. package/dist/types/editor/selector.d.ts +2 -0
  52. package/dist/types/editor/types.d.ts +55 -0
  53. package/dist/types/editor/view-kit.d.ts +33 -0
  54. package/dist/types/editor/view.d.ts +5 -0
  55. package/dist/types/editor-bootstrap.d.ts +37 -0
  56. package/dist/types/exclusion-groups.d.ts +59 -0
  57. package/dist/types/exposure-signals.d.ts +7 -0
  58. package/dist/types/flags.d.ts +65 -0
  59. package/dist/types/goals.d.ts +16 -0
  60. package/dist/types/guards.d.ts +39 -0
  61. package/dist/types/handover.d.ts +10 -0
  62. package/dist/types/hash.d.ts +19 -0
  63. package/dist/types/heatmap.d.ts +8 -0
  64. package/dist/types/hideflicker.d.ts +15 -0
  65. package/dist/types/hydration.d.ts +11 -0
  66. package/dist/types/index.d.ts +144 -0
  67. package/dist/types/js.d.ts +6 -0
  68. package/dist/types/module.d.ts +4 -0
  69. package/dist/types/money.d.ts +14 -0
  70. package/dist/types/mutate.d.ts +34 -0
  71. package/dist/types/observer-mute.d.ts +3 -0
  72. package/dist/types/pii-keys.d.ts +1 -0
  73. package/dist/types/prerequisites.d.ts +12 -0
  74. package/dist/types/qa-assistant-state.d.ts +68 -0
  75. package/dist/types/qa-assistant-view.d.ts +6 -0
  76. package/dist/types/qa-assistant.d.ts +9 -0
  77. package/dist/types/qa-toolbar.d.ts +19 -0
  78. package/dist/types/react/index.d.ts +84 -0
  79. package/dist/types/reassert-sentinel.d.ts +6 -0
  80. package/dist/types/router.d.ts +9 -0
  81. package/dist/types/runtime-tools.d.ts +50 -0
  82. package/dist/types/runtime.d.ts +22 -0
  83. package/dist/types/safe-regex.d.ts +6 -0
  84. package/dist/types/sanitize.d.ts +5 -0
  85. package/dist/types/semantic.d.ts +7 -0
  86. package/dist/types/shopify-cart-events.d.ts +7 -0
  87. package/dist/types/shopify-cart.d.ts +62 -0
  88. package/dist/types/signals.d.ts +31 -0
  89. package/dist/types/surface-signals.d.ts +6 -0
  90. package/dist/types/targeting-diagnostics.d.ts +5 -0
  91. package/dist/types/targeting.d.ts +50 -0
  92. package/dist/types/test-fixtures/commerce.d.ts +12 -0
  93. package/dist/types/track.d.ts +79 -0
  94. package/dist/types/types.d.ts +859 -0
  95. package/dist/types/wait.d.ts +7 -0
  96. package/package.json +74 -0
  97. package/src/analytics.ts +146 -0
  98. package/src/antiflicker.test.ts +127 -0
  99. package/src/antiflicker.ts +147 -0
  100. package/src/assignment-store.test.ts +171 -0
  101. package/src/assignment-store.ts +208 -0
  102. package/src/audit.test.ts +17 -0
  103. package/src/audit.ts +362 -0
  104. package/src/bucket.test.ts +65 -0
  105. package/src/bucket.ts +18 -0
  106. package/src/bucketing-cleanup.test.ts +30 -0
  107. package/src/bucketing-cleanup.ts +72 -0
  108. package/src/bucketing-parity.test.ts +41 -0
  109. package/src/commerce-analytics-lifecycle.test.ts +1416 -0
  110. package/src/commerce-config-lifecycle.test.ts +274 -0
  111. package/src/commerce-epoch-lifecycle.test.ts +62 -0
  112. package/src/commerce-gift.test.ts +1293 -0
  113. package/src/commerce-gift.ts +979 -0
  114. package/src/commerce-offers-display.test.ts +154 -0
  115. package/src/commerce-price.test.ts +1145 -0
  116. package/src/commerce-price.ts +941 -0
  117. package/src/commerce-revocation-lifecycle.test.ts +78 -0
  118. package/src/commerce-shipping.test.ts +630 -0
  119. package/src/commerce-shipping.ts +447 -0
  120. package/src/config-fetch.test.ts +325 -0
  121. package/src/css.ts +54 -0
  122. package/src/cwv.test.ts +95 -0
  123. package/src/cwv.ts +129 -0
  124. package/src/data-layer-normalize.ts +21 -0
  125. package/src/debug.test.ts +83 -0
  126. package/src/debug.ts +138 -0
  127. package/src/define.test.ts +327 -0
  128. package/src/define.ts +208 -0
  129. package/src/devtools.ts +582 -0
  130. package/src/dom-trigger.test.ts +70 -0
  131. package/src/dom-trigger.ts +55 -0
  132. package/src/editor/api.test.ts +154 -0
  133. package/src/editor/api.ts +185 -0
  134. package/src/editor/artifacts.test.ts +57 -0
  135. package/src/editor/artifacts.ts +115 -0
  136. package/src/editor/command-runtime.test.ts +225 -0
  137. package/src/editor/command-runtime.ts +572 -0
  138. package/src/editor/controller.test.ts +371 -0
  139. package/src/editor/controller.ts +596 -0
  140. package/src/editor/device-preview.test.ts +60 -0
  141. package/src/editor/device-preview.ts +120 -0
  142. package/src/editor/index.test.ts +161 -0
  143. package/src/editor/index.ts +169 -0
  144. package/src/editor/operator.test.ts +148 -0
  145. package/src/editor/operator.ts +256 -0
  146. package/src/editor/picker.test.ts +58 -0
  147. package/src/editor/picker.ts +152 -0
  148. package/src/editor/selector.test.ts +44 -0
  149. package/src/editor/selector.ts +110 -0
  150. package/src/editor/types.ts +92 -0
  151. package/src/editor/view-kit.ts +162 -0
  152. package/src/editor/view.test.ts +535 -0
  153. package/src/editor/view.ts +1395 -0
  154. package/src/editor-bootstrap.test.ts +116 -0
  155. package/src/editor-bootstrap.ts +213 -0
  156. package/src/event-schema-contract.test.ts +35 -0
  157. package/src/exclusion-groups.test.ts +383 -0
  158. package/src/exclusion-groups.ts +270 -0
  159. package/src/exposure-signals.test.ts +182 -0
  160. package/src/exposure-signals.ts +110 -0
  161. package/src/flags.test.ts +322 -0
  162. package/src/flags.ts +201 -0
  163. package/src/goals.test.ts +42 -0
  164. package/src/goals.ts +200 -0
  165. package/src/guards.test.ts +249 -0
  166. package/src/guards.ts +250 -0
  167. package/src/handover-cache-order.test.ts +32 -0
  168. package/src/handover.test.ts +349 -0
  169. package/src/handover.ts +309 -0
  170. package/src/hash.ts +214 -0
  171. package/src/heatmap.test.ts +305 -0
  172. package/src/heatmap.ts +373 -0
  173. package/src/hideflicker.ts +21 -0
  174. package/src/hydration.test.ts +252 -0
  175. package/src/hydration.ts +203 -0
  176. package/src/index.ts +7513 -0
  177. package/src/init-empty.test.ts +5572 -0
  178. package/src/js.test.ts +26 -0
  179. package/src/js.ts +61 -0
  180. package/src/module.ts +50 -0
  181. package/src/money.test.ts +35 -0
  182. package/src/money.ts +53 -0
  183. package/src/mutate.test.ts +304 -0
  184. package/src/mutate.ts +469 -0
  185. package/src/observer-mute.test.ts +88 -0
  186. package/src/observer-mute.ts +45 -0
  187. package/src/page-trigger.test.ts +84 -0
  188. package/src/personalization.test.ts +68 -0
  189. package/src/pii-keys.ts +17 -0
  190. package/src/prerequisites.test.ts +39 -0
  191. package/src/prerequisites.ts +94 -0
  192. package/src/qa-assistant-state.test.ts +471 -0
  193. package/src/qa-assistant-state.ts +505 -0
  194. package/src/qa-assistant-view.test.ts +185 -0
  195. package/src/qa-assistant-view.ts +768 -0
  196. package/src/qa-assistant.ts +40 -0
  197. package/src/qa-toolbar.ts +444 -0
  198. package/src/react/index.test.tsx +177 -0
  199. package/src/react/index.tsx +252 -0
  200. package/src/reassert-sentinel.test.ts +314 -0
  201. package/src/reassert-sentinel.ts +191 -0
  202. package/src/router.test.ts +78 -0
  203. package/src/router.ts +92 -0
  204. package/src/runtime-tools.test.ts +112 -0
  205. package/src/runtime-tools.ts +403 -0
  206. package/src/runtime.test.ts +217 -0
  207. package/src/runtime.ts +268 -0
  208. package/src/safe-regex.ts +35 -0
  209. package/src/sanitize.ts +34 -0
  210. package/src/semantic.ts +245 -0
  211. package/src/shopify-cart-events.test.ts +76 -0
  212. package/src/shopify-cart-events.ts +115 -0
  213. package/src/shopify-cart.test.ts +2823 -0
  214. package/src/shopify-cart.ts +1256 -0
  215. package/src/signals.ts +226 -0
  216. package/src/surface-signals.test.ts +83 -0
  217. package/src/surface-signals.ts +165 -0
  218. package/src/targeting-diagnostics.ts +560 -0
  219. package/src/targeting.detailed.test.ts +443 -0
  220. package/src/targeting.test.ts +1054 -0
  221. package/src/targeting.ts +1024 -0
  222. package/src/test-fixtures/commerce.ts +65 -0
  223. package/src/track.test.ts +817 -0
  224. package/src/track.ts +519 -0
  225. package/src/types.ts +991 -0
  226. package/src/wait.test.ts +96 -0
  227. package/src/wait.ts +238 -0
@@ -0,0 +1,1416 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ commerceAssignmentResponseFixture,
5
+ commercePriceFixture as commerce,
6
+ installCommerceProductPage,
7
+ priceExperimentsFixture as experiments,
8
+ } from "./test-fixtures/commerce";
9
+
10
+ describe("commerce analytics lifecycle", () => {
11
+ const originalFetch = globalThis.fetch;
12
+ let fetchMock: ReturnType<typeof vi.fn>;
13
+
14
+ beforeEach(() => {
15
+ vi.useFakeTimers();
16
+ vi.resetModules();
17
+ delete (globalThis as any).__drip;
18
+ delete (globalThis as any).drip;
19
+ delete (globalThis as any).Apex;
20
+ localStorage.clear();
21
+ sessionStorage.clear();
22
+ installCommerceProductPage();
23
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
24
+ String(input).endsWith("/cart.js")
25
+ ? Response.json({ token: "cart-runtime?key=runtime-secret" })
26
+ : String(input).includes("/v1/assign")
27
+ ? new Response(JSON.stringify(commerceAssignmentResponseFixture), { status: 200 })
28
+ : new Response("ok", { status: 202 }));
29
+ globalThis.fetch = fetchMock as typeof fetch;
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.clearAllTimers();
34
+ vi.useRealTimers();
35
+ vi.restoreAllMocks();
36
+ globalThis.fetch = originalFetch;
37
+ delete (globalThis as any).__drip;
38
+ delete (globalThis as any).drip;
39
+ delete (globalThis as any).Apex;
40
+ delete (window as Window & { Shopify?: unknown }).Shopify;
41
+ delete (window as Window & { ShopifyAnalytics?: unknown }).ShopifyAnalytics;
42
+ document.head.innerHTML = "";
43
+ document.body.innerHTML = "";
44
+ });
45
+
46
+ it("adopts a null cart pin without retaining treatment state or exposure", async () => {
47
+ const nullPinToken = JSON.stringify({
48
+ v: 2,
49
+ p: {
50
+ price: {
51
+ v: 1,
52
+ r: 3,
53
+ c: "b".repeat(64),
54
+ x: "2026-07-20",
55
+ cartHash: "c".repeat(64),
56
+ a: [],
57
+ s: "d".repeat(64),
58
+ },
59
+ },
60
+ });
61
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
62
+ String(input).endsWith("/cart.js")
63
+ ? Response.json({ token: "cart-runtime?key=runtime-secret" })
64
+ : String(input).includes("/v1/assign")
65
+ ? Response.json({ assignments: [], commerce_authorization: nullPinToken })
66
+ : new Response("ok", { status: 202 }));
67
+ globalThis.fetch = fetchMock as typeof fetch;
68
+ const onExperimentViewed = vi.fn();
69
+ const { getDiagnostics, init } = await import("./index");
70
+
71
+ init({
72
+ antiFlickerMode: "off",
73
+ commerce,
74
+ endpoint: "https://events.test",
75
+ experiments,
76
+ ingestSignature: "ingest-token",
77
+ onExperimentViewed,
78
+ shopId: "shop_123",
79
+ trackingStart: "immediate",
80
+ userId: "visitor_123",
81
+ });
82
+
83
+ await vi.waitFor(() => {
84
+ expect(getDiagnostics().assignments).toEqual([]);
85
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
86
+ expect(fetchMock.mock.calls.some(([url]) => url === "/cart/update.js")).toBe(true);
87
+ });
88
+ const cartUpdate = fetchMock.mock.calls.find(([url]) => url === "/cart/update.js");
89
+ const attributes = JSON.parse(String(cartUpdate?.[1]?.body)).attributes;
90
+ expect(attributes).toMatchObject({
91
+ apex_assignments: "[]",
92
+ apex_assignment_authorization: nullPinToken,
93
+ apex_experiment_id: "",
94
+ apex_variation_id: "",
95
+ apex_assignment_epoch: "",
96
+ });
97
+ expect(JSON.parse(localStorage.getItem("drip_assignments_v1") ?? "{}")).not.toHaveProperty(
98
+ "exp_price",
99
+ );
100
+ expect(localStorage.getItem("drip_exposures")).toBeNull();
101
+ expect(onExperimentViewed).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it.each([
105
+ ["withholds exposure for rejected compare-at framing", "1299.00", false],
106
+ ["emits exposure for rendered compare-at framing", "1499.00", true],
107
+ ] as const)("%s", async (_label, amount, shouldExpose) => {
108
+ const onExperimentViewed = vi.fn();
109
+ const { init } = await import("./index");
110
+
111
+ init({
112
+ antiFlickerMode: "off",
113
+ commerce: {
114
+ price: {
115
+ revision: 1,
116
+ checksum: "sum",
117
+ experiments: [
118
+ {
119
+ experiment_id: "exp_compare",
120
+ assignment_epoch: 1,
121
+ currency: "EUR",
122
+ variants: {
123
+ treatment: [
124
+ {
125
+ variant_gid: "gid://shopify/ProductVariant/123",
126
+ compareAt: { mode: "set", amount },
127
+ },
128
+ ],
129
+ },
130
+ },
131
+ ],
132
+ },
133
+ },
134
+ endpoint: "https://events.test",
135
+ experiments: [
136
+ {
137
+ id: "exp_compare",
138
+ assignmentEpoch: 1,
139
+ status: "running",
140
+ trafficAllocation: 1,
141
+ variations: [
142
+ { id: "treatment", weight: 1, isControl: false, mutations: [] },
143
+ ],
144
+ },
145
+ ],
146
+ onExperimentViewed,
147
+ shopId: "shop_123",
148
+ trackingStart: "immediate",
149
+ userId: "visitor_compare",
150
+ });
151
+
152
+ await vi.waitFor(() => {
153
+ expect(Boolean(document.querySelector("[data-apex-compare-at]"))).toBe(
154
+ shouldExpose,
155
+ );
156
+ expect(onExperimentViewed).toHaveBeenCalledTimes(shouldExpose ? 1 : 0);
157
+ });
158
+ });
159
+
160
+ it("keeps an off-target resurrected pin in the price plane only", async () => {
161
+ delete (window as Window & { ShopifyAnalytics?: unknown }).ShopifyAnalytics;
162
+ document.body.insertAdjacentHTML(
163
+ "beforeend",
164
+ '<div class="companion">Control companion</div>',
165
+ );
166
+ const targetedExperiments = [{
167
+ ...experiments[0],
168
+ targeting: {
169
+ url: [{ type: "simple" as const, pattern: "*/products/other", include: true }],
170
+ },
171
+ variations: [{
172
+ ...experiments[0]!.variations[0],
173
+ mutations: [{
174
+ selector: ".companion",
175
+ action: "text" as const,
176
+ text: "Treatment companion",
177
+ }],
178
+ }],
179
+ goals: [{
180
+ id: "goal_companion",
181
+ type: "click" as const,
182
+ selector: ".companion",
183
+ }],
184
+ }];
185
+ const pinnedToken = JSON.stringify({
186
+ v: 1,
187
+ cartHash: "c".repeat(64),
188
+ a: [{ e: "exp_price", v: "treatment", ep: 1 }],
189
+ });
190
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
191
+ String(input).endsWith("/cart.js")
192
+ ? Response.json({ token: "cart-runtime?key=runtime-secret" })
193
+ : String(input).includes("/v1/assign")
194
+ ? Response.json({
195
+ assignments: [{
196
+ experiment_id: "exp_price",
197
+ variation_id: "treatment",
198
+ assignment_epoch: 1,
199
+ commerce_price_only: true,
200
+ }],
201
+ commerce_authorization: pinnedToken,
202
+ })
203
+ : new Response("ok", { status: 202 }));
204
+ globalThis.fetch = fetchMock as typeof fetch;
205
+ const onExperimentViewed = vi.fn();
206
+ const { flush, getAssignments, init, trackRevenue } = await import("./index");
207
+
208
+ init({
209
+ antiFlickerMode: "off",
210
+ commerce,
211
+ endpoint: "https://events.test",
212
+ experiments: targetedExperiments,
213
+ ingestSignature: "ingest-token",
214
+ onExperimentViewed,
215
+ shopId: "shop_123",
216
+ trackingStart: "immediate",
217
+ userId: "visitor_123",
218
+ });
219
+
220
+ await vi.waitFor(() => {
221
+ expect(fetchMock.mock.calls.some(([url]) => url === "/cart/update.js")).toBe(true);
222
+ });
223
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
224
+ expect(document.querySelector(".companion")?.textContent).toBe("Control companion");
225
+ expect(getAssignments()).toEqual([]);
226
+ expect(onExperimentViewed).not.toHaveBeenCalled();
227
+ expect(localStorage.getItem("drip_exposures")).toBeNull();
228
+
229
+ trackRevenue(19.99, { orderId: "off-target-order" });
230
+ await flush();
231
+ const trackedEvents = fetchMock.mock.calls
232
+ .filter(([url]) => String(url) === "https://events.test/events")
233
+ .flatMap(([, options]) => JSON.parse(String(options?.body)).events);
234
+ expect(trackedEvents.some((event: { type: string }) =>
235
+ event.type === "experiment_viewed" || event.type === "goal"
236
+ )).toBe(false);
237
+ const cartUpdate = fetchMock.mock.calls.find(([url]) => url === "/cart/update.js");
238
+ expect(JSON.parse(String(cartUpdate?.[1]?.body)).attributes).toMatchObject({
239
+ apex_assignments: JSON.stringify([{ e: "exp_price", v: "treatment", ep: 1 }]),
240
+ apex_assignment_authorization: pinnedToken,
241
+ });
242
+
243
+ });
244
+
245
+ it("keeps off-target offer pins charge-only while applying targeted offers", async () => {
246
+ document.body.insertAdjacentHTML(
247
+ "beforeend",
248
+ '<div class="off-target-offer">Off-target control</div><div class="targeted-offer">Targeted control</div>',
249
+ );
250
+ const offerExperiments = [
251
+ {
252
+ id: "exp_offer_off_target",
253
+ assignmentEpoch: 1,
254
+ status: "running" as const,
255
+ trafficAllocation: 1,
256
+ targeting: {
257
+ url: [{ type: "simple" as const, pattern: "*/products/other", include: true }],
258
+ },
259
+ variations: [{
260
+ id: "off_target_treatment",
261
+ weight: 1,
262
+ isControl: false,
263
+ mutations: [{
264
+ selector: ".off-target-offer",
265
+ action: "text" as const,
266
+ text: "Off-target treatment",
267
+ }],
268
+ }],
269
+ },
270
+ {
271
+ id: "exp_offer_targeted",
272
+ assignmentEpoch: 1,
273
+ status: "running" as const,
274
+ trafficAllocation: 1,
275
+ variations: [{
276
+ id: "targeted_treatment",
277
+ weight: 1,
278
+ isControl: false,
279
+ mutations: [{
280
+ selector: ".targeted-offer",
281
+ action: "text" as const,
282
+ text: "Targeted treatment",
283
+ }],
284
+ }],
285
+ },
286
+ ];
287
+ const authorization = JSON.stringify({
288
+ v: 2,
289
+ p: {
290
+ offers: {
291
+ v: 1,
292
+ a: [
293
+ { e: "exp_offer_off_target", v: "off_target_treatment", ep: 1 },
294
+ { e: "exp_offer_targeted", v: "targeted_treatment", ep: 1 },
295
+ ],
296
+ },
297
+ },
298
+ });
299
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
300
+ String(input).endsWith("/cart.js")
301
+ ? Response.json({ token: "cart-runtime?key=runtime-secret" })
302
+ : String(input).includes("/v1/assign")
303
+ ? Response.json({
304
+ assignments: [
305
+ {
306
+ experiment_id: "exp_offer_off_target",
307
+ variation_id: "off_target_treatment",
308
+ assignment_epoch: 1,
309
+ commerce_price_only: true,
310
+ },
311
+ {
312
+ experiment_id: "exp_offer_targeted",
313
+ variation_id: "targeted_treatment",
314
+ assignment_epoch: 1,
315
+ },
316
+ ],
317
+ commerce_authorization: authorization,
318
+ })
319
+ : new Response("ok", { status: 202 }));
320
+ globalThis.fetch = fetchMock as typeof fetch;
321
+ const onExperimentViewed = vi.fn();
322
+ const { getAssignments, init } = await import("./index");
323
+
324
+ init({
325
+ antiFlickerMode: "off",
326
+ commerce: {
327
+ offers: {
328
+ authorization: {
329
+ version: 1,
330
+ revision: 1,
331
+ checksum: "a".repeat(64),
332
+ experimentIds: ["exp_offer_off_target", "exp_offer_targeted"],
333
+ },
334
+ experiments: [],
335
+ },
336
+ },
337
+ endpoint: "https://events.test",
338
+ experiments: offerExperiments,
339
+ ingestSignature: "ingest-token",
340
+ onExperimentViewed,
341
+ shopId: "shop_123",
342
+ trackingStart: "immediate",
343
+ userId: "visitor_123",
344
+ });
345
+
346
+ await vi.waitFor(() => {
347
+ expect(fetchMock.mock.calls.some(([url]) => url === "/cart/update.js")).toBe(true);
348
+ });
349
+ expect(document.querySelector(".off-target-offer")?.textContent)
350
+ .toBe("Off-target control");
351
+ expect(document.querySelector(".targeted-offer")?.textContent)
352
+ .toBe("Targeted treatment");
353
+ expect(getAssignments()).toEqual([
354
+ expect.objectContaining({ experimentId: "exp_offer_targeted" }),
355
+ ]);
356
+ expect(onExperimentViewed).toHaveBeenCalledTimes(1);
357
+ expect(onExperimentViewed).toHaveBeenCalledWith(
358
+ expect.objectContaining({ experimentId: "exp_offer_targeted" }),
359
+ );
360
+ expect(JSON.parse(localStorage.getItem("drip_assignments_v1") ?? "{}"))
361
+ .not.toHaveProperty("exp_offer_off_target");
362
+ expect(JSON.parse(localStorage.getItem("drip_assignments_v1") ?? "{}"))
363
+ .toHaveProperty("exp_offer_targeted");
364
+ const cartUpdate = fetchMock.mock.calls.find(([url]) => url === "/cart/update.js");
365
+ expect(JSON.parse(JSON.parse(String(cartUpdate?.[1]?.body)).attributes.apex_assignments))
366
+ .toEqual(expect.arrayContaining([
367
+ { e: "exp_offer_off_target", v: "off_target_treatment", ep: 1 },
368
+ { e: "exp_offer_targeted", v: "targeted_treatment", ep: 1 },
369
+ ]));
370
+ await expect(window.fetch("/cart/add.js", { method: "POST" }))
371
+ .resolves.toMatchObject({ status: 202 });
372
+ });
373
+
374
+ it("keeps browser-only companion mutations but withholds price display and exposure", async () => {
375
+ document.body.insertAdjacentHTML(
376
+ "beforeend",
377
+ '<div class="browser-copy">Browser control</div>',
378
+ );
379
+ const browserOnlyExperiment = {
380
+ id: "exp_browser_only",
381
+ assignmentEpoch: 1,
382
+ status: "running" as const,
383
+ trafficAllocation: 1,
384
+ variations: [{
385
+ id: "browser_treatment",
386
+ weight: 1,
387
+ isControl: false,
388
+ mutations: [{
389
+ selector: ".browser-copy",
390
+ action: "text" as const,
391
+ text: "Browser treatment",
392
+ }],
393
+ }],
394
+ };
395
+ const mixedCommerce = {
396
+ price: {
397
+ ...commerce.price!,
398
+ authorization: {
399
+ ...commerce.price!.authorization!,
400
+ experimentIds: ["exp_price", "exp_browser_only"],
401
+ },
402
+ experiments: [
403
+ ...commerce.price!.experiments,
404
+ {
405
+ experiment_id: "exp_browser_only",
406
+ assignment_epoch: 1,
407
+ currency: "EUR",
408
+ variants: {
409
+ browser_treatment: [{
410
+ variant_gid: "gid://shopify/ProductVariant/123",
411
+ fixedPricePerUnit: "1099.95",
412
+ }],
413
+ },
414
+ },
415
+ ],
416
+ },
417
+ };
418
+ const nullPinToken = JSON.stringify({
419
+ v: 2,
420
+ e: ["exp_price"],
421
+ p: {
422
+ price: {
423
+ v: 1,
424
+ r: 3,
425
+ c: "b".repeat(64),
426
+ x: "2026-07-20",
427
+ cartHash: "c".repeat(64),
428
+ a: [],
429
+ s: "d".repeat(64),
430
+ },
431
+ },
432
+ });
433
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
434
+ String(input).endsWith("/cart.js")
435
+ ? Response.json({ token: "cart-runtime?key=runtime-secret" })
436
+ : String(input).includes("/v1/assign")
437
+ ? Response.json({ assignments: [], commerce_authorization: nullPinToken })
438
+ : new Response("ok", { status: 202 }));
439
+ globalThis.fetch = fetchMock as typeof fetch;
440
+ const onExperimentViewed = vi.fn();
441
+ const { getDiagnostics, init } = await import("./index");
442
+
443
+ init({
444
+ antiFlickerMode: "off",
445
+ commerce: mixedCommerce,
446
+ endpoint: "https://events.test",
447
+ experiments: [...experiments, browserOnlyExperiment],
448
+ ingestSignature: "ingest-token",
449
+ onExperimentViewed,
450
+ shopId: "shop_123",
451
+ trackingStart: "immediate",
452
+ userId: "visitor_123",
453
+ });
454
+
455
+ await vi.waitFor(() => {
456
+ expect(getDiagnostics().assignments).toEqual([
457
+ expect.objectContaining({
458
+ experimentId: "exp_browser_only",
459
+ variationId: "browser_treatment",
460
+ }),
461
+ ]);
462
+ expect(document.querySelector(".browser-copy")?.textContent)
463
+ .toBe("Browser treatment");
464
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
465
+ expect(fetchMock.mock.calls.some(([url]) => url === "/cart/update.js")).toBe(true);
466
+ });
467
+ expect(onExperimentViewed).not.toHaveBeenCalled();
468
+ expect(localStorage.getItem("drip_exposures")).toBeNull();
469
+ const cartUpdate = fetchMock.mock.calls.find(([url]) => url === "/cart/update.js");
470
+ const attributes = JSON.parse(String(cartUpdate?.[1]?.body)).attributes;
471
+ expect(JSON.parse(attributes.apex_assignments)).toEqual([{
472
+ e: "exp_browser_only",
473
+ v: "browser_treatment",
474
+ ep: 1,
475
+ }]);
476
+ expect(attributes.apex_assignment_authorization).toBe(nullPinToken);
477
+ });
478
+
479
+ it("keeps headless display published until a successful cart write is acknowledged", async () => {
480
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
481
+ String(input).endsWith("/cart.js")
482
+ ? new Response("unavailable", { status: 503 })
483
+ : String(input).includes("/v1/assign")
484
+ ? Response.json(commerceAssignmentResponseFixture)
485
+ : new Response("ok", { status: 202 }));
486
+ globalThis.fetch = fetchMock as typeof fetch;
487
+ const onExperimentViewed = vi.fn();
488
+ const {
489
+ confirmCartAttributesWritten,
490
+ getDiagnostics,
491
+ getSignedCartAttributes,
492
+ init,
493
+ } = await import("./index");
494
+ expect((globalThis as any).drip.confirmCartAttributesWritten)
495
+ .toBe(confirmCartAttributesWritten);
496
+ expect((globalThis as any).Apex.confirmCartAttributesWritten)
497
+ .toBe(confirmCartAttributesWritten);
498
+
499
+ init({
500
+ antiFlickerMode: "off",
501
+ commerce,
502
+ endpoint: "https://events.test",
503
+ experiments,
504
+ ingestSignature: "ingest-token",
505
+ onExperimentViewed,
506
+ shopId: "shop_123",
507
+ trackingStart: "immediate",
508
+ userId: "visitor_123",
509
+ });
510
+ await vi.waitFor(() => {
511
+ expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith("/cart.js")))
512
+ .toBe(true);
513
+ });
514
+
515
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
516
+ expect(onExperimentViewed).not.toHaveBeenCalled();
517
+
518
+ const entries = await getSignedCartAttributes(
519
+ "gid://shopify/Cart/headless?key=headless-secret",
520
+ );
521
+
522
+ expect(Object.fromEntries(entries.map((entry) => [entry.key, entry.value])))
523
+ .toMatchObject({
524
+ apex_assignment_authorization: commerceAssignmentResponseFixture.commerce_authorization,
525
+ });
526
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
527
+ expect(onExperimentViewed).not.toHaveBeenCalled();
528
+ expect(localStorage.getItem("drip_exposures")).toBeNull();
529
+ expect(getDiagnostics().spa.lastShopifyCartSync?.status).toBe("pending");
530
+
531
+ expect(confirmCartAttributesWritten(
532
+ "gid://shopify/Cart/wrong?key=wrong-secret",
533
+ )).toBe(false);
534
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
535
+ expect(onExperimentViewed).not.toHaveBeenCalled();
536
+
537
+ expect(confirmCartAttributesWritten(
538
+ "gid://shopify/Cart/headless?key=headless-secret",
539
+ )).toBe(true);
540
+ await vi.waitFor(() => {
541
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
542
+ expect(onExperimentViewed).toHaveBeenCalledWith(
543
+ expect.objectContaining({
544
+ experimentId: "exp_price",
545
+ variationId: "treatment",
546
+ }),
547
+ );
548
+ });
549
+ expect(getDiagnostics().spa.lastShopifyCartSync?.status).toBe("sent");
550
+ expect(confirmCartAttributesWritten(
551
+ "gid://shopify/Cart/headless?key=headless-secret",
552
+ )).toBe(false);
553
+ expect(fetchMock.mock.calls.some(([url]) => url === "/cart/update.js")).toBe(false);
554
+
555
+ const replacementEntries = await getSignedCartAttributes(
556
+ "gid://shopify/Cart/replacement?key=replacement-secret",
557
+ );
558
+ expect(Object.fromEntries(replacementEntries.map((entry) => [entry.key, entry.value])))
559
+ .toMatchObject({
560
+ apex_assignment_authorization: commerceAssignmentResponseFixture.commerce_authorization,
561
+ });
562
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
563
+ expect(getDiagnostics().spa.lastShopifyCartSync?.status).toBe("pending");
564
+
565
+ expect(confirmCartAttributesWritten(
566
+ "gid://shopify/Cart/replacement?key=replacement-secret",
567
+ )).toBe(true);
568
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
569
+
570
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) =>
571
+ String(input).includes("/v1/assign")
572
+ ? new Response("unavailable", { status: 503 })
573
+ : new Response("ok", { status: 202 }));
574
+ const cartWritesBeforeFailure = fetchMock.mock.calls.filter(
575
+ ([url]) => url === "/cart/update.js",
576
+ ).length;
577
+
578
+ await expect(getSignedCartAttributes(
579
+ "gid://shopify/Cart/failed-replacement?key=failed-secret",
580
+ )).resolves.toEqual([]);
581
+ await vi.waitFor(() => {
582
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
583
+ });
584
+ expect(fetchMock.mock.calls.filter(([url]) => url === "/cart/update.js"))
585
+ .toHaveLength(cartWritesBeforeFailure);
586
+ });
587
+
588
+ it("rejects a stale headless acknowledgement for the same cart generation", async () => {
589
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
590
+ String(input).endsWith("/cart.js")
591
+ ? new Response("unavailable", { status: 503 })
592
+ : String(input).includes("/v1/assign")
593
+ ? Response.json(commerceAssignmentResponseFixture)
594
+ : new Response("ok", { status: 202 }));
595
+ globalThis.fetch = fetchMock as typeof fetch;
596
+ const {
597
+ confirmCartAttributesWritten,
598
+ getSignedCartAttributes,
599
+ init,
600
+ } = await import("./index");
601
+
602
+ init({
603
+ antiFlickerMode: "off",
604
+ commerce,
605
+ endpoint: "https://events.test",
606
+ experiments,
607
+ ingestSignature: "ingest-token",
608
+ shopId: "shop_123",
609
+ trackingStart: "immediate",
610
+ userId: "visitor_123",
611
+ });
612
+ await vi.waitFor(() => {
613
+ expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith("/cart.js")))
614
+ .toBe(true);
615
+ });
616
+
617
+ const cartToken = "gid://shopify/Cart/headless?key=headless-secret";
618
+ await expect(getSignedCartAttributes(cartToken)).resolves.not.toEqual([]);
619
+
620
+ let resolveLatest!: (response: Response) => void;
621
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
622
+ if (!String(input).includes("/v1/assign")) return new Response("ok", { status: 202 });
623
+ return new Promise<Response>((resolve) => { resolveLatest = resolve; });
624
+ });
625
+ const latest = getSignedCartAttributes(cartToken);
626
+ await vi.waitFor(() => expect(resolveLatest).toEqual(expect.any(Function)));
627
+
628
+ expect(confirmCartAttributesWritten(cartToken)).toBe(false);
629
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
630
+
631
+ resolveLatest(Response.json(commerceAssignmentResponseFixture));
632
+ await expect(latest).resolves.not.toEqual([]);
633
+ expect(confirmCartAttributesWritten(cartToken)).toBe(true);
634
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
635
+ });
636
+
637
+ it("does not let a stale failed headless call revoke the newer cart", async () => {
638
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
639
+ String(input).endsWith("/cart.js")
640
+ ? new Response("unavailable", { status: 503 })
641
+ : String(input).includes("/v1/assign")
642
+ ? Response.json(commerceAssignmentResponseFixture)
643
+ : new Response("ok", { status: 202 }));
644
+ globalThis.fetch = fetchMock as typeof fetch;
645
+ const {
646
+ confirmCartAttributesWritten,
647
+ getDiagnostics,
648
+ getSignedCartAttributes,
649
+ init,
650
+ } = await import("./index");
651
+
652
+ init({
653
+ antiFlickerMode: "off",
654
+ commerce,
655
+ endpoint: "https://events.test",
656
+ experiments,
657
+ ingestSignature: "ingest-token",
658
+ shopId: "shop_123",
659
+ trackingStart: "immediate",
660
+ userId: "visitor_123",
661
+ });
662
+ await vi.waitFor(() => {
663
+ expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith("/cart.js")))
664
+ .toBe(true);
665
+ });
666
+ const initialCartToken = "gid://shopify/Cart/initial?key=initial-secret";
667
+ await expect(getSignedCartAttributes(initialCartToken)).resolves.not.toEqual([]);
668
+ expect(confirmCartAttributesWritten(initialCartToken)).toBe(true);
669
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
670
+
671
+ let resolveOlder!: (response: Response) => void;
672
+ let resolveNewer!: (response: Response) => void;
673
+ let headlessAuthorizationCalls = 0;
674
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
675
+ if (!String(input).includes("/v1/assign")) return new Response("ok", { status: 202 });
676
+ headlessAuthorizationCalls += 1;
677
+ return new Promise<Response>((resolve) => {
678
+ if (headlessAuthorizationCalls === 1) resolveOlder = resolve;
679
+ else resolveNewer = resolve;
680
+ });
681
+ });
682
+
683
+ const older = getSignedCartAttributes(
684
+ "gid://shopify/Cart/older?key=older-secret",
685
+ );
686
+ const newer = getSignedCartAttributes(
687
+ "gid://shopify/Cart/newer?key=newer-secret",
688
+ );
689
+ await vi.waitFor(() => expect(headlessAuthorizationCalls).toBe(2));
690
+
691
+ resolveNewer(Response.json(commerceAssignmentResponseFixture));
692
+ await expect(newer).resolves.not.toEqual([]);
693
+ expect(getDiagnostics().spa.lastShopifyCartSync?.status).toBe("pending");
694
+
695
+ resolveOlder(new Response("unavailable", { status: 503 }));
696
+ await expect(older).resolves.toEqual([]);
697
+ expect(confirmCartAttributesWritten(
698
+ "gid://shopify/Cart/older?key=older-secret",
699
+ )).toBe(false);
700
+ expect(confirmCartAttributesWritten(
701
+ "gid://shopify/Cart/newer?key=newer-secret",
702
+ )).toBe(true);
703
+ expect(getDiagnostics().spa.lastShopifyCartSync?.status).toBe("sent");
704
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
705
+ });
706
+
707
+ it("withholds shipping display and exposure when only the price plane is signed", async () => {
708
+ const shippingExperiment = {
709
+ id: "exp_shipping",
710
+ assignmentEpoch: 1,
711
+ status: "running" as const,
712
+ trafficAllocation: 1,
713
+ variations: [{
714
+ id: "shipping_treatment",
715
+ weight: 1,
716
+ isControl: false,
717
+ mutations: [],
718
+ }],
719
+ };
720
+ const mixedCommerce = {
721
+ ...commerce,
722
+ shipping: {
723
+ authorization: {
724
+ version: 1 as const,
725
+ revision: 7,
726
+ checksum: "b".repeat(64),
727
+ experimentIds: ["exp_shipping"],
728
+ },
729
+ experiments: [{
730
+ experiment_id: "exp_shipping",
731
+ assignment_epoch: 1,
732
+ currency: "EUR",
733
+ variants: {
734
+ shipping_treatment: {
735
+ threshold: "50.00",
736
+ treatment: "banner" as const,
737
+ progressCopy: "Only {remaining} until free shipping",
738
+ reachedCopy: "Free shipping reached",
739
+ },
740
+ },
741
+ }],
742
+ },
743
+ };
744
+ const priceOnlyToken = JSON.stringify({
745
+ v: 2,
746
+ p: {
747
+ price: {
748
+ v: 1,
749
+ cartHash: "c".repeat(64),
750
+ a: [{ e: "exp_price", v: "treatment", ep: 1 }],
751
+ },
752
+ },
753
+ });
754
+ fetchMock = vi.fn(async (input: RequestInfo | URL) => {
755
+ const url = String(input);
756
+ if (url.endsWith("/cart.js")) {
757
+ return Response.json({
758
+ token: "cart-runtime?key=runtime-secret",
759
+ currency: "EUR",
760
+ items_subtotal_price: 2500,
761
+ });
762
+ }
763
+ if (url.includes("/v1/assign")) {
764
+ return Response.json({
765
+ assignments: [
766
+ { experiment_id: "exp_price", variation_id: "treatment", assignment_epoch: 1 },
767
+ {
768
+ experiment_id: "exp_shipping",
769
+ variation_id: "shipping_treatment",
770
+ assignment_epoch: 1,
771
+ },
772
+ ],
773
+ commerce_authorization: priceOnlyToken,
774
+ });
775
+ }
776
+ return new Response("ok", { status: 202 });
777
+ });
778
+ globalThis.fetch = fetchMock as typeof fetch;
779
+ const onExperimentViewed = vi.fn();
780
+ const { init } = await import("./index");
781
+
782
+ init({
783
+ antiFlickerMode: "off",
784
+ commerce: mixedCommerce,
785
+ endpoint: "https://events.test",
786
+ experiments: [...experiments, shippingExperiment],
787
+ ingestSignature: "ingest-token",
788
+ onExperimentViewed,
789
+ shopId: "shop_123",
790
+ trackingStart: "immediate",
791
+ userId: "visitor_123",
792
+ });
793
+
794
+ await vi.waitFor(() => {
795
+ expect(fetchMock.mock.calls.some(([url]) => url === "/cart/update.js")).toBe(true);
796
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
797
+ });
798
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
799
+ expect(onExperimentViewed).toHaveBeenCalledWith(
800
+ expect.objectContaining({ experimentId: "exp_price" }),
801
+ );
802
+ expect(onExperimentViewed).not.toHaveBeenCalledWith(
803
+ expect.objectContaining({ experimentId: "exp_shipping" }),
804
+ );
805
+ const cartUpdate = fetchMock.mock.calls.find(([url]) => url === "/cart/update.js");
806
+ const attributes = JSON.parse(String(cartUpdate?.[1]?.body)).attributes;
807
+ expect(JSON.parse(attributes.apex_assignments)).toEqual([
808
+ { e: "exp_price", v: "treatment", ep: 1 },
809
+ ]);
810
+ });
811
+
812
+ it("waits for a matching cart before exposing a scoped display-only shipping arm", async () => {
813
+ const shippingExperiment = {
814
+ id: "exp_shipping_display_only",
815
+ assignmentEpoch: 1,
816
+ status: "running" as const,
817
+ trafficAllocation: 1,
818
+ variations: [{
819
+ id: "shipping_banner",
820
+ weight: 1,
821
+ isControl: false,
822
+ mutations: [],
823
+ }],
824
+ };
825
+ const displayOnlyCommerce = {
826
+ shipping: {
827
+ experiments: [{
828
+ experiment_id: "exp_shipping_display_only",
829
+ assignment_epoch: 1,
830
+ currency: "EUR",
831
+ variants: {
832
+ shipping_banner: {
833
+ threshold: null,
834
+ treatment: "banner" as const,
835
+ progressCopy: "",
836
+ reachedCopy: "",
837
+ bannerCopy: "Shipping update",
838
+ flatRate: null,
839
+ percentOff: null,
840
+ target: {
841
+ productGids: ["gid://shopify/Product/42"],
842
+ variantGids: [],
843
+ },
844
+ },
845
+ },
846
+ }],
847
+ },
848
+ };
849
+ let cartReads = 0;
850
+ fetchMock = vi.fn(async (input: RequestInfo | URL) => {
851
+ const url = String(input);
852
+ if (url.endsWith("/cart.js")) {
853
+ cartReads += 1;
854
+ return Response.json({
855
+ token: "cart-runtime?key=runtime-secret",
856
+ currency: "EUR",
857
+ items_subtotal_price: 2500,
858
+ items: cartReads === 1 ? [] : [{ product_id: 42, variant_id: 7 }],
859
+ });
860
+ }
861
+ return new Response("ok", { status: 202 });
862
+ });
863
+ globalThis.fetch = fetchMock as typeof fetch;
864
+ const onExperimentViewed = vi.fn();
865
+ const { init } = await import("./index");
866
+
867
+ init({
868
+ antiFlickerMode: "off",
869
+ commerce: displayOnlyCommerce,
870
+ endpoint: "https://events.test",
871
+ experiments: [shippingExperiment],
872
+ ingestSignature: "ingest-token",
873
+ onExperimentViewed,
874
+ shopId: "shop_123",
875
+ trackingStart: "immediate",
876
+ userId: "visitor_123",
877
+ });
878
+
879
+ await vi.waitFor(() => expect(cartReads).toBe(1));
880
+ expect(onExperimentViewed).not.toHaveBeenCalled();
881
+
882
+ await window.fetch("/cart/update.js", { method: "POST" });
883
+ await vi.waitFor(() => {
884
+ expect(onExperimentViewed).toHaveBeenCalledWith(
885
+ expect.objectContaining({ experimentId: "exp_shipping_display_only" }),
886
+ );
887
+ });
888
+ expect(onExperimentViewed).toHaveBeenCalledTimes(1);
889
+ });
890
+
891
+ it("uses the assigned treatment arm target instead of a sibling arm target", async () => {
892
+ const experiment = {
893
+ id: "exp_shipping_per_arm",
894
+ assignmentEpoch: 1,
895
+ status: "running" as const,
896
+ trafficAllocation: 1,
897
+ variations: [
898
+ { id: "arm_a", weight: 1, isControl: false, mutations: [] },
899
+ { id: "arm_b", weight: 0, isControl: false, mutations: [] },
900
+ ],
901
+ };
902
+ const target = (product: number) => ({
903
+ productGids: [`gid://shopify/Product/${product}`],
904
+ variantGids: [] as string[],
905
+ });
906
+ const variant = (product: number) => ({
907
+ threshold: null,
908
+ treatment: "banner" as const,
909
+ progressCopy: "",
910
+ reachedCopy: "",
911
+ bannerCopy: "Shipping update",
912
+ flatRate: null,
913
+ percentOff: null,
914
+ target: target(product),
915
+ });
916
+ let product = 43;
917
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
918
+ String(input).endsWith("/cart.js")
919
+ ? Response.json({ currency: "EUR", items_subtotal_price: 2500, items: [{ product_id: product }] })
920
+ : new Response("ok", { status: 202 }));
921
+ globalThis.fetch = fetchMock as typeof fetch;
922
+ const onExperimentViewed = vi.fn();
923
+ const { init } = await import("./index");
924
+
925
+ init({
926
+ antiFlickerMode: "off",
927
+ commerce: { shipping: { experiments: [{
928
+ experiment_id: experiment.id,
929
+ assignment_epoch: 1,
930
+ currency: "EUR",
931
+ variants: { arm_a: variant(42), arm_b: variant(43) },
932
+ }] } },
933
+ endpoint: "https://events.test",
934
+ experiments: [experiment],
935
+ ingestSignature: "ingest-token",
936
+ onExperimentViewed,
937
+ shopId: "shop_123",
938
+ trackingStart: "immediate",
939
+ userId: "visitor_123",
940
+ });
941
+
942
+ await vi.waitFor(() => expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith("/cart.js"))).toBe(true));
943
+ expect(onExperimentViewed).not.toHaveBeenCalled();
944
+ product = 42;
945
+ await window.fetch("/cart/update.js", { method: "POST" });
946
+ await vi.waitFor(() => expect(onExperimentViewed).toHaveBeenCalledTimes(1));
947
+ });
948
+
949
+ it("withholds a scoped shipping control assignment until the experiment target matches", async () => {
950
+ const experiment = {
951
+ id: "exp_shipping_scoped_control",
952
+ assignmentEpoch: 1,
953
+ status: "running" as const,
954
+ trafficAllocation: 1,
955
+ variations: [{ id: "control", weight: 1, isControl: true, mutations: [] }],
956
+ };
957
+ const emptyTarget = { productGids: [] as string[], variantGids: [] as string[] };
958
+ const commerceConfig = {
959
+ shipping: {
960
+ experiments: [{
961
+ experiment_id: experiment.id,
962
+ assignment_epoch: 1,
963
+ currency: "EUR",
964
+ variants: {
965
+ control: {
966
+ threshold: null,
967
+ treatment: "none" as const,
968
+ progressCopy: "",
969
+ reachedCopy: "",
970
+ bannerCopy: "",
971
+ flatRate: null,
972
+ percentOff: null,
973
+ target: emptyTarget,
974
+ },
975
+ treatment: {
976
+ threshold: null,
977
+ treatment: "banner" as const,
978
+ progressCopy: "",
979
+ reachedCopy: "",
980
+ bannerCopy: "Shipping update",
981
+ flatRate: null,
982
+ percentOff: null,
983
+ target: {
984
+ productGids: ["gid://shopify/Product/42"],
985
+ variantGids: [],
986
+ },
987
+ },
988
+ },
989
+ }],
990
+ },
991
+ };
992
+ let cartReads = 0;
993
+ fetchMock = vi.fn(async (input: RequestInfo | URL) => {
994
+ const url = String(input);
995
+ if (url.endsWith("/cart.js")) {
996
+ cartReads += 1;
997
+ return Response.json({
998
+ currency: "EUR",
999
+ items_subtotal_price: 2500,
1000
+ items: cartReads === 1 ? [] : [{ product_id: 42, variant_id: 7 }],
1001
+ });
1002
+ }
1003
+ return new Response("ok", { status: 202 });
1004
+ });
1005
+ globalThis.fetch = fetchMock as typeof fetch;
1006
+ const onExperimentViewed = vi.fn();
1007
+ const { init } = await import("./index");
1008
+
1009
+ init({
1010
+ antiFlickerMode: "off",
1011
+ commerce: commerceConfig,
1012
+ endpoint: "https://events.test",
1013
+ experiments: [experiment],
1014
+ ingestSignature: "ingest-token",
1015
+ onExperimentViewed,
1016
+ shopId: "shop_123",
1017
+ trackingStart: "immediate",
1018
+ userId: "visitor_123",
1019
+ });
1020
+
1021
+ await vi.waitFor(() => expect(cartReads).toBe(1));
1022
+ expect(onExperimentViewed).not.toHaveBeenCalled();
1023
+ await window.fetch("/cart/update.js", { method: "POST" });
1024
+ await vi.waitFor(() => expect(onExperimentViewed).toHaveBeenCalledTimes(1));
1025
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
1026
+ });
1027
+
1028
+ it.each([
1029
+ {
1030
+ name: "withholds a cross-currency currency-bound arm",
1031
+ treatment: "free_shipping" as const,
1032
+ threshold: "50.00",
1033
+ expectedExposures: 0,
1034
+ expectedCartReads: 0,
1035
+ },
1036
+ {
1037
+ name: "exposes a cross-currency thresholdless percent arm",
1038
+ treatment: "percent_off" as const,
1039
+ threshold: null,
1040
+ expectedExposures: 1,
1041
+ expectedCartReads: 1,
1042
+ },
1043
+ ])("$name", async ({ treatment, threshold, expectedExposures, expectedCartReads }) => {
1044
+ const experiment = {
1045
+ id: "exp_shipping_currency",
1046
+ assignmentEpoch: 1,
1047
+ status: "running" as const,
1048
+ trafficAllocation: 1,
1049
+ variations: [{ id: "treatment", weight: 1, isControl: false, mutations: [] }],
1050
+ };
1051
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
1052
+ String(input).endsWith("/cart.js")
1053
+ ? Response.json({ currency: "EUR", items_subtotal_price: 2500, items: [] })
1054
+ : new Response("ok", { status: 202 }));
1055
+ globalThis.fetch = fetchMock as typeof fetch;
1056
+ const onExperimentViewed = vi.fn();
1057
+ const { init } = await import("./index");
1058
+
1059
+ init({
1060
+ antiFlickerMode: "off",
1061
+ commerce: {
1062
+ shipping: {
1063
+ experiments: [{
1064
+ experiment_id: experiment.id,
1065
+ assignment_epoch: 1,
1066
+ currency: "USD",
1067
+ variants: {
1068
+ treatment: {
1069
+ threshold,
1070
+ treatment,
1071
+ progressCopy: "Only {remaining}",
1072
+ reachedCopy: "Reached",
1073
+ bannerCopy: "Shipping update",
1074
+ flatRate: null,
1075
+ percentOff: treatment === "percent_off" ? 10 : null,
1076
+ target: { productGids: [], variantGids: [] },
1077
+ },
1078
+ },
1079
+ }],
1080
+ },
1081
+ },
1082
+ endpoint: "https://events.test",
1083
+ experiments: [experiment],
1084
+ ingestSignature: "ingest-token",
1085
+ onExperimentViewed,
1086
+ shopId: "shop_123",
1087
+ trackingStart: "immediate",
1088
+ userId: "visitor_123",
1089
+ });
1090
+
1091
+ if (expectedCartReads > 0) {
1092
+ await vi.waitFor(() => expect(onExperimentViewed).toHaveBeenCalledTimes(expectedExposures));
1093
+ } else {
1094
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
1095
+ }
1096
+ expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/cart.js"))).toHaveLength(
1097
+ expectedCartReads,
1098
+ );
1099
+ expect(onExperimentViewed).toHaveBeenCalledTimes(expectedExposures);
1100
+ });
1101
+
1102
+ it("exposes a cross-currency control for a thresholdless percent treatment", async () => {
1103
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
1104
+ String(input).endsWith("/cart.js")
1105
+ ? Response.json({ currency: "EUR", items_subtotal_price: 2500, items: [] })
1106
+ : new Response("ok", { status: 202 }));
1107
+ globalThis.fetch = fetchMock as typeof fetch;
1108
+ const onExperimentViewed = vi.fn();
1109
+ const { init } = await import("./index");
1110
+
1111
+ init({
1112
+ antiFlickerMode: "off",
1113
+ commerce: { shipping: { experiments: [{
1114
+ experiment_id: "exp_shipping_percent_control",
1115
+ assignment_epoch: 1,
1116
+ currency: "USD",
1117
+ variants: {
1118
+ control: { threshold: null, treatment: "none", progressCopy: "", reachedCopy: "", bannerCopy: "", flatRate: null, percentOff: null, target: { productGids: [], variantGids: [] } },
1119
+ treatment: { threshold: null, treatment: "percent_off", progressCopy: "", reachedCopy: "", bannerCopy: "Shipping update", flatRate: null, percentOff: 10, target: { productGids: [], variantGids: [] } },
1120
+ },
1121
+ }] } },
1122
+ endpoint: "https://events.test",
1123
+ experiments: [{
1124
+ id: "exp_shipping_percent_control",
1125
+ assignmentEpoch: 1,
1126
+ status: "running",
1127
+ trafficAllocation: 1,
1128
+ variations: [
1129
+ { id: "control", weight: 1, isControl: true, mutations: [] },
1130
+ { id: "treatment", weight: 0, isControl: false, mutations: [] },
1131
+ ],
1132
+ }],
1133
+ ingestSignature: "ingest-token",
1134
+ onExperimentViewed,
1135
+ shopId: "shop_123",
1136
+ trackingStart: "immediate",
1137
+ userId: "visitor_123",
1138
+ });
1139
+
1140
+ await vi.waitFor(() => expect(onExperimentViewed).toHaveBeenCalledTimes(1));
1141
+ });
1142
+
1143
+ it("exposes an untargeted threshold control once the cart currency matches", async () => {
1144
+ const controlCommerce = {
1145
+ shipping: {
1146
+ experiments: [{
1147
+ experiment_id: "exp_shipping_control",
1148
+ assignment_epoch: 1,
1149
+ currency: "EUR",
1150
+ variants: {
1151
+ control_arm: {
1152
+ threshold: null,
1153
+ treatment: "none" as const,
1154
+ progressCopy: "",
1155
+ reachedCopy: "",
1156
+ bannerCopy: "",
1157
+ flatRate: null,
1158
+ percentOff: null,
1159
+ target: { productGids: [], variantGids: [] },
1160
+ },
1161
+ banner_arm: {
1162
+ threshold: "5000",
1163
+ treatment: "banner" as const,
1164
+ progressCopy: "",
1165
+ reachedCopy: "",
1166
+ bannerCopy: "Free shipping at {threshold}",
1167
+ flatRate: null,
1168
+ percentOff: null,
1169
+ target: { productGids: [], variantGids: [] },
1170
+ },
1171
+ },
1172
+ }],
1173
+ },
1174
+ };
1175
+ fetchMock = vi.fn(async (input: RequestInfo | URL) => {
1176
+ const url = String(input);
1177
+ if (url.endsWith("/cart.js")) {
1178
+ return Response.json({
1179
+ token: "cart-1",
1180
+ currency: "EUR",
1181
+ items_subtotal_price: 2500,
1182
+ items: [],
1183
+ });
1184
+ }
1185
+ return new Response("ok", { status: 202 });
1186
+ });
1187
+ globalThis.fetch = fetchMock as typeof fetch;
1188
+ const onExperimentViewed = vi.fn();
1189
+ const { init } = await import("./index");
1190
+
1191
+ init({
1192
+ antiFlickerMode: "off",
1193
+ commerce: controlCommerce,
1194
+ endpoint: "https://events.test",
1195
+ experiments: [{
1196
+ id: "exp_shipping_control",
1197
+ assignmentEpoch: 1,
1198
+ status: "running" as const,
1199
+ trafficAllocation: 1,
1200
+ variations: [
1201
+ { id: "control_arm", weight: 1, isControl: true, mutations: [] },
1202
+ { id: "banner_arm", weight: 0, isControl: false, mutations: [] },
1203
+ ],
1204
+ }],
1205
+ ingestSignature: "ingest-token",
1206
+ onExperimentViewed,
1207
+ shopId: "shop_123",
1208
+ trackingStart: "immediate",
1209
+ userId: "visitor_123",
1210
+ });
1211
+
1212
+ await vi.waitFor(() => {
1213
+ expect(onExperimentViewed).toHaveBeenCalledWith(
1214
+ expect.objectContaining({ experimentId: "exp_shipping_control" }),
1215
+ );
1216
+ });
1217
+ });
1218
+
1219
+ it("keeps plane-inactive price display and exposure behavior unchanged", async () => {
1220
+ const planeInactiveCommerce = {
1221
+ price: {
1222
+ ...commerce.price!,
1223
+ authorization: undefined,
1224
+ },
1225
+ };
1226
+ const onExperimentViewed = vi.fn();
1227
+ const { init } = await import("./index");
1228
+
1229
+ init({
1230
+ antiFlickerMode: "off",
1231
+ commerce: planeInactiveCommerce,
1232
+ endpoint: "https://events.test",
1233
+ experiments,
1234
+ ingestSignature: "ingest-token",
1235
+ onExperimentViewed,
1236
+ shopId: "shop_123",
1237
+ trackingStart: "immediate",
1238
+ userId: "visitor_123",
1239
+ });
1240
+
1241
+ await vi.waitFor(() => {
1242
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
1243
+ expect(onExperimentViewed).toHaveBeenCalledWith(
1244
+ expect.objectContaining({
1245
+ experimentId: "exp_price",
1246
+ variationId: "treatment",
1247
+ }),
1248
+ );
1249
+ });
1250
+ });
1251
+
1252
+ it("does not remove an active price swap when analytics stops", async () => {
1253
+ const { init, setConsent } = await import("./index");
1254
+ init({
1255
+ antiFlickerMode: "off",
1256
+ commerce,
1257
+ consentMode: { enabled: true },
1258
+ debug: { enabled: true, restrictMode: "always" },
1259
+ endpoint: "https://events.test",
1260
+ experiments,
1261
+ hasConsent: true,
1262
+ ingestSignature: "ingest-token",
1263
+ shopId: "shop_123",
1264
+ trackingStart: "immediate",
1265
+ });
1266
+ await vi.waitFor(() => {
1267
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
1268
+ });
1269
+
1270
+ setConsent(false);
1271
+
1272
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
1273
+ });
1274
+
1275
+ it("stamps a commerce cohort despite blocked tracking without starting analytics", async () => {
1276
+ const publish = vi.fn();
1277
+ (window as any).Shopify.analytics = { publish };
1278
+ const { getDiagnostics, init } = await import("./index");
1279
+
1280
+ init({
1281
+ antiFlickerMode: "off",
1282
+ commerce,
1283
+ consentMode: { enabled: true },
1284
+ debug: { enabled: true, restrictMode: "always" },
1285
+ endpoint: "https://events.test",
1286
+ exclusionGroups: [{
1287
+ id: "group_commerce",
1288
+ epoch: 3,
1289
+ members: [{ experimentId: "exp_price", share: 1 }],
1290
+ }],
1291
+ experiments,
1292
+ hasConsent: false,
1293
+ ingestSignature: "ingest-token",
1294
+ shopId: "shop_123",
1295
+ trackingStart: "immediate",
1296
+ });
1297
+
1298
+ await vi.waitFor(() => {
1299
+ expect(fetchMock.mock.calls.some(([url]) => url === "/cart/update.js"))
1300
+ .toBe(true);
1301
+ });
1302
+ expect(getDiagnostics().trackingBlocked).toBe(true);
1303
+ expect(getDiagnostics().analyticsStarted).toBe(false);
1304
+ expect(publish).not.toHaveBeenCalled();
1305
+ expect(fetchMock.mock.calls.some(([url]) =>
1306
+ new URL(String(url), location.href).pathname === "/events"))
1307
+ .toBe(false);
1308
+ const assignRequest = fetchMock.mock.calls.find(([url]) =>
1309
+ String(url).includes("/v1/assign")
1310
+ );
1311
+ expect(JSON.parse(String(assignRequest?.[1]?.body))).toMatchObject({
1312
+ exclusion_group_selections: [{
1313
+ group_id: "group_commerce",
1314
+ experiment_id: "exp_price",
1315
+ epoch: 3,
1316
+ }],
1317
+ });
1318
+ });
1319
+
1320
+ it("keeps blocked non-commerce assignments unchanged and performs no sync", async () => {
1321
+ const publish = vi.fn();
1322
+ (window as any).Shopify.analytics = { publish };
1323
+ const { getDiagnostics, init } = await import("./index");
1324
+
1325
+ init({
1326
+ antiFlickerMode: "off",
1327
+ consentMode: { enabled: true },
1328
+ debug: { enabled: true, restrictMode: "always" },
1329
+ endpoint: "https://events.test",
1330
+ experiments,
1331
+ hasConsent: false,
1332
+ shopId: "shop_123",
1333
+ trackingStart: "immediate",
1334
+ });
1335
+ await Promise.resolve();
1336
+
1337
+ expect(getDiagnostics().trackingBlocked).toBe(true);
1338
+ expect(fetchMock).not.toHaveBeenCalled();
1339
+ expect(publish).not.toHaveBeenCalled();
1340
+ });
1341
+
1342
+ it("force re-stamps the cohort after a successful cart add", async () => {
1343
+ const { getDiagnostics, init } = await import("./index");
1344
+ init({
1345
+ antiFlickerMode: "off",
1346
+ commerce,
1347
+ endpoint: "https://events.test",
1348
+ experiments,
1349
+ ingestSignature: "ingest-token",
1350
+ shopId: "shop_123",
1351
+ trackingStart: "immediate",
1352
+ });
1353
+ const cartWrites = () => fetchMock.mock.calls
1354
+ .filter(([url]) => url === "/cart/update.js");
1355
+ await vi.waitFor(() => expect(cartWrites().length).toBeGreaterThan(0));
1356
+ const writesBeforeAdd = cartWrites().length;
1357
+
1358
+ await window.fetch("/cart/add.js", { method: "POST" });
1359
+ await vi.waitFor(() => expect(cartWrites()).toHaveLength(writesBeforeAdd + 1));
1360
+
1361
+ const completedWrites = cartWrites();
1362
+ const body = JSON.parse(String(completedWrites[completedWrites.length - 1]?.[1]?.body));
1363
+ expect(JSON.parse(body.attributes.apex_assignments)).toEqual([
1364
+ expect.objectContaining({ e: "exp_price", v: "treatment" }),
1365
+ ]);
1366
+ expect(getDiagnostics().spa.lastShopifyCartSync?.status).toBe("sent");
1367
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
1368
+ });
1369
+
1370
+ it("adopts a mismatching cart pin into runtime and displayed price state", async () => {
1371
+ const controlFirstExperiments = [{
1372
+ ...experiments[0],
1373
+ variations: [
1374
+ { id: "control", weight: 1, isControl: true, mutations: [] },
1375
+ { ...experiments[0].variations[0], weight: 0 },
1376
+ ],
1377
+ }];
1378
+ const { getDiagnostics, init } = await import("./index");
1379
+
1380
+ init({
1381
+ antiFlickerMode: "off",
1382
+ commerce,
1383
+ endpoint: "https://events.test",
1384
+ experiments: controlFirstExperiments,
1385
+ ingestSignature: "ingest-token",
1386
+ shopId: "shop_123",
1387
+ trackingStart: "immediate",
1388
+ userId: "visitor_123",
1389
+ });
1390
+
1391
+ await vi.waitFor(() => {
1392
+ expect(getDiagnostics().assignments).toEqual([
1393
+ expect.objectContaining({
1394
+ experimentId: "exp_price",
1395
+ variationId: "treatment",
1396
+ isControl: false,
1397
+ }),
1398
+ ]);
1399
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
1400
+ });
1401
+ await vi.waitFor(() => {
1402
+ expect(fetchMock.mock.calls.some(([url]) => url === "/cart/update.js")).toBe(true);
1403
+ });
1404
+ const cartUpdate = fetchMock.mock.calls.find(([url]) => url === "/cart/update.js");
1405
+ const attributes = JSON.parse(String(cartUpdate?.[1]?.body)).attributes;
1406
+ expect(JSON.parse(attributes.apex_assignments)).toEqual([{
1407
+ e: "exp_price",
1408
+ v: "treatment",
1409
+ ep: 1,
1410
+ }]);
1411
+ expect(JSON.parse(localStorage.getItem("drip_assignments_v1") ?? "{}")).toMatchObject({
1412
+ exp_price: ["treatment", 1, expect.any(Number)],
1413
+ });
1414
+ });
1415
+
1416
+ });