@drip-apex/sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19283 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19267 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16636 -13533
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +28 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2071 -0
  128. package/src/consent-evaluation-borlabs.test.ts +41 -0
  129. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  130. package/src/consent-evaluation.ts +151 -0
  131. package/src/css.test.ts +30 -0
  132. package/src/css.ts +120 -6
  133. package/src/cwv.test.ts +60 -77
  134. package/src/debug-loader.test.ts +214 -0
  135. package/src/debug-loader.ts +41 -0
  136. package/src/decide-assignments.test.ts +196 -0
  137. package/src/decide-assignments.ts +204 -0
  138. package/src/delivery-timing.test.ts +104 -0
  139. package/src/delivery-timing.ts +89 -0
  140. package/src/early-interaction-capture.test.ts +451 -0
  141. package/src/early-interaction-capture.ts +230 -0
  142. package/src/early-reassert.test.ts +207 -0
  143. package/src/early-reassert.ts +152 -0
  144. package/src/edge-adoption.test.ts +435 -0
  145. package/src/edge-runtime.ts +30 -0
  146. package/src/editor/api.test.ts +109 -0
  147. package/src/editor/api.ts +34 -3
  148. package/src/editor/controller.test.ts +297 -14
  149. package/src/editor/controller.ts +69 -5
  150. package/src/editor/index.test.ts +2 -0
  151. package/src/editor/index.ts +1 -0
  152. package/src/editor/publication-result.ts +58 -0
  153. package/src/editor/types.ts +10 -1
  154. package/src/editor/view-kit.ts +2 -1
  155. package/src/editor/view.test.ts +108 -0
  156. package/src/editor/view.ts +49 -7
  157. package/src/endpoint.test.ts +15 -0
  158. package/src/endpoint.ts +10 -0
  159. package/src/exclusion-groups.ts +8 -18
  160. package/src/flags.test.ts +88 -1
  161. package/src/flags.ts +29 -29
  162. package/src/global-runtime-reconciler.test.ts +271 -0
  163. package/src/goal-eligibility.test.ts +29 -0
  164. package/src/goal-eligibility.ts +17 -0
  165. package/src/goals.test.ts +144 -1
  166. package/src/goals.ts +97 -18
  167. package/src/guards.test.ts +99 -1
  168. package/src/guards.ts +34 -15
  169. package/src/handover-cache-order.test.ts +6 -3
  170. package/src/handover.test.ts +154 -8
  171. package/src/handover.ts +122 -12
  172. package/src/hash.ts +29 -54
  173. package/src/holdout.test.ts +26 -0
  174. package/src/hydration.test.ts +73 -2
  175. package/src/hydration.ts +18 -9
  176. package/src/index.ts +130 -7287
  177. package/src/init-analytics.test.ts +1694 -0
  178. package/src/init-anti-flicker.test.ts +1211 -0
  179. package/src/init-cart-attributes.test.ts +2003 -0
  180. package/src/init-config-resolution.test.ts +999 -0
  181. package/src/init-consent.test.ts +971 -0
  182. package/src/init-empty.test.ts +11 -5406
  183. package/src/init-integrations.test.ts +846 -0
  184. package/src/init-page-activation.test.ts +5064 -0
  185. package/src/init-qa-loading.test.ts +740 -0
  186. package/src/init-redirects.test.ts +2156 -0
  187. package/src/init-test-helpers.ts +287 -0
  188. package/src/inline-bootstrap.test.ts +184 -0
  189. package/src/inline-bootstrap.ts +73 -0
  190. package/src/js.test.ts +51 -1
  191. package/src/js.ts +86 -2
  192. package/src/money.test.ts +55 -1
  193. package/src/money.ts +104 -2
  194. package/src/money.web-parity.contract.test.ts +85 -0
  195. package/src/mutate.test.ts +474 -10
  196. package/src/mutate.ts +739 -230
  197. package/src/page-trigger.test.ts +218 -8
  198. package/src/performance.ts +10 -0
  199. package/src/persistence-decoders.ts +18 -0
  200. package/src/public-api.ts +1828 -0
  201. package/src/qa-assistant-state.test.ts +249 -3
  202. package/src/qa-assistant-state.ts +146 -6
  203. package/src/qa-assistant-view.test.ts +220 -2
  204. package/src/qa-assistant-view.ts +102 -5
  205. package/src/react/index.test.tsx +37 -0
  206. package/src/react/index.tsx +19 -3
  207. package/src/reassert-sentinel.test.ts +155 -1
  208. package/src/reassert-sentinel.ts +168 -8
  209. package/src/redirect.test.ts +541 -0
  210. package/src/redirect.ts +572 -0
  211. package/src/replay-recorder-entry.ts +115 -0
  212. package/src/replay-signal-state.ts +100 -0
  213. package/src/replay-signals.test.ts +249 -0
  214. package/src/replay-signals.ts +151 -0
  215. package/src/replay.test.ts +764 -0
  216. package/src/replay.ts +514 -0
  217. package/src/router.test.ts +61 -2
  218. package/src/router.ts +50 -12
  219. package/src/runtime-state.ts +507 -0
  220. package/src/runtime.test.ts +174 -7
  221. package/src/runtime.ts +88 -14
  222. package/src/sanitize.test.ts +34 -0
  223. package/src/sanitize.ts +23 -12
  224. package/src/shadow.test.ts +187 -0
  225. package/src/shadow.ts +99 -0
  226. package/src/shopify-cart.test.ts +733 -23
  227. package/src/shopify-cart.ts +402 -49
  228. package/src/storefront-vitals.test.ts +217 -0
  229. package/src/storefront-vitals.ts +206 -0
  230. package/src/surface-signals.ts +6 -0
  231. package/src/targeting-conformance.test.ts +9 -0
  232. package/src/targeting-diagnostics.ts +38 -8
  233. package/src/targeting-parity-corpus.json +559 -0
  234. package/src/targeting-parity-corpus.test.ts +18 -0
  235. package/src/targeting.detailed.test.ts +105 -0
  236. package/src/targeting.test.ts +211 -0
  237. package/src/targeting.ts +88 -207
  238. package/src/track.test.ts +147 -0
  239. package/src/track.ts +125 -26
  240. package/src/types.ts +120 -13
  241. package/src/wait.test.ts +23 -0
  242. package/src/wait.ts +129 -13
  243. package/src/weekday-delivery.test.ts +56 -0
  244. package/src/weekday-schedule-parity-corpus.json +92 -0
  245. package/src/weekday-schedule.test.ts +69 -0
  246. package/src/weekday-schedule.ts +20 -0
