@drip-apex/sdk 0.1.0 → 0.2.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 (245) 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 +19262 -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 +19246 -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 +16621 -13539
  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 +27 -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 +2070 -0
  128. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  129. package/src/consent-evaluation.ts +138 -0
  130. package/src/css.test.ts +30 -0
  131. package/src/css.ts +120 -6
  132. package/src/cwv.test.ts +60 -77
  133. package/src/debug-loader.test.ts +214 -0
  134. package/src/debug-loader.ts +41 -0
  135. package/src/decide-assignments.test.ts +196 -0
  136. package/src/decide-assignments.ts +204 -0
  137. package/src/delivery-timing.test.ts +104 -0
  138. package/src/delivery-timing.ts +89 -0
  139. package/src/early-interaction-capture.test.ts +451 -0
  140. package/src/early-interaction-capture.ts +230 -0
  141. package/src/early-reassert.test.ts +207 -0
  142. package/src/early-reassert.ts +152 -0
  143. package/src/edge-adoption.test.ts +435 -0
  144. package/src/edge-runtime.ts +30 -0
  145. package/src/editor/api.test.ts +109 -0
  146. package/src/editor/api.ts +34 -3
  147. package/src/editor/controller.test.ts +297 -14
  148. package/src/editor/controller.ts +69 -5
  149. package/src/editor/index.test.ts +2 -0
  150. package/src/editor/index.ts +1 -0
  151. package/src/editor/publication-result.ts +58 -0
  152. package/src/editor/types.ts +10 -1
  153. package/src/editor/view-kit.ts +2 -1
  154. package/src/editor/view.test.ts +108 -0
  155. package/src/editor/view.ts +49 -7
  156. package/src/endpoint.test.ts +15 -0
  157. package/src/endpoint.ts +10 -0
  158. package/src/exclusion-groups.ts +8 -18
  159. package/src/flags.test.ts +88 -1
  160. package/src/flags.ts +29 -29
  161. package/src/global-runtime-reconciler.test.ts +271 -0
  162. package/src/goal-eligibility.test.ts +29 -0
  163. package/src/goal-eligibility.ts +17 -0
  164. package/src/goals.test.ts +144 -1
  165. package/src/goals.ts +97 -18
  166. package/src/guards.test.ts +99 -1
  167. package/src/guards.ts +34 -15
  168. package/src/handover-cache-order.test.ts +6 -3
  169. package/src/handover.test.ts +154 -8
  170. package/src/handover.ts +122 -12
  171. package/src/hash.ts +29 -54
  172. package/src/holdout.test.ts +26 -0
  173. package/src/hydration.test.ts +73 -2
  174. package/src/hydration.ts +18 -9
  175. package/src/index.ts +130 -7287
  176. package/src/init-analytics.test.ts +1694 -0
  177. package/src/init-anti-flicker.test.ts +1211 -0
  178. package/src/init-cart-attributes.test.ts +2003 -0
  179. package/src/init-config-resolution.test.ts +999 -0
  180. package/src/init-consent.test.ts +971 -0
  181. package/src/init-empty.test.ts +11 -5406
  182. package/src/init-integrations.test.ts +846 -0
  183. package/src/init-page-activation.test.ts +5064 -0
  184. package/src/init-qa-loading.test.ts +740 -0
  185. package/src/init-redirects.test.ts +2156 -0
  186. package/src/init-test-helpers.ts +287 -0
  187. package/src/inline-bootstrap.test.ts +184 -0
  188. package/src/inline-bootstrap.ts +73 -0
  189. package/src/js.test.ts +51 -1
  190. package/src/js.ts +86 -2
  191. package/src/money.test.ts +55 -1
  192. package/src/money.ts +104 -2
  193. package/src/money.web-parity.contract.test.ts +85 -0
  194. package/src/mutate.test.ts +474 -10
  195. package/src/mutate.ts +739 -230
  196. package/src/page-trigger.test.ts +218 -8
  197. package/src/performance.ts +10 -0
  198. package/src/persistence-decoders.ts +18 -0
  199. package/src/public-api.ts +1828 -0
  200. package/src/qa-assistant-state.test.ts +249 -3
  201. package/src/qa-assistant-state.ts +146 -6
  202. package/src/qa-assistant-view.test.ts +220 -2
  203. package/src/qa-assistant-view.ts +102 -5
  204. package/src/react/index.test.tsx +37 -0
  205. package/src/react/index.tsx +19 -3
  206. package/src/reassert-sentinel.test.ts +155 -1
  207. package/src/reassert-sentinel.ts +168 -8
  208. package/src/redirect.test.ts +541 -0
  209. package/src/redirect.ts +572 -0
  210. package/src/replay-recorder-entry.ts +115 -0
  211. package/src/replay-signal-state.ts +100 -0
  212. package/src/replay-signals.test.ts +249 -0
  213. package/src/replay-signals.ts +151 -0
  214. package/src/replay.test.ts +764 -0
  215. package/src/replay.ts +514 -0
  216. package/src/router.test.ts +61 -2
  217. package/src/router.ts +50 -12
  218. package/src/runtime-state.ts +507 -0
  219. package/src/runtime.test.ts +174 -7
  220. package/src/runtime.ts +88 -14
  221. package/src/sanitize.test.ts +34 -0
  222. package/src/sanitize.ts +23 -12
  223. package/src/shadow.test.ts +187 -0
  224. package/src/shadow.ts +99 -0
  225. package/src/shopify-cart.test.ts +733 -23
  226. package/src/shopify-cart.ts +402 -49
  227. package/src/storefront-vitals.test.ts +217 -0
  228. package/src/storefront-vitals.ts +206 -0
  229. package/src/surface-signals.ts +6 -0
  230. package/src/targeting-conformance.test.ts +9 -0
  231. package/src/targeting-diagnostics.ts +38 -8
  232. package/src/targeting-parity-corpus.json +559 -0
  233. package/src/targeting-parity-corpus.test.ts +18 -0
  234. package/src/targeting.detailed.test.ts +105 -0
  235. package/src/targeting.test.ts +211 -0
  236. package/src/targeting.ts +88 -207
  237. package/src/track.test.ts +147 -0
  238. package/src/track.ts +125 -26
  239. package/src/types.ts +120 -13
  240. package/src/wait.test.ts +23 -0
  241. package/src/wait.ts +129 -13
  242. package/src/weekday-delivery.test.ts +56 -0
  243. package/src/weekday-schedule-parity-corpus.json +92 -0
  244. package/src/weekday-schedule.test.ts +69 -0
  245. package/src/weekday-schedule.ts +20 -0
