@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
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { isHoldoutVisitor } from "./hash";
2
3
 
3
4
  function makeStorage() {
4
5
  const map = new Map<string, string>();
@@ -84,6 +85,7 @@ describe("async config fetch contract", () => {
84
85
  const originalLocalStorage = (globalThis as any).localStorage;
85
86
  const originalSessionStorage = (globalThis as any).sessionStorage;
86
87
  const originalCustomEvent = (globalThis as any).CustomEvent;
88
+ const originalMutationObserver = (globalThis as any).MutationObserver;
87
89
  const originalFetch = globalThis.fetch;
88
90
  const originalAbortController = (globalThis as any).AbortController;
89
91
 
@@ -93,7 +95,12 @@ describe("async config fetch contract", () => {
93
95
  vi.setSystemTime(0);
94
96
  delete (globalThis as any).__drip;
95
97
 
96
- const appended: Array<{ id?: string; remove?: () => void }> = [];
98
+ const appended: Array<{
99
+ className?: string;
100
+ id?: string;
101
+ remove?: () => void;
102
+ textContent?: string;
103
+ }> = [];
97
104
  const windowStub: Record<string, any> = {
98
105
  addEventListener: vi.fn(),
99
106
  clearInterval: (id: number) => clearInterval(id),
@@ -122,12 +129,25 @@ describe("async config fetch contract", () => {
122
129
  (globalThis as any).document = {
123
130
  addEventListener: vi.fn(),
124
131
  body: { scrollHeight: 1200 },
125
- createElement: vi.fn((tag: string) => ({
126
- id: "",
127
- remove: vi.fn(),
128
- set textContent(_value: string) {},
129
- tagName: tag.toUpperCase(),
130
- })),
132
+ createElement: vi.fn((tag: string) => {
133
+ const element: {
134
+ className: string;
135
+ id: string;
136
+ remove: () => void;
137
+ tagName: string;
138
+ textContent: string;
139
+ } = {
140
+ className: "",
141
+ id: "",
142
+ remove: vi.fn(() => {
143
+ const index = appended.indexOf(element);
144
+ if (index >= 0) appended.splice(index, 1);
145
+ }),
146
+ tagName: tag.toUpperCase(),
147
+ textContent: "",
148
+ };
149
+ return element;
150
+ }),
131
151
  dispatchEvent: vi.fn(),
132
152
  documentElement: {
133
153
  classList: makeClassList(),
@@ -141,7 +161,13 @@ describe("async config fetch contract", () => {
141
161
  }),
142
162
  },
143
163
  querySelector: vi.fn(() => null),
144
- querySelectorAll: vi.fn(() => []),
164
+ querySelectorAll: vi.fn((selector: string) => {
165
+ if (!selector.startsWith(".")) return [];
166
+ const className = selector.slice(1);
167
+ return appended.filter((element) =>
168
+ String(element.className ?? "").split(/\s+/).includes(className),
169
+ );
170
+ }),
145
171
  referrer: "",
146
172
  removeEventListener: vi.fn(),
147
173
  title: "Plain probe",
@@ -160,6 +186,11 @@ describe("async config fetch contract", () => {
160
186
  this.detail = init?.detail;
161
187
  }
162
188
  };
189
+ (globalThis as any).MutationObserver = class MutationObserver {
190
+ observe() {}
191
+ disconnect() {}
192
+ constructor(_callback: MutationCallback) {}
193
+ };
163
194
  globalThis.fetch = vi.fn(async () => new Response("ok", { status: 202 })) as any;
164
195
  });
165
196
 
@@ -179,8 +210,10 @@ describe("async config fetch contract", () => {
179
210
  (globalThis as any).localStorage = originalLocalStorage;
180
211
  (globalThis as any).sessionStorage = originalSessionStorage;
181
212
  (globalThis as any).CustomEvent = originalCustomEvent;
213
+ (globalThis as any).MutationObserver = originalMutationObserver;
182
214
  (globalThis as any).AbortController = originalAbortController;
183
215
  globalThis.fetch = originalFetch;
216
+ vi.unstubAllGlobals();
184
217
  });
185
218
 
186
219
  it("bounds fetchWithTimeout when AbortController is unavailable", async () => {
@@ -220,7 +253,69 @@ describe("async config fetch contract", () => {
220
253
  expect(aborts).toEqual([700]);
221
254
  });
222
255
 
256
+ it("keeps the intended fetch budget when a late boot cannot install a barrier", async () => {
257
+ vi.stubGlobal("performance", {
258
+ now: () => 500,
259
+ getEntriesByType: () => [{ startTime: 0, domContentLoadedEventEnd: 100 }],
260
+ });
261
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
262
+ const aborts: number[] = [];
263
+ globalThis.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) =>
264
+ new Promise<Response>((_resolve, reject) => {
265
+ init?.signal?.addEventListener("abort", () => {
266
+ aborts.push(Date.now());
267
+ reject(new DOMException("The operation was aborted.", "AbortError"));
268
+ });
269
+ })) as any;
270
+ const { init } = await import("./index");
271
+
272
+ init({
273
+ antiFlickerMode: "full",
274
+ antiFlickerTimeoutMs: 700,
275
+ debug: { enabled: true, restrictMode: "always" },
276
+ endpoint: "https://events.test",
277
+ shopId: "shop_123",
278
+ });
279
+
280
+ expect(document.getElementById("drip-antiflicker")).toBeNull();
281
+ expect(logSpy.mock.calls.some(([message]) =>
282
+ String(message).includes("loaded after the pre-paint window"),
283
+ )).toBe(true);
284
+ await vi.advanceTimersByTimeAsync(700);
285
+ expect(aborts).toEqual([700]);
286
+ });
287
+
288
+ it("latches an early boot so the post-fetch re-arm still installs a barrier", async () => {
289
+ vi.stubGlobal("performance", {
290
+ now: () => Date.now(),
291
+ getEntriesByType: () => [{ startTime: 0, domContentLoadedEventEnd: 100 }],
292
+ });
293
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
294
+ if (!String(input).includes("/config")) {
295
+ return Promise.resolve(new Response("ok", { status: 202 }));
296
+ }
297
+ return new Promise<Response>((resolve) => {
298
+ setTimeout(() => resolve(new Response(JSON.stringify(configPayload()), { status: 200 })), 500);
299
+ });
300
+ }) as any;
301
+ const { init } = await import("./index");
302
+
303
+ init({
304
+ antiFlickerMode: "full",
305
+ antiFlickerTimeoutMs: 1_000,
306
+ endpoint: "https://events.test",
307
+ shopId: "shop_123",
308
+ });
309
+ expect(document.head.appendChild).toHaveBeenCalledTimes(1);
310
+
311
+ await vi.advanceTimersByTimeAsync(500);
312
+ await flushPromises(10);
313
+
314
+ expect(document.head.appendChild).toHaveBeenCalledTimes(2);
315
+ });
316
+
223
317
  it("skips applying late config by default but writes the config cache", async () => {
318
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
224
319
  let configCalls = 0;
225
320
  globalThis.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
226
321
  if (!String(input).includes("/config")) {
@@ -243,8 +338,9 @@ describe("async config fetch contract", () => {
243
338
  const { getDiagnostics, init } = await import("./index");
244
339
 
245
340
  init({
246
- antiFlickerMode: "full",
341
+ antiFlickerMode: "scoped",
247
342
  antiFlickerTimeoutMs: 300,
343
+ debug: { enabled: true, restrictMode: "always" },
248
344
  endpoint: "https://events.test",
249
345
  shopId: "shop_123",
250
346
  });
@@ -258,6 +354,9 @@ describe("async config fetch contract", () => {
258
354
  expect(getDiagnostics().assignments).toHaveLength(0);
259
355
  expect(getDiagnostics().initialized).toBe(false);
260
356
  expect(getDiagnostics().load.stage).toBe("late_config_skip");
357
+ expect(logSpy.mock.calls.some(([message]) =>
358
+ String(message).includes("loaded after the pre-paint window"),
359
+ )).toBe(false);
261
360
  expect(document.dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
262
361
  type: "drip_runtime_status",
263
362
  detail: expect.objectContaining({ stage: "late_config_skip", configFetchMs: 850 }),
@@ -322,4 +421,376 @@ describe("async config fetch contract", () => {
322
421
  expect(getDiagnostics().configSource).toBe("network");
323
422
  expect(getDiagnostics().load.stage).toBe("assignments_ready");
324
423
  });
424
+
425
+ it("applies holdout gating and cohort tagging from a fetched config", async () => {
426
+ const holdoutConfigUpdatedAt = "2026-07-27T08:30:00.000Z";
427
+ const shopifyPublish = vi.fn();
428
+ (globalThis as any).window.Shopify = {
429
+ analytics: { publish: shopifyPublish },
430
+ };
431
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
432
+ if (String(input).includes("/config")) {
433
+ return Promise.resolve(new Response(JSON.stringify({
434
+ ...configPayload(),
435
+ holdout_v1_enabled: true,
436
+ holdout_pct: 10,
437
+ holdout_config_updated_at: holdoutConfigUpdatedAt,
438
+ }), { status: 200 }));
439
+ }
440
+ return Promise.resolve(new Response("ok", { status: 202 }));
441
+ }) as any;
442
+ const { flush, getDiagnostics, init, track } = await import("./index");
443
+ let visitorId = "holdout-config-0";
444
+ while (!isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, visitorId)) {
445
+ visitorId = `holdout-config-${Number(visitorId.split("-").at(-1)) + 1}`;
446
+ }
447
+
448
+ init({ antiFlickerMode: "off", endpoint: "https://events.test", shopId: "shop_123", trackingStart: "immediate", userId: visitorId });
449
+ await waitFor(() => getDiagnostics().configSource === "network");
450
+ track("holdout_probe");
451
+ await flush();
452
+
453
+ expect(getDiagnostics().assignments).toHaveLength(0);
454
+ const eventsRequest = vi.mocked(globalThis.fetch).mock.calls.find(
455
+ (call) => String(call[0]).endsWith("/events"),
456
+ );
457
+ const events = JSON.parse(String(eventsRequest?.[1]?.body)).events;
458
+ expect(events.find((event: { type: string }) => event.type === "holdout_probe").data.cohort).toBe("holdout");
459
+ const cached = JSON.parse(String(localStorage.getItem("drip_cfg:shop_123:production")));
460
+ expect(cached.holdout).toEqual([true, 10, holdoutConfigUpdatedAt]);
461
+ expect(shopifyPublish).toHaveBeenCalledWith(
462
+ "drip_assignment_sync",
463
+ expect.objectContaining({
464
+ cohort: "holdout",
465
+ holdoutConfigEpoch: Date.parse(holdoutConfigUpdatedAt),
466
+ }),
467
+ );
468
+ });
469
+
470
+ it("restores the holdout configuration epoch from the config cache", async () => {
471
+ const holdoutConfigUpdatedAt = "2026-07-27T08:30:00.000Z";
472
+ const shopifyPublish = vi.fn();
473
+ (globalThis as any).window.Shopify = {
474
+ analytics: { publish: shopifyPublish },
475
+ };
476
+ let visitorId = "cached-epoch-holdout-0";
477
+ while (!isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, visitorId)) {
478
+ visitorId = `cached-epoch-holdout-${Number(visitorId.split("-").at(-1)) + 1}`;
479
+ }
480
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
481
+ fetchedAt: Date.now(),
482
+ hash: "cached-holdout-epoch",
483
+ experiments: [],
484
+ holdout: [true, 10, holdoutConfigUpdatedAt],
485
+ }));
486
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
487
+ if (String(input).includes("/config")) {
488
+ return new Promise<Response>(() => {});
489
+ }
490
+ return Promise.resolve(new Response("ok", { status: 202 }));
491
+ }) as any;
492
+ const { flush, getDiagnostics, init, track } = await import("./index");
493
+
494
+ init({
495
+ antiFlickerMode: "off",
496
+ endpoint: "https://events.test",
497
+ shopId: "shop_123",
498
+ trackingStart: "immediate",
499
+ userId: visitorId,
500
+ });
501
+
502
+ expect(getDiagnostics().configSource).toBe("cache");
503
+ track("cached_holdout_epoch_probe");
504
+ await flush();
505
+ const eventsRequest = vi.mocked(globalThis.fetch).mock.calls.find(
506
+ (call) => String(call[0]).endsWith("/events"),
507
+ );
508
+ const events = JSON.parse(String(eventsRequest?.[1]?.body)).events;
509
+ expect(events.find((event: { type: string }) =>
510
+ event.type === "cached_holdout_epoch_probe"
511
+ ).data).toMatchObject({
512
+ cohort: "holdout",
513
+ holdout_config_epoch: Date.parse(holdoutConfigUpdatedAt),
514
+ });
515
+ expect(shopifyPublish).toHaveBeenCalledWith(
516
+ "drip_assignment_sync",
517
+ expect.objectContaining({
518
+ cohort: "holdout",
519
+ holdoutConfigEpoch: Date.parse(holdoutConfigUpdatedAt),
520
+ }),
521
+ );
522
+ });
523
+
524
+ it("hydrates cached geography before evaluating country targeting", async () => {
525
+ const countryExperiment = {
526
+ id: "exp_cached_country",
527
+ status: "running",
528
+ trafficAllocation: 1,
529
+ targeting: {
530
+ segmentRules: [{
531
+ include: true,
532
+ rules: {
533
+ conditions: [{ signal: "geo.country", operator: "equals", value: "DE" }],
534
+ },
535
+ }],
536
+ },
537
+ variations: [{ id: "var_cached_country", weight: 1, mutations: [] }],
538
+ };
539
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
540
+ fetchedAt: Date.now(),
541
+ hash: "cached-country-config",
542
+ experiments: [countryExperiment],
543
+ geo: { country: "DE" },
544
+ }));
545
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
546
+ if (String(input).includes("/config")) return new Promise<Response>(() => {});
547
+ return Promise.resolve(new Response("ok", { status: 202 }));
548
+ }) as any;
549
+ const { getDiagnostics, init } = await import("./index");
550
+
551
+ init({
552
+ antiFlickerMode: "off",
553
+ endpoint: "https://events.test",
554
+ shopId: "shop_123",
555
+ userId: "cached-country-visitor",
556
+ });
557
+
558
+ expect(getDiagnostics().configSource).toBe("cache");
559
+ expect(getDiagnostics().assignments).toEqual([
560
+ expect.objectContaining({ experimentId: "exp_cached_country" }),
561
+ ]);
562
+ });
563
+
564
+ it("keeps fresh request geography when cached geography differs", async () => {
565
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
566
+ fetchedAt: Date.now(),
567
+ hash: "cached-country-config",
568
+ experiments: [{
569
+ id: "exp_fresh_country",
570
+ status: "running",
571
+ trafficAllocation: 1,
572
+ targeting: {
573
+ segmentRules: [{
574
+ include: true,
575
+ rules: {
576
+ conditions: [{ signal: "geo.country", operator: "equals", value: "US" }],
577
+ },
578
+ }],
579
+ },
580
+ variations: [{ id: "var_fresh_country", weight: 1, mutations: [] }],
581
+ }],
582
+ geo: { country: "DE" },
583
+ }));
584
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
585
+ if (String(input).includes("/config")) return new Promise<Response>(() => {});
586
+ return Promise.resolve(new Response("ok", { status: 202 }));
587
+ }) as any;
588
+ const { getDiagnostics, getSignals, init } = await import("./index");
589
+
590
+ init({
591
+ antiFlickerMode: "off",
592
+ endpoint: "https://events.test",
593
+ geo: { country: "US" },
594
+ shopId: "shop_123",
595
+ userId: "fresh-country-visitor",
596
+ });
597
+
598
+ expect(getDiagnostics().configSource).toBe("cache");
599
+ expect(getSignals({ markVisitorSeen: false })["geo.country"]).toBe("US");
600
+ expect(getDiagnostics().assignments).toEqual([
601
+ expect.objectContaining({ experimentId: "exp_fresh_country" }),
602
+ ]);
603
+ });
604
+
605
+ it("keeps legacy cached envelopes without geography compatible", async () => {
606
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
607
+ fetchedAt: Date.now(),
608
+ hash: "legacy-cached-country-config",
609
+ experiments: [{
610
+ id: "exp_legacy_cached_country",
611
+ status: "running",
612
+ trafficAllocation: 1,
613
+ targeting: {
614
+ segmentRules: [{
615
+ include: true,
616
+ rules: {
617
+ conditions: [{ signal: "geo.country", operator: "equals", value: "DE" }],
618
+ },
619
+ }],
620
+ },
621
+ variations: [{ id: "var_legacy_cached_country", weight: 1, mutations: [] }],
622
+ }],
623
+ }));
624
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
625
+ if (String(input).includes("/config")) return new Promise<Response>(() => {});
626
+ return Promise.resolve(new Response("ok", { status: 202 }));
627
+ }) as any;
628
+ const { getDiagnostics, init } = await import("./index");
629
+
630
+ expect(() => init({
631
+ antiFlickerMode: "off",
632
+ endpoint: "https://events.test",
633
+ shopId: "shop_123",
634
+ userId: "legacy-cached-country-visitor",
635
+ })).not.toThrow();
636
+ expect(getDiagnostics().configSource).toBe("cache");
637
+ expect(getDiagnostics().assignments).toHaveLength(0);
638
+ });
639
+
640
+ it("synchronously gates a holdout visitor before applying cached treatments or project code", async () => {
641
+ const target = { textContent: "Control" };
642
+ const querySelectorAll = document.querySelectorAll.bind(document);
643
+ document.querySelectorAll = vi.fn((selector: string) => (
644
+ selector === "#cached-title"
645
+ ? [target]
646
+ : querySelectorAll(selector)
647
+ )) as typeof document.querySelectorAll;
648
+
649
+ let visitorId = "cached-holdout-0";
650
+ while (!isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, visitorId)) {
651
+ visitorId = `cached-holdout-${Number(visitorId.split("-").at(-1)) + 1}`;
652
+ }
653
+ const cachedExperiments = [{
654
+ id: "exp_cached_holdout",
655
+ status: "running",
656
+ trafficAllocation: 1,
657
+ js: "window.__cachedHoldoutExperiment = true;",
658
+ variations: [{
659
+ id: "var_cached_holdout",
660
+ weight: 1,
661
+ mutations: [{
662
+ selector: "#cached-title",
663
+ action: "text",
664
+ text: "Cached treatment",
665
+ }],
666
+ css: "#cached-title { color: red; }",
667
+ js: "window.__cachedHoldoutTreatment = true;",
668
+ }],
669
+ }];
670
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
671
+ fetchedAt: Date.now(),
672
+ hash: "cached-holdout-config",
673
+ experiments: cachedExperiments,
674
+ runtime: {
675
+ projectCode: {
676
+ enabled: true,
677
+ css: "body { outline: 1px solid red; }",
678
+ helperJs: "window.__cachedHoldoutProjectHelper = true;",
679
+ js: "window.__cachedHoldoutProjectStartup = true;",
680
+ },
681
+ },
682
+ holdout: [true, 10],
683
+ }));
684
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
685
+ if (String(input).includes("/config")) {
686
+ return new Promise<Response>(() => {});
687
+ }
688
+ return Promise.resolve(new Response("ok", { status: 202 }));
689
+ }) as any;
690
+ const { getDiagnostics, init } = await import("./index");
691
+
692
+ const assignments = init({
693
+ antiFlickerMode: "off",
694
+ endpoint: "https://events.test",
695
+ shopId: "shop_123",
696
+ userId: visitorId,
697
+ });
698
+
699
+ expect(assignments).toHaveLength(0);
700
+ expect(getDiagnostics().configSource).toBe("cache");
701
+ expect(target.textContent).toBe("Control");
702
+ expect((window as any).__cachedHoldoutTreatment).toBeUndefined();
703
+ expect((window as any).__cachedHoldoutExperiment).toBeUndefined();
704
+ expect((window as any).__cachedHoldoutProjectHelper).toBeUndefined();
705
+ expect((window as any).__cachedHoldoutProjectStartup).toBeUndefined();
706
+ expect(document.getElementById("drip-css-exp_cached_holdout-var_cached")).toBeNull();
707
+ expect(document.getElementById("drip-project-css")).toBeNull();
708
+ });
709
+
710
+ it("tears down cached treatments when the network config moves the visitor into holdout", async () => {
711
+ const target = { textContent: "Control" };
712
+ const querySelectorAll = document.querySelectorAll.bind(document);
713
+ document.querySelectorAll = vi.fn((selector: string) => (
714
+ selector === "#cached-title"
715
+ ? [target]
716
+ : querySelectorAll(selector)
717
+ )) as typeof document.querySelectorAll;
718
+
719
+ let visitorId = "cached-network-holdout-0";
720
+ while (!isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, visitorId)) {
721
+ visitorId = `cached-network-holdout-${Number(visitorId.split("-").at(-1)) + 1}`;
722
+ }
723
+ const cachedExperiments = [{
724
+ id: "exp_cached",
725
+ status: "running",
726
+ trafficAllocation: 1,
727
+ js: "window.__cachedExperiment = true;",
728
+ resetJs: "window.__cachedExperiment = false;",
729
+ variations: [{
730
+ id: "var_cached",
731
+ weight: 1,
732
+ mutations: [{
733
+ selector: "#cached-title",
734
+ action: "text",
735
+ text: "Cached treatment",
736
+ }],
737
+ css: "#cached-title { color: red; }",
738
+ js: "window.__cachedTreatment = true;",
739
+ resetJs: "window.__cachedTreatment = false;",
740
+ }],
741
+ }];
742
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
743
+ fetchedAt: Date.now(),
744
+ hash: "cached-active-config",
745
+ experiments: cachedExperiments,
746
+ runtime: {
747
+ projectCode: {
748
+ enabled: true,
749
+ css: "body { outline: 1px solid red; }",
750
+ helperJs: "window.__cachedProjectHelper = true;",
751
+ js: "window.__cachedProjectStartup = true;",
752
+ },
753
+ },
754
+ holdout: [false, 0],
755
+ }));
756
+ let resolveConfig!: (response: Response) => void;
757
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
758
+ if (String(input).includes("/config")) {
759
+ return new Promise<Response>((resolve) => {
760
+ resolveConfig = resolve;
761
+ });
762
+ }
763
+ return Promise.resolve(new Response("ok", { status: 202 }));
764
+ }) as any;
765
+ const { getDiagnostics, init } = await import("./index");
766
+
767
+ init({
768
+ antiFlickerMode: "off",
769
+ endpoint: "https://events.test",
770
+ lateConfigPolicy: "apply",
771
+ shopId: "shop_123",
772
+ userId: visitorId,
773
+ });
774
+ await flushPromises();
775
+
776
+ expect(target.textContent).toBe("Cached treatment");
777
+ expect((window as any).__cachedTreatment).toBe(true);
778
+ expect((window as any).__cachedExperiment).toBe(true);
779
+ expect(document.getElementById("drip-css-exp_cached-var_cached")).not.toBeNull();
780
+ expect(document.getElementById("drip-project-css")).not.toBeNull();
781
+
782
+ resolveConfig(new Response(JSON.stringify({
783
+ experiments: cachedExperiments,
784
+ holdout_v1_enabled: true,
785
+ holdout_pct: 10,
786
+ }), { status: 200 }));
787
+ await waitFor(() => getDiagnostics().configSource === "network");
788
+
789
+ expect(getDiagnostics().assignments).toHaveLength(0);
790
+ expect(target.textContent).toBe("Control");
791
+ expect((window as any).__cachedTreatment).toBe(false);
792
+ expect((window as any).__cachedExperiment).toBe(false);
793
+ expect(document.getElementById("drip-css-exp_cached-var_cached")).toBeNull();
794
+ expect(document.getElementById("drip-project-css")).toBeNull();
795
+ });
325
796
  });