@@ -0,0 +1,104 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { collectDeliveryTiming } from "./delivery-timing";
3
+
4
+ describe("delivery timing", () => {
5
+ afterEach(() => vi.restoreAllMocks());
6
+
7
+ it("collects snippet, external bundle, transfer, and SDK-ready timing", () => {
8
+ vi.spyOn(performance, "getEntriesByType").mockImplementation((type) => {
9
+ if (type === "navigation") return [{ startTime: 0 }] as PerformanceEntry[];
10
+ if (type === "resource") return [
11
+ {
12
+ name: "https://events.drip-apex.com/s/shop_123.js",
13
+ startTime: 25,
14
+ responseEnd: 75,
15
+ duration: 50,
16
+ encodedBodySize: 12_345,
17
+ transferSize: 12_645,
18
+ },
19
+ {
20
+ name: "https://events.drip-apex.com/sdk/1.2.3/drip.live.js",
21
+ startTime: 80,
22
+ responseEnd: 200,
23
+ duration: 120,
24
+ encodedBodySize: 45_678,
25
+ transferSize: 45_978,
26
+ },
27
+ ] as PerformanceEntry[];
28
+ return [];
29
+ });
30
+ vi.spyOn(performance, "timeOrigin", "get").mockReturnValue(1_000);
31
+
32
+ expect(collectDeliveryTiming(1_250, "https://events.drip-apex.com/sdk/1.2.3/drip.live.js"))
33
+ .toEqual({
34
+ snippet_fetch_ms: 50,
35
+ snippet_transfer_size: 12_645,
36
+ sdk_bundle_fetch_ms: 120,
37
+ sdk_bundle_transfer_size: 45_978,
38
+ time_to_sdk_ready_ms: 250,
39
+ });
40
+ });
41
+
42
+ it("preserves a zero transfer size for browser-cache hits", () => {
43
+ vi.spyOn(performance, "getEntriesByType").mockImplementation((type) => (
44
+ type === "resource" ? [{
45
+ name: "https://events.drip-apex.com/sdk/1.2.3/drip.live.js",
46
+ startTime: 10,
47
+ responseEnd: 20,
48
+ encodedBodySize: 45_678,
49
+ transferSize: 0,
50
+ }] as PerformanceEntry[] : []
51
+ ));
52
+
53
+ expect(collectDeliveryTiming(undefined).sdk_bundle_transfer_size).toBe(0);
54
+ });
55
+
56
+ it("falls back to encoded body size only when transfer size is unavailable", () => {
57
+ vi.spyOn(performance, "getEntriesByType").mockImplementation((type) => (
58
+ type === "resource" ? [{
59
+ name: "https://events.drip-apex.com/s/shop_123.js",
60
+ startTime: 10,
61
+ responseEnd: 20,
62
+ encodedBodySize: 5_000,
63
+ }] as PerformanceEntry[] : []
64
+ ));
65
+
66
+ expect(collectDeliveryTiming(undefined).snippet_transfer_size).toBe(5_000);
67
+ });
68
+
69
+ it("uses zero bundle timing for an inlined SDK", () => {
70
+ vi.spyOn(performance, "getEntriesByType").mockImplementation((type) => (
71
+ type === "resource" ? [{
72
+ name: "https://events.drip-apex.com/s/shop_123.js",
73
+ startTime: 10,
74
+ responseEnd: 30,
75
+ encodedBodySize: 5_000,
76
+ transferSize: 5_300,
77
+ }] as PerformanceEntry[] : []
78
+ ));
79
+
80
+ expect(collectDeliveryTiming(undefined)).toMatchObject({
81
+ sdk_bundle_fetch_ms: 0,
82
+ sdk_bundle_transfer_size: 0,
83
+ });
84
+ });
85
+
86
+ it("adds navigation startTime to the performance time origin", () => {
87
+ vi.spyOn(performance, "getEntriesByType").mockImplementation((type) => (
88
+ type === "navigation" ? [{ startTime: 50 }] as PerformanceEntry[] : []
89
+ ));
90
+ vi.spyOn(performance, "timeOrigin", "get").mockReturnValue(1_000);
91
+
92
+ expect(collectDeliveryTiming(1_250).time_to_sdk_ready_ms).toBe(200);
93
+ });
94
+
95
+ it("drops negative and implausibly large SDK-ready deltas", () => {
96
+ vi.spyOn(performance, "getEntriesByType").mockImplementation((type) => (
97
+ type === "navigation" ? [{ startTime: 0 }] as PerformanceEntry[] : []
98
+ ));
99
+ vi.spyOn(performance, "timeOrigin", "get").mockReturnValue(1_000);
100
+
101
+ expect(collectDeliveryTiming(999)).not.toHaveProperty("time_to_sdk_ready_ms");
102
+ expect(collectDeliveryTiming(301_001)).not.toHaveProperty("time_to_sdk_ready_ms");
103
+ });
104
+ });
@@ -0,0 +1,89 @@
1
+ import { navigationEntry, roundPerformance } from "./performance";
2
+
3
+ export type DeliveryTiming = {
4
+ snippet_fetch_ms: number;
5
+ snippet_transfer_size: number;
6
+ sdk_bundle_fetch_ms: number;
7
+ sdk_bundle_transfer_size: number;
8
+ time_to_sdk_ready_ms?: number;
9
+ };
10
+
11
+ function duration(entry: PerformanceResourceTiming): number {
12
+ const value = Number.isFinite(entry.responseEnd) && Number.isFinite(entry.startTime)
13
+ ? entry.responseEnd - entry.startTime
14
+ : entry.duration;
15
+ return Number.isFinite(value) ? roundPerformance(Math.max(0, value)) : 0;
16
+ }
17
+
18
+ function transferSize(entry: PerformanceResourceTiming): number {
19
+ const size = entry.transferSize === undefined ? entry.encodedBodySize : entry.transferSize;
20
+ return Number.isFinite(size) ? Math.max(0, Math.round(size)) : 0;
21
+ }
22
+
23
+ function pathname(value: string): string {
24
+ try {
25
+ return new URL(value, typeof location !== "undefined" ? location.href : "https://apex.invalid").pathname;
26
+ } catch {
27
+ return value.split(/[?#]/, 1)[0] ?? "";
28
+ }
29
+ }
30
+
31
+ function navigationStartEpoch(navigation?: PerformanceNavigationTiming): number | null {
32
+ const startTime = navigation && Number.isFinite(navigation.startTime) && navigation.startTime >= 0
33
+ ? navigation.startTime
34
+ : 0;
35
+ let origin = performance.timeOrigin;
36
+ try { if (!(origin > 0)) origin = performance.timing?.navigationStart; } catch {}
37
+ if (!(origin > 0) && typeof performance.now === "function") origin = Date.now() - performance.now();
38
+ return Number.isFinite(origin) && origin > 0 ? origin + startTime : null;
39
+ }
40
+
41
+ function timeToSdkReady(sdkInitStartedAt: number | undefined, navigationStart: number | null): number | undefined {
42
+ if (navigationStart === null || !Number.isFinite(sdkInitStartedAt)) return undefined;
43
+ const delta = sdkInitStartedAt! - navigationStart;
44
+ if (!Number.isFinite(delta) || delta < 0 || delta > 5 * 60_000) return undefined;
45
+ return roundPerformance(delta);
46
+ }
47
+
48
+ export function collectDeliveryTiming(
49
+ sdkInitStartedAt: number | undefined,
50
+ sdkScriptSrc?: string,
51
+ ): DeliveryTiming {
52
+ const empty: DeliveryTiming = {
53
+ snippet_fetch_ms: 0,
54
+ snippet_transfer_size: 0,
55
+ sdk_bundle_fetch_ms: 0,
56
+ sdk_bundle_transfer_size: 0,
57
+ };
58
+ if (typeof performance === "undefined") return empty;
59
+
60
+ try {
61
+ const resources = (performance.getEntriesByType?.("resource") ?? []) as PerformanceResourceTiming[];
62
+ const navigation = navigationEntry();
63
+ const scriptPath = sdkScriptSrc ? pathname(sdkScriptSrc) : "";
64
+ let snippet: PerformanceResourceTiming | undefined;
65
+ let bundle: PerformanceResourceTiming | undefined;
66
+ for (const entry of resources) {
67
+ const entryPath = pathname(entry.name);
68
+ if (/^\/s\/[^/]+\.js$/i.test(entryPath)) {
69
+ if (!snippet || entry.startTime > snippet.startTime) snippet = entry;
70
+ } else if (
71
+ /\/sdk\/[^/]+\/drip\.(?:live|debug|heatmap)\.js$/i.test(entryPath)
72
+ || Boolean(scriptPath && entryPath === scriptPath && /\/drip\.[^/]+\.js$/i.test(entryPath))
73
+ ) {
74
+ if (!bundle || entry.startTime > bundle.startTime) bundle = entry;
75
+ }
76
+ }
77
+ const navigationStart = navigationStartEpoch(navigation);
78
+ const readyTiming = timeToSdkReady(sdkInitStartedAt, navigationStart);
79
+ return {
80
+ snippet_fetch_ms: snippet ? duration(snippet) : 0,
81
+ snippet_transfer_size: snippet ? transferSize(snippet) : 0,
82
+ sdk_bundle_fetch_ms: bundle ? duration(bundle) : 0,
83
+ sdk_bundle_transfer_size: bundle ? transferSize(bundle) : 0,
84
+ ...(readyTiming === undefined ? {} : { time_to_sdk_ready_ms: readyTiming }),
85
+ };
86
+ } catch {
87
+ return empty;
88
+ }
89
+ }
@@ -0,0 +1,451 @@
1
+ /** @vitest-environment jsdom */
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ clearEarlyInteractions,
5
+ drainBootstrapEarlyInteractions,
6
+ drainEarlyInteractions,
7
+ EARLY_INTERACTION_STORAGE_KEY,
8
+ EARLY_INTERACTION_TTL_MS,
9
+ installEarlyInteractionCapture,
10
+ type EarlyInteractionEntry,
11
+ } from "./early-interaction-capture";
12
+ import type { Experiment } from "./types";
13
+
14
+ // Hermeticity: weekday tests pin the most recent UTC Monday at 12:00 instead
15
+ // of a literal calendar date.
16
+ const RECENT_MONDAY_NOON = (() => {
17
+ const d = new Date();
18
+ d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7));
19
+ d.setUTCHours(12, 0, 0, 0);
20
+ return d;
21
+ })();
22
+
23
+ const experiment = {
24
+ id: "exp-1",
25
+ name: "CTA",
26
+ status: "running",
27
+ assignmentEpoch: 3,
28
+ variations: [{ id: "var-1", weight: 1, mutations: [] }],
29
+ goals: [{ id: "goal-1", type: "click", selector: ".buy" }],
30
+ } as Experiment;
31
+
32
+ function install(overrides: Partial<Parameters<typeof installEarlyInteractionCapture>[0]> = {}) {
33
+ installEarlyInteractionCapture({ experiments: [experiment], assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }], configRevision: 7, pageUrl: "https://shop.test/products/one", ...overrides });
34
+ }
35
+
36
+ beforeEach(() => {
37
+ vi.useFakeTimers();
38
+ vi.setSystemTime(2_000);
39
+ localStorage.clear();
40
+ sessionStorage.clear();
41
+ document.cookie = "drip_consent=; Max-Age=0; Path=/";
42
+ document.body.innerHTML = '<a class="buy" data-sku="sku-1" href="#next">Buy</a><form class="buy" data-sku="sku-form"></form>';
43
+ globalThis.__dripHasConsent = undefined;
44
+ globalThis.__dripEarlyInteractions = undefined;
45
+ globalThis.__dripEarlyQ = undefined;
46
+ delete (globalThis as any).OnetrustActiveGroups;
47
+ delete (globalThis as any).Cookiebot;
48
+ });
49
+
50
+ afterEach(() => {
51
+ globalThis.__dripEarlyInteractions?.stop();
52
+ clearEarlyInteractions();
53
+ vi.useRealTimers();
54
+ });
55
+
56
+ describe("early interaction capture", () => {
57
+ it("drains bootstrap interactions once with their original timestamps", () => {
58
+ const target = document.querySelector("a.buy")!;
59
+ globalThis.__dripEarlyQ = [
60
+ { kind: "click", target, timestamp: 1234, id: 1, pageUrl: "https://shop.test/products/one", consentAllowed: true },
61
+ { kind: "click", target, timestamp: 1234, id: 1, pageUrl: "https://shop.test/products/one", consentAllowed: true },
62
+ ];
63
+ const consume = vi.fn();
64
+ const input = {
65
+ assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }],
66
+ experiments: [experiment],
67
+ consume,
68
+ };
69
+ drainBootstrapEarlyInteractions(input);
70
+ drainBootstrapEarlyInteractions(input);
71
+ expect(consume).toHaveBeenCalledOnce();
72
+ expect(consume).toHaveBeenCalledWith("goal-1", expect.objectContaining({ variationId: "var-1" }), 1234, "click", "https://shop.test/products/one");
73
+ });
74
+
75
+ it("discards expired and future-dated bootstrap interactions", () => {
76
+ const target = document.querySelector("a.buy")!;
77
+ globalThis.__dripEarlyQ = [
78
+ { kind: "click", target, timestamp: 1_000, id: 1, pageUrl: "https://shop.test/expired", consentAllowed: true },
79
+ { kind: "click", target, timestamp: 1_000 + EARLY_INTERACTION_TTL_MS + 2, id: 2, pageUrl: "https://shop.test/future", consentAllowed: true },
80
+ ];
81
+ const consume = vi.fn();
82
+ drainBootstrapEarlyInteractions({
83
+ assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }],
84
+ experiments: [experiment],
85
+ now: 1_000 + EARLY_INTERACTION_TTL_MS + 1,
86
+ consume,
87
+ });
88
+ expect(consume).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it("discards bootstrap interactions when tracking consent is blocked", () => {
92
+ globalThis.__dripHasConsent = false;
93
+ globalThis.__dripEarlyQ = [{ kind: "click", target: document.querySelector("a.buy")!, timestamp: 1234, id: 1, pageUrl: "https://shop.test/products/one", consentAllowed: true }];
94
+ const consume = vi.fn();
95
+ drainBootstrapEarlyInteractions({ assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }], experiments: [experiment], requireConsent: true, consume });
96
+ expect(consume).not.toHaveBeenCalled();
97
+ globalThis.__dripHasConsent = true;
98
+ drainBootstrapEarlyInteractions({ assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }], experiments: [experiment], requireConsent: true, consume });
99
+ expect(consume).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it("discards interactions captured before consent was allowed", () => {
103
+ globalThis.__dripHasConsent = true;
104
+ globalThis.__dripEarlyQ = [{ kind: "click", target: document.querySelector("a.buy")!, timestamp: 1234, id: 1, pageUrl: "https://shop.test/products/one", consentAllowed: false }];
105
+ const consume = vi.fn();
106
+ drainBootstrapEarlyInteractions({ assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }], experiments: [experiment], requireConsent: true, consume });
107
+ expect(consume).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it("tears down bootstrap listeners on the first drain and makes SPA rewires inert", () => {
111
+ const stop = vi.fn();
112
+ globalThis.__dripEarlyStop = stop;
113
+ globalThis.__dripEarlyQ = [{ kind: "click", target: document.querySelector("a.buy")!, timestamp: 1234, id: 1, pageUrl: "https://shop.test/products/one", consentAllowed: true }];
114
+ const consume = vi.fn();
115
+ const input = { assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }], experiments: [experiment], consume };
116
+ drainBootstrapEarlyInteractions(input);
117
+ drainBootstrapEarlyInteractions(input);
118
+ expect(stop).toHaveBeenCalledOnce();
119
+ expect(consume).toHaveBeenCalledOnce();
120
+ });
121
+
122
+ it("uses the click-time URL for bootstrap goal matching", () => {
123
+ const urlExperiment = { ...experiment, goals: [{ id: "goal-1", type: "click", selector: ".buy", urlPattern: "/products/" }] } as Experiment;
124
+ globalThis.__dripEarlyQ = [{ kind: "click", target: document.querySelector("a.buy")!, timestamp: 1234, id: 1, pageUrl: "https://shop.test/products/original", consentAllowed: true }];
125
+ history.replaceState({}, "", "/collections/current");
126
+ const consume = vi.fn();
127
+ drainBootstrapEarlyInteractions({ assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }], experiments: [urlExperiment], consume });
128
+ expect(consume).toHaveBeenCalledOnce();
129
+ });
130
+
131
+ it("uses the click-time URL for Page-scoped bootstrap goals", () => {
132
+ const pageExperiment = {
133
+ ...experiment,
134
+ targeting: { pageRules: [{ id: "page-products", includeRules: [{ type: "simple", pattern: "shop.test/products/*" }] }] },
135
+ goals: [{ id: "goal-1", type: "click", selector: ".buy", pageId: "page-products" }],
136
+ } as Experiment;
137
+ globalThis.__dripEarlyQ = [{ kind: "click", target: document.querySelector("a.buy")!, timestamp: 1234, id: 1, pageUrl: "https://shop.test/products/original", consentAllowed: true }];
138
+ history.replaceState({}, "", "/collections/current");
139
+ const consume = vi.fn();
140
+ drainBootstrapEarlyInteractions({ assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }], experiments: [pageExperiment], activePageIds: new Set(), consume });
141
+ expect(consume).toHaveBeenCalledOnce();
142
+ });
143
+
144
+ it("does not pair a bootstrap click and submit from different forms", () => {
145
+ document.body.innerHTML = '<form id="one"><button class="buy">One</button></form><form id="two" class="buy"></form>';
146
+ globalThis.__dripEarlyQ = [
147
+ { kind: "click", target: document.querySelector("button")!, timestamp: 1000, id: 1, pageUrl: "https://shop.test/cart", consentAllowed: true },
148
+ { kind: "submit", target: document.querySelector("#two")!, timestamp: 1100, id: 2, pageUrl: "https://shop.test/cart", consentAllowed: true },
149
+ ];
150
+ const consume = vi.fn();
151
+ drainBootstrapEarlyInteractions({ assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }], experiments: [experiment], consume });
152
+ expect(consume).toHaveBeenCalledTimes(2);
153
+ });
154
+
155
+ it("drains a bootstrap submit through its external form-associated button", () => {
156
+ document.body.innerHTML = '<form id="buy"></form><button class="external-buy" type="submit" form="buy">Buy</button>';
157
+ const submitExperiment = {
158
+ ...experiment,
159
+ goals: [{ id: "goal-submit", type: "submit", selector: ".external-buy" }],
160
+ } as Experiment;
161
+ globalThis.__dripEarlyQ = [{
162
+ kind: "submit",
163
+ target: document.querySelector("form")!,
164
+ timestamp: 1234,
165
+ id: 1,
166
+ pageUrl: "https://shop.test/products/one",
167
+ consentAllowed: true,
168
+ }];
169
+ const consume = vi.fn();
170
+ drainBootstrapEarlyInteractions({
171
+ assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }],
172
+ experiments: [submitExperiment],
173
+ consume,
174
+ });
175
+ expect(consume).toHaveBeenCalledWith("goal-submit", expect.anything(), 1234, "submit", "https://shop.test/products/one");
176
+ });
177
+
178
+ it("pairs a bootstrap click and submit through an external form-associated button", () => {
179
+ document.body.innerHTML = '<form id="buy"></form><button class="external-buy" type="submit" form="buy">Buy</button>';
180
+ const pairedExperiment = { ...experiment, goals: [{ id: "goal-1", type: "click", selector: ".external-buy" }] } as Experiment;
181
+ globalThis.__dripEarlyQ = [
182
+ { kind: "click", target: document.querySelector("button")!, timestamp: 1000, id: 1, pageUrl: "https://shop.test/cart", consentAllowed: true },
183
+ { kind: "submit", target: document.querySelector("form")!, timestamp: 1100, id: 2, pageUrl: "https://shop.test/cart", consentAllowed: true },
184
+ ];
185
+ const consume = vi.fn();
186
+ drainBootstrapEarlyInteractions({ assignments: [{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3 }], experiments: [pairedExperiment], consume, now: 1200 });
187
+ expect(consume).toHaveBeenCalledOnce();
188
+ });
189
+
190
+ it("does not retain deny-before-attach interactions when consent is granted later", () => {
191
+ globalThis.__dripHasConsent = false;
192
+ install({ requireConsent: true });
193
+ document.querySelector(".buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
194
+ globalThis.__dripHasConsent = true;
195
+ const consumed: EarlyInteractionEntry[] = [];
196
+ drainEarlyInteractions({ experiments: [experiment], configRevision: 7, sessionId: "missing", consume: (entry) => consumed.push(entry) });
197
+ expect(consumed).toEqual([]);
198
+ expect(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)).toBeNull();
199
+ });
200
+
201
+ it("writes through before navigation and drains on the next page", () => {
202
+ install();
203
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
204
+ const sessionId = sessionStorage.getItem("drip_sid")!;
205
+ const queued = localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!;
206
+ expect(JSON.parse(queued)).toHaveLength(1);
207
+ globalThis.__dripEarlyInteractions?.stop();
208
+ globalThis.__dripEarlyInteractions = undefined;
209
+ const consumed: EarlyInteractionEntry[] = [];
210
+ drainEarlyInteractions({ experiments: [experiment], configRevision: 7, sessionId, consume: (entry) => consumed.push(entry) });
211
+ expect(consumed).toMatchObject([{ goalId: "goal-1", kind: "click", pageUrl: "https://shop.test/products/one", data: { early_interaction: true } }]);
212
+ expect(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)).toBeNull();
213
+ });
214
+
215
+ it("captures qualifying form submissions", () => {
216
+ install({ storageMode: "sessionStorage" });
217
+ document.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
218
+ const entries = JSON.parse(sessionStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!);
219
+ expect(entries).toMatchObject([{ goalId: "goal-1", kind: "submit", data: { early_interaction: true } }]);
220
+ });
221
+
222
+ it("pairs durable click and submit capture through an external form-associated button", () => {
223
+ document.body.innerHTML = '<form id="buy"></form><button class="buy" type="button" form="buy">Buy</button>';
224
+ install();
225
+ document.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
226
+ document.querySelector("form")!.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true, submitter: document.querySelector("button")! }));
227
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toHaveLength(1);
228
+ });
229
+
230
+ it("captures a scoped click only when its Page is active", () => {
231
+ const scoped = {
232
+ ...experiment,
233
+ targeting: {
234
+ pageRules: [{
235
+ id: "page-products",
236
+ includeRules: [{ type: "simple", pattern: "shop.test/products/*" }],
237
+ }],
238
+ },
239
+ goals: [{
240
+ id: "goal-1",
241
+ type: "click",
242
+ selector: ".buy",
243
+ pageId: "page-products",
244
+ }],
245
+ } as Experiment;
246
+ install({ experiments: [scoped] });
247
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
248
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toHaveLength(1);
249
+
250
+ clearEarlyInteractions();
251
+ install({ experiments: [scoped], pageUrl: "https://shop.test/collections/sale" });
252
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
253
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toEqual([]);
254
+ });
255
+
256
+ it("builds normalized targeting attributes for scoped early clicks", () => {
257
+ const goalOnly = { ...experiment, targeting: { pageRules: [{ id: "page-products", includeRules: [{ type: "simple", pattern: "/products/*" }] }] }, goalPageRules: [{ id: "page-cart", includeRules: [{ type: "simple", pattern: "/cart" }] }], goals: [{ id: "goal-1", type: "click", selector: ".buy", pageId: "page-cart" }] } as Experiment;
258
+ install({ experiments: [goalOnly], pageUrl: "https://shop.test/cart" });
259
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
260
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toHaveLength(1);
261
+ clearEarlyInteractions();
262
+
263
+ const scoped = {
264
+ ...experiment,
265
+ targeting: {
266
+ pageRules: [{
267
+ id: "page-mobile",
268
+ includeRules: [{ type: "simple", pattern: "shop.test/products/*" }],
269
+ advancedCondition: {
270
+ code: 'return attributes["device.type"] === "mobile";',
271
+ },
272
+ }],
273
+ },
274
+ goals: [{
275
+ id: "goal-1",
276
+ type: "click",
277
+ selector: ".buy",
278
+ pageId: "page-mobile",
279
+ }],
280
+ } as Experiment;
281
+ install({ experiments: [scoped], attributes: { device: "Mobile" } });
282
+
283
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
284
+
285
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toHaveLength(1);
286
+ });
287
+
288
+ it("honors urlPattern for an unscoped fallback click goal", () => {
289
+ const urlScoped = {
290
+ ...experiment,
291
+ goals: [{
292
+ id: "goal-1",
293
+ type: "click",
294
+ selector: ".buy",
295
+ urlPattern: "/products/",
296
+ }],
297
+ } as Experiment;
298
+ install({ experiments: [urlScoped], pageUrl: "https://shop.test/collections/sale" });
299
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
300
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toEqual([]);
301
+ });
302
+
303
+ it.each([
304
+ ["expired TTL", { timestamp: Date.now() - EARLY_INTERACTION_TTL_MS - 1 }],
305
+ ["future timestamp", { timestamp: Date.now() + 10_000 }],
306
+ ["session mismatch", { sessionId: "other-session" }],
307
+ ["config mismatch", { configRevision: 8 }],
308
+ ])("discards %s entries", (_name, patch) => {
309
+ const entry: EarlyInteractionEntry = {
310
+ goalId: "goal-1", experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3, kind: "click", data: {}, timestamp: Date.now(),
311
+ sessionId: "session-1", pageUrl: "https://shop.test/previous", configRevision: 7, ...patch,
312
+ };
313
+ localStorage.setItem(EARLY_INTERACTION_STORAGE_KEY, JSON.stringify([entry]));
314
+ const consume = vi.fn();
315
+ drainEarlyInteractions({ experiments: [experiment], configRevision: 7, sessionId: "session-1", consume });
316
+ expect(consume).not.toHaveBeenCalled();
317
+ });
318
+
319
+ it("clears queued memory and persistence on consent withdrawal", () => {
320
+ globalThis.__dripHasConsent = true;
321
+ install({ requireConsent: true });
322
+ document.querySelector(".buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
323
+ globalThis.__dripHasConsent = false;
324
+ window.dispatchEvent(new Event("CookiebotOnDecline"));
325
+ expect(globalThis.__dripEarlyInteractions?.entries).toEqual([]);
326
+ expect(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)).toBeNull();
327
+ });
328
+
329
+ it("installs no listeners and clears the queue for an opted-out visitor", () => {
330
+ localStorage.setItem("drip_optout", "1");
331
+ localStorage.setItem(EARLY_INTERACTION_STORAGE_KEY, "[{}]");
332
+ install();
333
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
334
+ expect(globalThis.__dripEarlyInteractions).toBeUndefined();
335
+ expect(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)).toBeNull();
336
+ });
337
+
338
+ it("preserves a prior queue while consent is unknown and arms after native consent arrives", () => {
339
+ const prior = { goalId: "goal-1", experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3, kind: "click", data: {}, timestamp: Date.now(), sessionId: "sid", pageUrl: "https://shop.test/pdp", configRevision: 7 };
340
+ localStorage.setItem(EARLY_INTERACTION_STORAGE_KEY, JSON.stringify([prior]));
341
+ install({ requireConsent: true, consentModeEnabled: true, consentProvider: "custom", consentGetter: "missingConsent.value" });
342
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toEqual([prior]);
343
+ (globalThis as any).missingConsent = { value: true };
344
+ window.dispatchEvent(new Event("OneTrustGroupsUpdated"));
345
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
346
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toHaveLength(2);
347
+ });
348
+
349
+ it("uses native CMP consent synchronously on the first visit", () => {
350
+ (globalThis as any).Cookiebot = { consent: { statistics: true } };
351
+ install({ requireConsent: true, consentModeEnabled: true, consentProvider: "cookiebot", consentRequiredGroup: "statistics" });
352
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
353
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toHaveLength(1);
354
+ });
355
+
356
+ it("refuses and clears a drain after explicit consent denial", () => {
357
+ install();
358
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
359
+ globalThis.__dripHasConsent = false;
360
+ const consume = vi.fn();
361
+ drainEarlyInteractions({ experiments: [experiment], configRevision: 7, sessionId: sessionStorage.getItem("drip_sid")!, requireConsent: true, consume });
362
+ expect(consume).not.toHaveBeenCalled();
363
+ expect(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)).toBeNull();
364
+ });
365
+
366
+ it("stamps and drains the originating assignment without a destination assignment", () => {
367
+ install();
368
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
369
+ const consume = vi.fn();
370
+ drainEarlyInteractions({ experiments: [experiment], configRevision: 7, sessionId: sessionStorage.getItem("drip_sid")!, consume });
371
+ expect(consume).toHaveBeenCalledWith(expect.objectContaining({ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 3, pageUrl: "https://shop.test/products/one" }));
372
+ });
373
+
374
+ it("does not capture selector matches for an unassigned experiment", () => {
375
+ install({ assignments: [] });
376
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
377
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toEqual([]);
378
+ });
379
+
380
+ it("keeps excluded weekdays dark during early capture and drain", () => {
381
+ vi.useFakeTimers();
382
+ vi.setSystemTime(new Date(RECENT_MONDAY_NOON));
383
+ const scheduled = {
384
+ ...experiment,
385
+ weekdaySchedule: { excludedDays: ["monday"], timezone: "UTC" },
386
+ } as Experiment;
387
+ const monday = Date.now();
388
+ install({ experiments: [scheduled], now: monday });
389
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
390
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toEqual([]);
391
+
392
+ clearEarlyInteractions();
393
+ install({ experiments: [scheduled], now: monday, forcedExperimentIds: ["exp-1"] });
394
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
395
+ const sessionId = sessionStorage.getItem("drip_sid")!;
396
+ const queued = localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!;
397
+ expect(JSON.parse(queued)).toHaveLength(1);
398
+
399
+ const consume = vi.fn();
400
+ drainEarlyInteractions({
401
+ experiments: [scheduled],
402
+ configRevision: 7,
403
+ sessionId,
404
+ now: monday,
405
+ consume,
406
+ });
407
+ expect(consume).not.toHaveBeenCalled();
408
+
409
+ localStorage.setItem(EARLY_INTERACTION_STORAGE_KEY, queued);
410
+ drainEarlyInteractions({
411
+ experiments: [scheduled],
412
+ configRevision: 7,
413
+ sessionId,
414
+ now: monday,
415
+ forcedExperimentIds: ["exp-1"],
416
+ consume,
417
+ });
418
+ expect(consume).toHaveBeenCalledTimes(1);
419
+ });
420
+
421
+ it("deduplicates paired click and submit events for one form action", () => {
422
+ document.body.innerHTML = '<form class="buy"><button class="buy" type="submit">Buy</button></form>';
423
+ install();
424
+ document.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
425
+ document.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
426
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toHaveLength(1);
427
+ });
428
+
429
+ it("does not deduplicate persisted click and submit events from different forms", () => {
430
+ document.body.innerHTML = '<form id="one"><button class="buy" type="submit">One</button></form><form id="two" class="buy"></form>';
431
+ install();
432
+ document.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
433
+ document.querySelector("#two")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
434
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toHaveLength(2);
435
+ });
436
+
437
+ it("captures explicit submit goals", () => {
438
+ const submitExperiment = { ...experiment, goals: [{ id: "submit-goal", type: "submit", selector: "form.buy" }] } as Experiment;
439
+ install({ experiments: [submitExperiment] });
440
+ document.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
441
+ expect(JSON.parse(localStorage.getItem(EARLY_INTERACTION_STORAGE_KEY)!)).toMatchObject([{ goalId: "submit-goal", kind: "submit" }]);
442
+ });
443
+
444
+ it("shares the tracker's session identity for cookie queue storage", () => {
445
+ sessionStorage.setItem("drip_sid", "tracker-cookie-session");
446
+ install({ storageMode: "cookie" });
447
+ document.querySelector("a.buy")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
448
+ expect(decodeURIComponent(document.cookie)).toContain('"sessionId":"tracker-cookie-session"');
449
+ });
450
+
451
+ });