@@ -0,0 +1,214 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { loadDebugBuild } from "./debug-loader";
3
+
4
+ const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
5
+ const originalLocation = Object.getOwnPropertyDescriptor(globalThis, "location");
6
+ const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
7
+ const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
8
+
9
+ function installBrowser(search = "", persisted = false, nonce = "") {
10
+ const scripts: Array<Record<string, unknown>> = [];
11
+ const values = new Map<string, string>();
12
+ if (persisted) values.set("drip_debug", "1");
13
+ const appendChild = vi.fn((script: Record<string, unknown>) => {
14
+ scripts.push(script);
15
+ return script;
16
+ });
17
+ const documentValue = {
18
+ createElement: vi.fn(() => ({ async: true, dataset: {} })),
19
+ head: { appendChild },
20
+ documentElement: { appendChild },
21
+ currentScript: {
22
+ nonce,
23
+ getAttribute: vi.fn((name: string) => name === "nonce" ? nonce : null),
24
+ },
25
+ };
26
+ const locationValue = {
27
+ href: `https://shop.example.test/${search}`,
28
+ search,
29
+ };
30
+ const localStorageValue = {
31
+ drip_debug: persisted ? "1" : undefined,
32
+ getItem: (key: string) => values.get(key) ?? null,
33
+ removeItem: (key: string) => {
34
+ values.delete(key);
35
+ if (key === "drip_debug") localStorageValue.drip_debug = undefined;
36
+ },
37
+ setItem: (key: string, value: string) => {
38
+ values.set(key, value);
39
+ if (key === "drip_debug") localStorageValue.drip_debug = value;
40
+ },
41
+ };
42
+ Object.defineProperty(globalThis, "document", { configurable: true, value: documentValue });
43
+ Object.defineProperty(globalThis, "location", { configurable: true, value: locationValue });
44
+ Object.defineProperty(globalThis, "localStorage", { configurable: true, value: localStorageValue });
45
+ Object.defineProperty(globalThis, "window", { configurable: true, value: globalThis });
46
+ return { appendChild, scripts };
47
+ }
48
+
49
+ function restore(name: "document" | "location" | "localStorage" | "window", descriptor?: PropertyDescriptor) {
50
+ if (descriptor) Object.defineProperty(globalThis, name, descriptor);
51
+ else delete (globalThis as Record<string, unknown>)[name];
52
+ }
53
+
54
+ afterEach(() => {
55
+ vi.restoreAllMocks();
56
+ vi.unstubAllGlobals();
57
+ restore("document", originalDocument);
58
+ restore("location", originalLocation);
59
+ restore("localStorage", originalLocalStorage);
60
+ restore("window", originalWindow);
61
+ });
62
+
63
+ describe("per-visitor debug build loader", () => {
64
+ it("activates from the configured query parameter and injects once", () => {
65
+ const browser = installBrowser("?apex_debug=secret");
66
+ const settings = {
67
+ enabled: true,
68
+ restrictMode: "query_param" as const,
69
+ queryParam: "apex_debug",
70
+ tokenRequired: true,
71
+ };
72
+
73
+ expect(loadDebugBuild(settings, "https://events.example.test/s/shop.js?env=staging")).toBe(true);
74
+ expect(loadDebugBuild(settings, "https://events.example.test/s/shop.js?env=staging")).toBe(true);
75
+ expect(browser.appendChild).toHaveBeenCalledTimes(1);
76
+ expect(browser.scripts[0]?.src).toBe(
77
+ "https://events.example.test/s/shop.js?env=staging&drip_sdk_variant=debug&drip_debug_token=secret",
78
+ );
79
+ });
80
+
81
+ it.each([
82
+ ["an empty token", "?apex_debug=", ""],
83
+ ["the default token", "?apex_debug=1", "1"],
84
+ ["an encoded token", "?apex_debug=a%2Bb%20c", "a%2Bb+c"],
85
+ ])("hands %s to the worker for validation", (_label, search, encodedToken) => {
86
+ const browser = installBrowser(search);
87
+
88
+ expect(loadDebugBuild({
89
+ enabled: true,
90
+ restrictMode: "query_param",
91
+ queryParam: "apex_debug",
92
+ tokenRequired: true,
93
+ }, "https://events.example.test/s/shop.js")).toBe(true);
94
+ expect(browser.scripts[0]?.src).toBe(
95
+ `https://events.example.test/s/shop.js?drip_sdk_variant=debug&drip_debug_token=${encodedToken}`,
96
+ );
97
+ });
98
+
99
+ it("puts debug parameters before a snippet URL fragment", () => {
100
+ const browser = installBrowser("?apex_debug=secret");
101
+
102
+ expect(loadDebugBuild({
103
+ enabled: true,
104
+ restrictMode: "query_param",
105
+ queryParam: "apex_debug",
106
+ tokenRequired: true,
107
+ }, "https://events.example.test/s/shop.js#install")).toBe(true);
108
+
109
+ const scriptUrl = new URL(String(browser.scripts[0]?.src));
110
+ expect(scriptUrl.searchParams.get("drip_sdk_variant")).toBe("debug");
111
+ expect(scriptUrl.searchParams.get("drip_debug_token")).toBe("secret");
112
+ expect(scriptUrl.hash).toBe("#install");
113
+ });
114
+
115
+ it("lets an unauthorized marker response initialize the live page exactly once", () => {
116
+ const browser = installBrowser("?apex_debug=wrong");
117
+ const settings = {
118
+ enabled: true,
119
+ restrictMode: "query_param" as const,
120
+ queryParam: "apex_debug",
121
+ tokenRequired: true,
122
+ };
123
+ const initializeLive = vi.fn();
124
+ const runLiveSnippet = (snippetUrl: string) => {
125
+ if (!loadDebugBuild(settings, snippetUrl)) initializeLive();
126
+ };
127
+
128
+ runLiveSnippet("https://events.example.test/s/shop.js");
129
+ expect(initializeLive).not.toHaveBeenCalled();
130
+ expect(browser.appendChild).toHaveBeenCalledTimes(1);
131
+
132
+ runLiveSnippet(String(browser.scripts[0]?.src));
133
+ expect(browser.appendChild).toHaveBeenCalledTimes(1);
134
+ expect(initializeLive).toHaveBeenCalledTimes(1);
135
+ });
136
+
137
+ it("falls back once when the debug script fails to load", () => {
138
+ const browser = installBrowser("?drip_debug=1");
139
+ const assignments: string[] = [];
140
+ const initializeLive = vi.fn(() => assignments.push("live-assignment"));
141
+
142
+ expect(loadDebugBuild({
143
+ enabled: true,
144
+ restrictMode: "query_param",
145
+ }, "https://events.example.test/s/shop.js", initializeLive)).toBe(true);
146
+
147
+ const script = browser.scripts[0] as { onerror?: () => void };
148
+ script.onerror?.();
149
+ expect(assignments).toEqual(["live-assignment"]);
150
+ expect(initializeLive).toHaveBeenCalledTimes(1);
151
+ expect(loadDebugBuild({
152
+ enabled: true,
153
+ restrictMode: "query_param",
154
+ }, "https://events.example.test/s/shop.js", initializeLive)).toBe(false);
155
+
156
+ script.onerror?.();
157
+ expect(assignments).toEqual(["live-assignment"]);
158
+ expect(initializeLive).toHaveBeenCalledTimes(1);
159
+ });
160
+
161
+ it("copies the current script nonce to the debug script", () => {
162
+ const browser = installBrowser("?drip_debug=1", false, "request-nonce-123");
163
+
164
+ expect(loadDebugBuild({
165
+ enabled: true,
166
+ restrictMode: "query_param",
167
+ }, "https://events.example.test/s/shop.js")).toBe(true);
168
+
169
+ expect(browser.scripts[0]?.nonce).toBe("request-nonce-123");
170
+ });
171
+
172
+ it("activates from persisted storage for a user-id restricted shop", () => {
173
+ const browser = installBrowser("", true);
174
+
175
+ expect(loadDebugBuild({
176
+ enabled: true,
177
+ restrictMode: "user_ids",
178
+ persist: true,
179
+ }, "https://events.example.test/s/shop.js")).toBe(true);
180
+ expect(browser.appendChild).toHaveBeenCalledTimes(1);
181
+ });
182
+
183
+ it.each([
184
+ ["absent config", undefined, "?drip_debug=1", false],
185
+ ["expired config", { enabled: true, restrictMode: "query_param" as const, expiresAt: "2020-01-01T00:00:00.000Z" }, "?drip_debug=1", false],
186
+ ["unparseable expiry", { enabled: true, restrictMode: "query_param" as const, expiresAt: "not-a-date" }, "?drip_debug=1", false],
187
+ ["off mode", { enabled: true, restrictMode: "off" as const }, "?drip_debug=1", false],
188
+ ["no opt-in", { enabled: true, restrictMode: "query_param" as const }, "", false],
189
+ ])("does not activate for %s", (_label, settings, search, persisted) => {
190
+ const browser = installBrowser(search, persisted);
191
+ expect(loadDebugBuild(settings, "https://events.example.test/s/shop.js")).toBe(false);
192
+ expect(browser.appendChild).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("aborts public live init before config fetch or event emission", async () => {
196
+ const browser = installBrowser("?drip_debug=1");
197
+ const fetchMock = vi.fn();
198
+ vi.stubGlobal("fetch", fetchMock);
199
+ const { init } = await import("./index");
200
+
201
+ const result = init({
202
+ shopId: "shop_123",
203
+ endpoint: "https://events.example.test/events",
204
+ snippetUrl: "https://events.example.test/s/shop_123.js",
205
+ runtimeFeatures: { sdkVariant: "live" },
206
+ debug: { enabled: true, restrictMode: "query_param" },
207
+ });
208
+
209
+ expect(result).toEqual([]);
210
+ expect(browser.appendChild).toHaveBeenCalledTimes(1);
211
+ await Promise.resolve();
212
+ expect(fetchMock).not.toHaveBeenCalled();
213
+ });
214
+ });
@@ -0,0 +1,41 @@
1
+ import type { DebugSettings } from "./types";
2
+
3
+ export function loadDebugBuild(
4
+ settings: DebugSettings | undefined,
5
+ snippetUrl: string | undefined,
6
+ onFallback?: () => void,
7
+ ): boolean {
8
+ const expiresAt = settings?.expiresAt ? Date.parse(settings.expiresAt) : null;
9
+ if (!settings?.enabled || !snippetUrl
10
+ || settings.restrictMode === "off"
11
+ || expiresAt !== null && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) return false;
12
+ try {
13
+ const value = new URLSearchParams(location.search).get(settings.queryParam || "drip_debug");
14
+ const tokenRequired = settings.tokenRequired === true || Boolean(settings.token);
15
+ if (!tokenRequired && (value == "0" || value == "false")) return false;
16
+ const queryOptIn = tokenRequired ? value !== null : value == "1" || value == "true";
17
+ if (!queryOptIn && !(settings.persist && (localStorage as any).drip_debug == "1")) return false;
18
+ const handoff = (document as any).__dripDL;
19
+ if (handoff === "failed") return false;
20
+ if (handoff) {
21
+ return new URL(snippetUrl, location.href).searchParams.get("drip_sdk_variant") !== "debug";
22
+ }
23
+ const currentScript = document.currentScript;
24
+ const nonce = currentScript && (currentScript.nonce || currentScript.getAttribute("nonce")) || "";
25
+ const script = document.createElement("script");
26
+ const debugUrl = new URL(snippetUrl, location.href);
27
+ debugUrl.searchParams.set("drip_sdk_variant", "debug");
28
+ if (value !== null) debugUrl.searchParams.set("drip_debug_token", value);
29
+ script.src = debugUrl.toString();
30
+ script.nonce = nonce;
31
+ script.onerror = () => {
32
+ if ((document as any).__dripDL !== script) return;
33
+ (document as any).__dripDL = "failed";
34
+ onFallback?.();
35
+ };
36
+ (document as any).__dripDL = document.head.appendChild(script);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
@@ -0,0 +1,196 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { __assignmentStoreInternals } from "./assignment-store";
3
+ import { chooseVariation, inTrafficAllocation } from "./bucket";
4
+ import {
5
+ buildAssignment,
6
+ decideAssignments,
7
+ type DecideAssignmentsInput,
8
+ } from "./decide-assignments";
9
+ import { hashToFloat, variationHashToFloat } from "./hash";
10
+ import type { Assignment, Experiment } from "./types";
11
+
12
+ // Hermeticity: weekday tests pin the most recent UTC Monday at 12:00 instead
13
+ // of a literal calendar date.
14
+ const RECENT_MONDAY_NOON = (() => {
15
+ const d = new Date();
16
+ d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7));
17
+ d.setUTCHours(12, 0, 0, 0);
18
+ return d;
19
+ })();
20
+
21
+ function experiment(id: string, weights = [0.5, 0.5], allocation = 1, epoch = 1): Experiment {
22
+ return {
23
+ id,
24
+ status: "running",
25
+ trafficAllocation: allocation,
26
+ assignmentEpoch: epoch,
27
+ variations: weights.map((weight, index) => ({
28
+ id: `${id}_${index}`,
29
+ weight,
30
+ mutations: [],
31
+ isControl: index === 0,
32
+ })),
33
+ };
34
+ }
35
+
36
+ function legacyDecision(input: DecideAssignmentsInput): Array<Pick<Assignment, "experimentId" | "variationId">> {
37
+ const pct = input.holdout?.percentage;
38
+ if (input.holdout?.enabled && typeof pct === "number" && pct > 0 && pct <= 10 &&
39
+ hashToFloat("apex:global-holdout:v1", input.visitorId) < pct / 100) return [];
40
+ const provisional = input.experiments.flatMap((exp) => {
41
+ const epoch = Number.isFinite(exp.assignmentEpoch)
42
+ ? Math.max(1, Math.floor(exp.assignmentEpoch as number))
43
+ : 1;
44
+ const sticky = input.storedAssignments?.[exp.id];
45
+ if (sticky?.epoch === epoch && exp.variations.some((variation) => variation.id === sticky.variationId)) {
46
+ return [{ experimentId: exp.id, variationId: sticky.variationId }];
47
+ }
48
+ const trafficHash = hashToFloat(exp.id, input.visitorId);
49
+ const allocation = exp.trafficAllocation ?? 1;
50
+ if (!inTrafficAllocation(trafficHash, allocation) || !exp.variations.length) return [];
51
+ const splitHash = allocation >= 1
52
+ ? trafficHash
53
+ : variationHashToFloat(exp.id, input.visitorId);
54
+ const index = chooseVariation(splitHash, exp.variations.map((variation) => variation.weight));
55
+ return index >= 0
56
+ ? [{ experimentId: exp.id, variationId: exp.variations[index].id }]
57
+ : [];
58
+ });
59
+ const suppressed = new Set<string>();
60
+ for (const group of input.exclusionGroups ?? []) {
61
+ const conflicts = group.members.filter((member) =>
62
+ provisional.some((assignment) => assignment.experimentId === member.experimentId)
63
+ );
64
+ if (conflicts.length <= 1) continue;
65
+ const epoch = Number.isFinite(group.epoch) ? Math.max(1, Math.floor(group.epoch as number)) : 1;
66
+ const prior = input.storedGroupSelections?.[group.id];
67
+ const priorMember = conflicts.find((member) => member.experimentId === prior?.experimentId);
68
+ const configured = conflicts.map((member) => member.share > 0 ? member.share : 0);
69
+ const weights = configured.some((weight) => weight > 0) ? configured : configured.map(() => 1);
70
+ const winner = prior?.epoch === epoch && priorMember && (priorMember.share > 0 || !configured.some((weight) => weight > 0))
71
+ ? prior.experimentId
72
+ : conflicts[chooseVariation(hashToFloat(`${group.id}:exclusion`, input.visitorId), weights)].experimentId;
73
+ for (const conflict of conflicts) if (conflict.experimentId !== winner) suppressed.add(conflict.experimentId);
74
+ }
75
+ return provisional.filter((assignment) => !suppressed.has(assignment.experimentId));
76
+ }
77
+
78
+ function ids(assignments: Assignment[]) {
79
+ return assignments.map(({ experimentId, variationId }) => ({ experimentId, variationId }));
80
+ }
81
+
82
+ describe("decideAssignments parity boundary", () => {
83
+ const cases: Array<[string, DecideAssignmentsInput]> = [
84
+ ["empty config", { experiments: [], visitorId: "visitor-a", now: 123, url: "/" }],
85
+ ["single experiment", { experiments: [experiment("a")], visitorId: "visitor-a", now: 123 }],
86
+ ["multiple experiments", { experiments: [experiment("a"), experiment("b", [0.2, 0.8])], visitorId: "visitor-b", now: 123 }],
87
+ ["partial traffic allocation", { experiments: [experiment("a", [0.5, 0.5], 0.23)], visitorId: "visitor-c", now: 123 }],
88
+ ["sticky assignment wins over changed weights", {
89
+ experiments: [experiment("sticky", [1, 0])], visitorId: "visitor-d", now: 123,
90
+ storedAssignments: { sticky: { variationId: "sticky_1", epoch: 1, assignedAt: 1 } },
91
+ }],
92
+ ["epoch bump rejects sticky assignment", {
93
+ experiments: [experiment("sticky", [1, 0], 1, 2)], visitorId: "visitor-d", now: 123,
94
+ storedAssignments: { sticky: { variationId: "sticky_1", epoch: 1, assignedAt: 1 } },
95
+ }],
96
+ ["holdout visitor", {
97
+ experiments: [experiment("a")], visitorId: "holdout-49", now: 123,
98
+ holdout: { enabled: true, percentage: 10 },
99
+ }],
100
+ ["non-holdout visitor", {
101
+ experiments: [experiment("a")], visitorId: "visitor-outside-holdout", now: 123,
102
+ holdout: { enabled: true, percentage: 1 },
103
+ }],
104
+ ["exclusion group deterministic conflict", {
105
+ experiments: [experiment("a"), experiment("b")], visitorId: "visitor-e", now: 123,
106
+ exclusionGroups: [{ id: "group", epoch: 1, members: [{ experimentId: "a", share: 0.5 }, { experimentId: "b", share: 0.5 }] }],
107
+ }],
108
+ ["exclusion group sticky winner", {
109
+ experiments: [experiment("a"), experiment("b")], visitorId: "visitor-e", now: 123,
110
+ exclusionGroups: [{ id: "group", epoch: 1, members: [{ experimentId: "a", share: 1 }, { experimentId: "b", share: 0 }] }],
111
+ storedGroupSelections: { group: { experimentId: "b", epoch: 1 } },
112
+ }],
113
+ ];
114
+
115
+ it.each(cases)("matches the pre-extraction decision for %s", (_name, input) => {
116
+ expect(ids(decideAssignments(input))).toEqual(legacyDecision(input));
117
+ });
118
+
119
+ it("proves sticky precedence and assignment epoch are independently observable", () => {
120
+ const base = {
121
+ experiments: [experiment("money", [1, 0], 1, 1)],
122
+ visitorId: "money-path-visitor",
123
+ now: 123,
124
+ storedAssignments: { money: { variationId: "money_1", epoch: 1, assignedAt: 1 } },
125
+ } satisfies DecideAssignmentsInput;
126
+ expect(decideAssignments(base)[0]?.variationId).toBe("money_1");
127
+ expect(decideAssignments({ ...base, experiments: [experiment("money", [1, 0], 1, 2)] })[0]?.variationId).toBe("money_0");
128
+ });
129
+
130
+ it("blocks an excluded weekday before sticky assignment restore", () => {
131
+ const scheduled = {
132
+ ...experiment("weekday", [1, 0]),
133
+ weekdaySchedule: {
134
+ excludedDays: ["monday"] as ["monday"],
135
+ timezone: "UTC",
136
+ },
137
+ };
138
+ expect(decideAssignments({
139
+ experiments: [scheduled],
140
+ visitorId: "weekday-visitor",
141
+ now: RECENT_MONDAY_NOON.getTime(),
142
+ storedAssignments: {
143
+ weekday: {
144
+ variationId: "weekday_1",
145
+ epoch: 1,
146
+ assignedAt: 1,
147
+ },
148
+ },
149
+ })).toEqual([]);
150
+ });
151
+
152
+ it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY, -2, 0, 1.9, 3.2])(
153
+ "matches assignment-store epoch normalization for %s",
154
+ (assignmentEpoch) => {
155
+ const config = experiment("epoch", [1], 1, assignmentEpoch);
156
+ expect(
157
+ buildAssignment(config, config.variations[0], 0, "visitor").assignmentEpoch,
158
+ ).toBe(__assignmentStoreInternals.assignmentEpoch(config));
159
+ },
160
+ );
161
+ });
162
+
163
+ describe("per-URL variation blocks", () => {
164
+ const treatment: Experiment = {
165
+ id: "multi", status: "running", trafficAllocation: 1,
166
+ variations: [{ id: "v", weight: 1, mutations: [{ action: "text", selector: "h1", text: "default" }], css: "default-css", js: "default-js", urlBlocks: [
167
+ { id: "first", name: "First", urlRules: [{ type: "simple", pattern: "*/products/a*" }], mutations: [{ action: "text", selector: "h1", text: "first" }], css: "first-css" },
168
+ { id: "second", name: "Second", urlRules: [{ type: "simple", pattern: "*/products/*" }], mutations: [{ action: "text", selector: "h1", text: "second" }] },
169
+ ] }],
170
+ };
171
+ it("uses the first matching block", () => {
172
+ const assignment = decideAssignments({ experiments: [treatment], visitorId: "x", now: 1, url: "https://shop.test/products/a" })[0];
173
+ expect(assignment.mutations[0]).toMatchObject({ text: "first" });
174
+ expect(assignment.activeUrlBlockId).toBe("first");
175
+ expect(assignment.css).toBe("first-css");
176
+ expect(assignment.js).toBeUndefined();
177
+ });
178
+ it("falls back to the flat artifact when no block matches", () => {
179
+ const assignment = decideAssignments({ experiments: [treatment], visitorId: "x", now: 1, url: "https://shop.test/pages/about" })[0];
180
+ expect(assignment.mutations[0]).toMatchObject({ text: "default" });
181
+ expect(assignment.activeUrlBlockId).toBeUndefined();
182
+ expect(assignment.css).toBe("default-css");
183
+ expect(assignment.js).toBe("default-js");
184
+ });
185
+ it("keeps an empty fallback assigned as a no-op", () => {
186
+ const noOp = { ...treatment, variations: [{ ...treatment.variations[0], mutations: [], css: undefined, js: undefined }] };
187
+ const assignment = decideAssignments({ experiments: [noOp], visitorId: "x", now: 1, url: "https://shop.test/pages/about" })[0];
188
+ expect(assignment.variationId).toBe("v");
189
+ expect(assignment.mutations).toEqual([]);
190
+ });
191
+ it("never applies URL blocks to control", () => {
192
+ const control = { ...treatment, variations: [{ ...treatment.variations[0], isControl: true }] };
193
+ const assignment = decideAssignments({ experiments: [control], visitorId: "x", now: 1, url: "https://shop.test/products/a" })[0];
194
+ expect(assignment.mutations[0]).toMatchObject({ text: "default" });
195
+ });
196
+ });
@@ -0,0 +1,204 @@
1
+ import { matchUrlRules } from "./targeting";
2
+ import type { PersistedAssignmentRecord } from "./assignment-store";
3
+ import { chooseVariation, inTrafficAllocation } from "./bucket";
4
+ import { hashToFloat, variationHashToFloat } from "./hash";
5
+ import { weekdayScheduleBlocksDelivery } from "./weekday-schedule";
6
+ import type {
7
+ Assignment,
8
+ ExclusionGroupConfig,
9
+ Experiment,
10
+ Variation,
11
+ } from "./types";
12
+
13
+ export type StoredGroupSelection = {
14
+ experimentId: string;
15
+ epoch: number;
16
+ };
17
+
18
+ export interface DecideAssignmentsInput {
19
+ experiments: Experiment[];
20
+ visitorId: string;
21
+ storedAssignments?: Record<string, PersistedAssignmentRecord>;
22
+ storedGroupSelections?: Record<string, StoredGroupSelection>;
23
+ exclusionGroups?: ExclusionGroupConfig[];
24
+ holdout?: { enabled?: boolean; percentage?: number };
25
+ /** Supplied by the caller so the decision boundary never reads the clock. */
26
+ now: number;
27
+ /** Reserved for the Stage 2 bootstrap's already-targeted payload contract. */
28
+ url?: string;
29
+ }
30
+
31
+ function epoch(value: number | null | undefined): number {
32
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value as number)) : 1;
33
+ }
34
+
35
+ export function buildAssignment(
36
+ experiment: Experiment,
37
+ variation: Variation,
38
+ index: number,
39
+ hashValue: string,
40
+ url?: string,
41
+ ): Assignment {
42
+ const block = !variation.isControl && url
43
+ ? variation.urlBlocks?.find((candidate) => matchUrlRules(url, candidate.urlRules))
44
+ : undefined;
45
+ return {
46
+ experimentId: experiment.id,
47
+ experimentName: experiment.name,
48
+ variationId: variation.id,
49
+ pageActive: true,
50
+ activeUrlBlockId: block?.id,
51
+ index,
52
+ mutations: block ? block.mutations : variation.mutations,
53
+ value: variation.value,
54
+ featureFlag: experiment.featureFlag,
55
+ personalization: experiment.personalization,
56
+ kind: experiment.kind ?? (experiment.personalization
57
+ ? "personalization"
58
+ : experiment.featureFlag
59
+ ? "feature_flag"
60
+ : "experiment"),
61
+ experimentCss: experiment.css,
62
+ experimentJs: experiment.js,
63
+ experimentResetJs: experiment.resetJs,
64
+ css: block ? block.css : variation.css,
65
+ js: block ? block.js : variation.js,
66
+ resetJs: variation.resetJs,
67
+ externalCssUrl: variation.externalCssUrl,
68
+ externalJsUrl: variation.externalJsUrl,
69
+ harnessSession: variation.harnessSession,
70
+ isControl: Boolean(variation.isControl),
71
+ redirectUrl: variation.redirectUrl,
72
+ artifactSource: variation.artifactSource,
73
+ hashValue,
74
+ assignmentEpoch: epoch(experiment.assignmentEpoch),
75
+ };
76
+ }
77
+
78
+ function decideVariationIndex(
79
+ experiment: Experiment,
80
+ visitorId: string,
81
+ storedAssignments: Record<string, PersistedAssignmentRecord>,
82
+ ): number {
83
+ if (!experiment.variations.length) return -1;
84
+
85
+ const stored = storedAssignments[experiment.id];
86
+ if (stored && stored.epoch === epoch(experiment.assignmentEpoch)) {
87
+ const index = experiment.variations.findIndex(
88
+ (variation) => variation.id === stored.variationId,
89
+ );
90
+ if (index >= 0) {
91
+ return index;
92
+ }
93
+ }
94
+
95
+ const hashValue = hashToFloat(experiment.id, visitorId);
96
+ const allocation = experiment.trafficAllocation ?? 1;
97
+ if (!inTrafficAllocation(hashValue, allocation)) return -1;
98
+ const variationHashValue = allocation >= 1
99
+ ? hashValue
100
+ : variationHashToFloat(experiment.id, visitorId);
101
+ const index = chooseVariation(
102
+ variationHashValue,
103
+ experiment.variations.map((variation) => variation.weight),
104
+ );
105
+ return index < experiment.variations.length ? index : -1;
106
+ }
107
+
108
+ function heldOut(input: DecideAssignmentsInput): boolean {
109
+ const percentage = input.holdout?.percentage;
110
+ return input.holdout?.enabled === true && typeof percentage === "number" &&
111
+ percentage > 0 && percentage <= 10 &&
112
+ hashToFloat("apex:global-holdout:v1", input.visitorId) < percentage / 100;
113
+ }
114
+
115
+ /** Worker bootstrap entrypoint after its literal status and weekday filters. */
116
+ export function decideVariationIdsPregated(
117
+ input: DecideAssignmentsInput,
118
+ ): Array<[string, string]> {
119
+ if (heldOut(input)) return [];
120
+ const out: Array<[string, string]> = [];
121
+ for (const experiment of input.experiments) {
122
+ const index = decideVariationIndex(experiment, input.visitorId, input.storedAssignments ?? {});
123
+ if (index >= 0) out.push([experiment.id, experiment.variations[index].id]);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Compact projection of the same production bucketing decision for pre-paint handoff. */
129
+ export function decideVariationIds(input: DecideAssignmentsInput): Array<[string, string]> {
130
+ return decideVariationIdsPregated({
131
+ ...input,
132
+ experiments: input.experiments.filter((experiment) =>
133
+ !weekdayScheduleBlocksDelivery(experiment.weekdaySchedule, new Date(input.now))
134
+ ),
135
+ });
136
+ }
137
+
138
+ function applyExclusionGroups(
139
+ assignments: Assignment[],
140
+ groups: ExclusionGroupConfig[],
141
+ visitorId: string,
142
+ storedSelections: Record<string, StoredGroupSelection>,
143
+ ): Assignment[] {
144
+ const assignedIds = new Set(assignments.map((assignment) => assignment.experimentId));
145
+ const suppressed = new Set<string>();
146
+ for (const group of groups) {
147
+ if (!group?.id || !Array.isArray(group.members)) continue;
148
+ const conflicting = group.members.filter((member) => assignedIds.has(member.experimentId));
149
+ if (conflicting.length <= 1) continue;
150
+ const prior = storedSelections[group.id];
151
+ const priorMember = prior
152
+ ? conflicting.find((member) => member.experimentId === prior.experimentId)
153
+ : undefined;
154
+ const hasPositiveWeight = conflicting.some((member) => member.share > 0);
155
+ let winnerId: string;
156
+ if (
157
+ prior &&
158
+ prior.epoch === epoch(group.epoch) &&
159
+ priorMember &&
160
+ (priorMember.share > 0 || !hasPositiveWeight)
161
+ ) {
162
+ winnerId = prior.experimentId;
163
+ } else {
164
+ const configured = conflicting.map((member) =>
165
+ Number.isFinite(member.share) && member.share > 0 ? member.share : 0
166
+ );
167
+ const weights = configured.some((weight) => weight > 0)
168
+ ? configured
169
+ : configured.map(() => 1);
170
+ const index = chooseVariation(
171
+ hashToFloat(`${group.id}:exclusion`, visitorId),
172
+ weights,
173
+ );
174
+ winnerId = conflicting[Math.max(0, Math.min(index, conflicting.length - 1))].experimentId;
175
+ }
176
+ for (const member of conflicting) {
177
+ if (member.experimentId !== winnerId) suppressed.add(member.experimentId);
178
+ }
179
+ }
180
+ return assignments.filter((assignment) => !suppressed.has(assignment.experimentId));
181
+ }
182
+
183
+ /**
184
+ * Pure assignment boundary for the SDK and the future inline bootstrap.
185
+ * The caller owns targeting, storage reads/writes, time, and exposure effects.
186
+ */
187
+ export function decideAssignments(input: DecideAssignmentsInput): Assignment[] {
188
+ if (heldOut(input)) return [];
189
+
190
+ const assignments: Assignment[] = [];
191
+ for (const experiment of input.experiments) {
192
+ if (weekdayScheduleBlocksDelivery(experiment.weekdaySchedule, new Date(input.now))) {
193
+ continue;
194
+ }
195
+ const index = decideVariationIndex(experiment, input.visitorId, input.storedAssignments ?? {});
196
+ if (index >= 0) assignments.push(buildAssignment(experiment, experiment.variations[index], index, input.visitorId, input.url));
197
+ }
198
+ return applyExclusionGroups(
199
+ assignments,
200
+ input.exclusionGroups ?? [],
201
+ input.visitorId,
202
+ input.storedGroupSelections ?? {},
203
+ );
204
+ }