@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,287 @@
1
+ import { afterEach, beforeEach, vi } from "vitest";
2
+ import type { Assignment, Experiment } from "./types";
3
+
4
+ // Hermeticity: weekday tests pin the most recent UTC Monday at 12:00 instead
5
+ // of a literal calendar date.
6
+ export const RECENT_MONDAY_NOON = (() => {
7
+ const d = new Date();
8
+ d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7));
9
+ d.setUTCHours(12, 0, 0, 0);
10
+ return d;
11
+ })();
12
+
13
+ export function makeStorage() {
14
+ const map = new Map<string, string>();
15
+ return {
16
+ getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
17
+ key: (index: number) => Array.from(map.keys())[index] ?? null,
18
+ removeItem: (key: string) => { map.delete(key); },
19
+ setItem: (key: string, value: string) => { map.set(key, value); },
20
+ get length() { return map.size; },
21
+ };
22
+ }
23
+
24
+ export function makeClassList() {
25
+ const values = new Set<string>();
26
+ return {
27
+ add: (...tokens: string[]) => { tokens.forEach((token) => values.add(token)); },
28
+ remove: (...tokens: string[]) => { tokens.forEach((token) => values.delete(token)); },
29
+ contains: (token: string) => values.has(token),
30
+ };
31
+ }
32
+
33
+ export function appendedAntiFlickerStyle(): { id: string; remove: ReturnType<typeof vi.fn>; textContent: string } | undefined {
34
+ const calls = vi.mocked((globalThis as any).document.head.appendChild).mock.calls;
35
+ for (let index = calls.length - 1; index >= 0; index -= 1) {
36
+ const element = calls[index]?.[0];
37
+ if (element.id === "drip-antiflicker") return element;
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ export function readCookieValue(name: string): string | null {
43
+ const cookie = String((globalThis as any).document?.cookie ?? "");
44
+ const match = cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
45
+ return match?.[1] || null;
46
+ }
47
+
48
+ export function readDecodedDripContext(): string | null {
49
+ const value = readCookieValue("drip_ctx");
50
+ return value == null ? null : decodeURIComponent(value);
51
+ }
52
+
53
+ export function installCookieJar(initial = "") {
54
+ const cookies = new Map<string, string>();
55
+ for (const pair of initial.split(";")) {
56
+ const separator = pair.indexOf("=");
57
+ if (separator < 0) continue;
58
+ const name = pair.slice(0, separator).trim();
59
+ if (name) cookies.set(name, pair.slice(separator + 1).trim());
60
+ }
61
+ const writes: string[] = [];
62
+ Object.defineProperty((globalThis as any).document, "cookie", {
63
+ configurable: true,
64
+ get: () => Array.from(cookies, ([name, value]) => `${name}=${value}`).join("; "),
65
+ set: (value: string) => {
66
+ writes.push(value);
67
+ const [pair, ...attributes] = value.split(";");
68
+ const separator = pair.indexOf("=");
69
+ if (separator < 0) return;
70
+ const name = pair.slice(0, separator).trim();
71
+ const cookieValue = pair.slice(separator + 1).trim();
72
+ if (attributes.some((attribute) => attribute.trim().toLowerCase() === "max-age=0")) {
73
+ cookies.delete(name);
74
+ } else {
75
+ cookies.set(name, cookieValue);
76
+ }
77
+ },
78
+ });
79
+ return writes;
80
+ }
81
+
82
+ export function installMutationObserverStub() {
83
+ const observe = vi.fn();
84
+ const disconnect = vi.fn();
85
+ const takeRecords = vi.fn(() => []);
86
+ (globalThis as any).MutationObserver = class MutationObserver {
87
+ observe = observe;
88
+ disconnect = disconnect;
89
+ takeRecords = takeRecords;
90
+ constructor(_callback: MutationCallback) {}
91
+ };
92
+ return { observe, disconnect, takeRecords };
93
+ }
94
+
95
+ export async function flushDynamicImports() {
96
+ await vi.dynamicImportSettled();
97
+ await Promise.resolve();
98
+ await Promise.resolve();
99
+ await Promise.resolve();
100
+ }
101
+
102
+ export function configPayloadHash(experiments: unknown[]): string {
103
+ return JSON.stringify({
104
+ experiments,
105
+ exclusionGroups: [],
106
+ profile: null,
107
+ runtime: null,
108
+ signals: [],
109
+ commerce: null,
110
+ holdout: [false, 0, null],
111
+ });
112
+ }
113
+
114
+ export function decodeTestHandover(token: string): Record<string, unknown> {
115
+ const payload = token.split(".")[0];
116
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
117
+ return JSON.parse(atob(normalized + "=".repeat((4 - normalized.length % 4) % 4)));
118
+ }
119
+
120
+ export function verifiedHandoverResponse(
121
+ input: RequestInfo | URL,
122
+ init: RequestInit | undefined,
123
+ shopId = "shop_123",
124
+ ): Response | null {
125
+ if (!String(input).endsWith("/v1/handover/verify")) return null;
126
+ const body = JSON.parse(String(init?.body)) as { verifyToken: string };
127
+ return new Response(JSON.stringify({
128
+ payload: { ...decodeTestHandover(body.verifyToken), shop: shopId },
129
+ }), { status: 200 });
130
+ }
131
+ export let windowListeners: Record<string, Array<() => void>>;
132
+ export let documentListeners: Record<string, Array<(...args: any[]) => void>>;
133
+ export const originalWindow = (globalThis as any).window;
134
+ export const originalDocument = (globalThis as any).document;
135
+ export const originalHistory = (globalThis as any).history;
136
+ export const originalLocation = (globalThis as any).location;
137
+ export const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
138
+ export const originalRequestIdleCallback = Object.getOwnPropertyDescriptor(globalThis, "requestIdleCallback");
139
+ export const originalCancelIdleCallback = Object.getOwnPropertyDescriptor(globalThis, "cancelIdleCallback");
140
+ export const originalLocalStorage = (globalThis as any).localStorage;
141
+ export const originalSessionStorage = (globalThis as any).sessionStorage;
142
+ export const originalCustomEvent = (globalThis as any).CustomEvent;
143
+ export const originalMutationObserver = (globalThis as any).MutationObserver;
144
+ export const originalElement = (globalThis as any).Element;
145
+ export const originalNode = (globalThis as any).Node;
146
+ export const originalFetch = globalThis.fetch;
147
+
148
+ export function registerInitTestLifecycle() {
149
+
150
+
151
+ beforeEach(() => {
152
+ vi.resetModules();
153
+ vi.useFakeTimers();
154
+ vi.spyOn(Math, "random").mockReturnValue(0.99);
155
+ delete (globalThis as any).__drip;
156
+ delete (globalThis as any).__dripPredecided;
157
+ delete (globalThis as any).__dripReplayRecorder;
158
+ delete (globalThis as any).Shopify;
159
+ delete (globalThis as any).hj;
160
+ delete (globalThis as any).adbq;
161
+ delete (globalThis as any).posthog;
162
+ delete (globalThis as any).OnetrustActiveGroups;
163
+ delete (globalThis as any).UC_UI;
164
+ delete (globalThis as any).Cookiebot;
165
+ delete (globalThis as any).CCM;
166
+ delete (globalThis as any).Pandectes;
167
+ delete (globalThis as any).CookieScript;
168
+ delete (globalThis as any).dr_cookiebanner_options;
169
+ windowListeners = {};
170
+ documentListeners = {};
171
+
172
+ const windowStub: Record<string, any> = {
173
+ addEventListener: vi.fn((type: string, handler: () => void) => {
174
+ windowListeners[type] = windowListeners[type] ?? [];
175
+ windowListeners[type].push(handler);
176
+ }),
177
+ clearInterval: (id: number) => clearInterval(id),
178
+ clearTimeout: (id: number) => clearTimeout(id),
179
+ innerHeight: 800,
180
+ removeEventListener: vi.fn((type: string, handler: () => void) => {
181
+ windowListeners[type] = (windowListeners[type] ?? []).filter((listener) => listener !== handler);
182
+ }),
183
+ setInterval: (fn: TimerHandler, ms?: number) => setInterval(fn, ms),
184
+ setTimeout: (fn: TimerHandler, ms?: number) => setTimeout(fn, ms),
185
+ Shopify: undefined,
186
+ };
187
+ windowStub.self = windowStub;
188
+ windowStub.top = windowStub;
189
+
190
+ (globalThis as any).window = windowStub;
191
+ (globalThis as any).location = {
192
+ hash: "",
193
+ hostname: "plain.test",
194
+ href: "https://plain.test/",
195
+ origin: "https://plain.test",
196
+ pathname: "/",
197
+ reload: vi.fn(),
198
+ search: "",
199
+ };
200
+ (globalThis as any).history = {
201
+ pushState: vi.fn(),
202
+ replaceState: vi.fn(),
203
+ };
204
+ (globalThis as any).document = {
205
+ addEventListener: vi.fn((type: string, handler: () => void) => {
206
+ documentListeners[type] = documentListeners[type] ?? [];
207
+ documentListeners[type].push(handler);
208
+ }),
209
+ body: { scrollHeight: 1200 },
210
+ createElement: vi.fn((tag: string) => ({
211
+ id: "",
212
+ remove: vi.fn(),
213
+ textContent: "",
214
+ tagName: tag.toUpperCase(),
215
+ })),
216
+ dispatchEvent: vi.fn(),
217
+ documentElement: {
218
+ classList: makeClassList(),
219
+ scrollHeight: 1200,
220
+ },
221
+ getElementById: vi.fn(() => null),
222
+ head: { appendChild: vi.fn() },
223
+ querySelector: vi.fn(() => null),
224
+ querySelectorAll: vi.fn(() => []),
225
+ referrer: "",
226
+ removeEventListener: vi.fn(),
227
+ title: "Plain probe",
228
+ };
229
+ Object.defineProperty(globalThis, "navigator", {
230
+ configurable: true,
231
+ value: { userAgent: "Mozilla/5.0", doNotTrack: "0", sendBeacon: vi.fn(() => true) },
232
+ });
233
+ (globalThis as any).localStorage = makeStorage();
234
+ (globalThis as any).sessionStorage = makeStorage();
235
+ (globalThis as any).CustomEvent = class CustomEvent {
236
+ type: string;
237
+ constructor(type: string) {
238
+ this.type = type;
239
+ }
240
+ };
241
+ globalThis.fetch = vi.fn(async () => new Response("ok", { status: 202 })) as any;
242
+ });
243
+
244
+
245
+ afterEach(async () => {
246
+ await vi.dynamicImportSettled();
247
+ vi.doUnmock("./shopify-cart");
248
+ vi.useRealTimers();
249
+ vi.restoreAllMocks();
250
+ delete (globalThis as any).__drip;
251
+ delete (globalThis as any).__dripReplayRecorder;
252
+ delete (globalThis as any).Shopify;
253
+ delete (globalThis as any).hj;
254
+ delete (globalThis as any).adbq;
255
+ delete (globalThis as any).posthog;
256
+ delete (globalThis as any).OnetrustActiveGroups;
257
+ delete (globalThis as any).UC_UI;
258
+ delete (globalThis as any).Cookiebot;
259
+ delete (globalThis as any).CCM;
260
+ delete (globalThis as any).Pandectes;
261
+ delete (globalThis as any).dr_cookiebanner_options;
262
+ (globalThis as any).window = originalWindow;
263
+ (globalThis as any).document = originalDocument;
264
+ (globalThis as any).history = originalHistory;
265
+ (globalThis as any).location = originalLocation;
266
+ if (originalNavigator) {
267
+ Object.defineProperty(globalThis, "navigator", originalNavigator);
268
+ }
269
+ if (originalRequestIdleCallback) {
270
+ Object.defineProperty(globalThis, "requestIdleCallback", originalRequestIdleCallback);
271
+ } else {
272
+ delete (globalThis as any).requestIdleCallback;
273
+ }
274
+ if (originalCancelIdleCallback) {
275
+ Object.defineProperty(globalThis, "cancelIdleCallback", originalCancelIdleCallback);
276
+ } else {
277
+ delete (globalThis as any).cancelIdleCallback;
278
+ }
279
+ (globalThis as any).localStorage = originalLocalStorage;
280
+ (globalThis as any).sessionStorage = originalSessionStorage;
281
+ (globalThis as any).CustomEvent = originalCustomEvent;
282
+ (globalThis as any).MutationObserver = originalMutationObserver;
283
+ (globalThis as any).Element = originalElement;
284
+ (globalThis as any).Node = originalNode;
285
+ globalThis.fetch = originalFetch;
286
+ });
287
+ }
@@ -0,0 +1,184 @@
1
+ import { gzipSync } from "node:zlib";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { INLINE_BOOTSTRAP_CODE } from "../../worker/src/_inline-bootstrap";
4
+ import { writePersistedAssignments } from "./assignment-store";
5
+ import { decideAssignments } from "./decide-assignments";
6
+ import { browserGroupStickyStore, resolveExclusionGroups } from "./exclusion-groups";
7
+ import { hashToFloat } from "./hash";
8
+ import type { Experiment } from "./types";
9
+
10
+ const variation = (id: string, weight: number) => ({ id, name: id, weight, mutations: [] });
11
+ const experiment = (id: string, weights = [0.5, 0.5], assignmentEpoch = 1): Experiment => ({
12
+ id, name: id, status: "running", assignmentEpoch,
13
+ variations: weights.map((weight, index) => variation(`${id}_${index}`, weight)),
14
+ });
15
+
16
+ let storageReads = 0;
17
+ let values: Map<string, string>;
18
+ let cookies: Map<string, string>;
19
+
20
+ function bootstrap(input: Record<string, unknown>) {
21
+ return globalThis.__dripComputePredecided({
22
+ experiments: [], configRevision: 7, url: "https://shop.test/products/a",
23
+ provisionalVisitorId: "provisional", ...input,
24
+ });
25
+ }
26
+
27
+ describe("generated inline bootstrap parity", () => {
28
+ beforeEach(() => {
29
+ values = new Map();
30
+ cookies = new Map();
31
+ storageReads = 0;
32
+ Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
33
+ clear: () => values.clear(),
34
+ getItem: (key: string) => { storageReads += 1; return values.get(key) ?? null; },
35
+ setItem: (key: string, value: string) => values.set(key, value),
36
+ removeItem: (key: string) => values.delete(key),
37
+ }});
38
+ Object.defineProperty(globalThis, "document", { configurable: true, value: {
39
+ get cookie() { return [...cookies].map(([key, value]) => `${key}=${value}`).join("; "); },
40
+ set cookie(raw: string) {
41
+ const [pair] = raw.split(";");
42
+ const index = pair.indexOf("=");
43
+ const key = pair.slice(0, index);
44
+ const value = pair.slice(index + 1);
45
+ if (/Max-Age=0/i.test(raw)) cookies.delete(key); else cookies.set(key, value);
46
+ },
47
+ }});
48
+ Object.defineProperty(globalThis, "navigator", { configurable: true, value: { doNotTrack: "0" } });
49
+ (0, eval)(INLINE_BOOTSTRAP_CODE);
50
+ });
51
+
52
+ it("round-trips production assignment writes across sticky and epoch behavior", () => {
53
+ writePersistedAssignments({
54
+ sticky: { variationId: "sticky_1", epoch: 2, assignedAt: 10 },
55
+ epoch: { variationId: "epoch_1", epoch: 2, assignedAt: 11 },
56
+ });
57
+ values.set("drip_uid", "returning-visitor");
58
+ const experiments = [experiment("sticky", [1, 0], 2), experiment("epoch", [1, 0], 3)];
59
+ const actual = bootstrap({ experiments });
60
+ expect(actual.visitorId).toBe("returning-visitor");
61
+ expect(actual.decision).toEqual(decideAssignments({
62
+ experiments, visitorId: "returning-visitor", now: expect.any(Number),
63
+ url: "https://shop.test/products/a",
64
+ storedAssignments: {
65
+ sticky: { variationId: "sticky_1", epoch: 2, assignedAt: 10 },
66
+ epoch: { variationId: "epoch_1", epoch: 2, assignedAt: 11 },
67
+ },
68
+ }).map((item) => [item.experimentId, item.variationId]));
69
+ expect(actual.decision.find((item: any) => item[0] === "sticky")?.[1]).toBe("sticky_1");
70
+ expect(actual.decision.find((item: any) => item[0] === "epoch")?.[1]).toBe("epoch_0");
71
+ });
72
+
73
+ it("declines custom-hash predecisions while preserving default-hash decisions", () => {
74
+ const visitorId = "visitor-2";
75
+ const attributeValue = "attribute-2";
76
+ const customHashExperiment = {
77
+ ...experiment("hash-attribute"),
78
+ trafficAllocation: 0.5,
79
+ hashAttribute: "plan",
80
+ };
81
+ const defaultHashExperiment = {
82
+ ...experiment("default-0"),
83
+ trafficAllocation: 0.5,
84
+ };
85
+ expect(hashToFloat(customHashExperiment.id, visitorId)).toBeLessThan(0.5);
86
+ expect(hashToFloat(customHashExperiment.id, attributeValue)).toBeGreaterThanOrEqual(0.5);
87
+ expect(hashToFloat(defaultHashExperiment.id, visitorId)).toBeLessThan(0.5);
88
+
89
+ values.set("drip_uid", visitorId);
90
+ const actual = bootstrap({ experiments: [customHashExperiment, defaultHashExperiment] });
91
+ expect(actual.decision.map((item: any) => item[0])).toEqual(["default-0"]);
92
+ });
93
+
94
+ it("round-trips the production exclusion-group write and resolves after targeting", () => {
95
+ browserGroupStickyStore.write("group", { experimentId: "ineligible", epoch: 4 });
96
+ values.set("drip_uid", "returning-visitor");
97
+ const experiments = [experiment("ineligible"), experiment("eligible")];
98
+ const predecided = bootstrap({ experiments });
99
+ expect(predecided.decision.map((item: any) => item[0])).toEqual(["ineligible", "eligible"]);
100
+ expect(predecided.groups).toEqual({ group: ["ineligible", 4] });
101
+
102
+ const allAssignments = decideAssignments({ experiments, visitorId: predecided.visitorId, now: 1, url: "https://shop.test/products/a" });
103
+ const sticky = resolveExclusionGroups(allAssignments, [{
104
+ id: "group", epoch: 4,
105
+ members: [{ experimentId: "ineligible", share: 1 }, { experimentId: "eligible", share: 1 }],
106
+ }], {
107
+ userId: predecided.visitorId,
108
+ store: {
109
+ read: (id) => {
110
+ const value = predecided.groups[id];
111
+ return value ? { experimentId: value[0], epoch: value[1] } : null;
112
+ },
113
+ write: () => undefined,
114
+ },
115
+ });
116
+ expect(sticky.assignments.map((item) => item.experimentId)).toEqual(["ineligible"]);
117
+
118
+ // This is the SDK ordering: page targeting removes the prior winner first,
119
+ // then exclusion resolution sees the eligible subset and serves its member.
120
+ const eligibleOnly = decideAssignments({ experiments: [experiments[1]], visitorId: predecided.visitorId, now: 1, url: "https://shop.test/products/a" });
121
+ const resolved = resolveExclusionGroups(eligibleOnly, [{
122
+ id: "group", epoch: 4,
123
+ members: [{ experimentId: "ineligible", share: 1 }, { experimentId: "eligible", share: 1 }],
124
+ }], { userId: predecided.visitorId, persist: false });
125
+ expect(resolved.assignments.map((item) => item.experimentId)).toEqual(["eligible"]);
126
+ });
127
+
128
+ it.each([
129
+ ["required without consent", { consentModeEnabled: true }],
130
+ ["explicitly denied", { requireConsent: true, hasConsent: false }],
131
+ ["DNT", { respectDoNotTrack: true, dnt: "1" }],
132
+ ])("uses only the provisional id and reads no persistent stores when %s", (_label, options) => {
133
+ values.set("drip_uid", "persistent-id");
134
+ values.set("drip_assignments_v1", JSON.stringify({ exp: ["exp_1", 1, 1] }));
135
+ values.set("drip_xgroups_v1", JSON.stringify({ group: ["exp", 1] }));
136
+ Object.defineProperty(globalThis, "navigator", { configurable: true, value: { doNotTrack: options.dnt ?? "0" } });
137
+ storageReads = 0;
138
+ const result = bootstrap({ experiments: [experiment("exp")], ...options });
139
+ expect(result.visitorId).toBe("provisional");
140
+ expect(result.groups).toEqual({});
141
+ expect(storageReads).toBe(0);
142
+ });
143
+
144
+ it("allows the real consent cookie before reading persistent stores", () => {
145
+ cookies.set("drip_consent", "1");
146
+ values.set("drip_uid", "consented-id");
147
+ const result = bootstrap({ consentModeEnabled: true });
148
+ expect(result.visitorId).toBe("consented-id");
149
+ expect(storageReads).toBeGreaterThan(0);
150
+ });
151
+
152
+ it("fails closed for CookieScript when only a residual consent cookie remains", () => {
153
+ cookies.set("drip_consent", "1");
154
+ values.set("drip_uid", "stale-consented-id");
155
+ storageReads = 0;
156
+ const result = bootstrap({ consentModeEnabled: true, consentProvider: "cookiescript" });
157
+ expect(result.visitorId).toBe("provisional");
158
+ expect(result.groups).toEqual({});
159
+ expect(storageReads).toBe(0);
160
+ });
161
+
162
+ it("uses the stored visitor id when CookieScript consent is available from its cookie", () => {
163
+ cookies.set("CookieScriptConsent", encodeURIComponent(JSON.stringify({
164
+ action: "accept",
165
+ categories: JSON.stringify(["performance"]),
166
+ })));
167
+ values.set("drip_uid", "cookiescript-consented-id");
168
+ const result = bootstrap({
169
+ consentModeEnabled: true,
170
+ consentProvider: "cookiescript",
171
+ consentRequiredGroup: "analytics",
172
+ });
173
+ expect(result.visitorId).toBe("cookiescript-consented-id");
174
+ expect(result.consentAllowed).toBe(true);
175
+ });
176
+
177
+ it("stays within the 2KB gzip inline budget", () => {
178
+ expect(gzipSync(INLINE_BOOTSTRAP_CODE).byteLength).toBeLessThanOrEqual(2048);
179
+ });
180
+ });
181
+
182
+ declare global {
183
+ var __dripComputePredecided: (input: any) => any;
184
+ }
@@ -0,0 +1,73 @@
1
+ import { evaluateSynchronousConsentForProvider, readConsentCookie, readCookieScriptConsent, STORAGE_ALLOWED } from "./consent-evaluation";
2
+ import { decodePersistedAssignments, decodePersistedGroupSelections, type CompactGroupSelections } from "./persistence-decoders";
3
+ import { decideVariationIdsPregated } from "./decide-assignments";
4
+ import type { Experiment } from "./types";
5
+
6
+ export type PredecidedAssignments = {
7
+ decision: Array<[experimentId: string, variationId: string]>;
8
+ configRevision: number;
9
+ visitorId: string;
10
+ groups: CompactGroupSelections;
11
+ consentAllowed: boolean;
12
+ };
13
+
14
+ function local(name: string): string {
15
+ try { return localStorage.getItem(name) || ""; } catch { return ""; }
16
+ }
17
+
18
+ function dnt(): string {
19
+ const nav = globalThis.navigator as Navigator & { msDoNotTrack?: string };
20
+ return nav.doNotTrack || nav.msDoNotTrack || (globalThis as typeof globalThis & { doNotTrack?: string }).doNotTrack || "";
21
+ }
22
+
23
+ /** Synchronous, read-only bootstrap entrypoint. Bundled into the Worker snippet. */
24
+ export function computePredecided(input: {
25
+ experiments: Experiment[];
26
+ holdout?: { enabled?: boolean; percentage?: number };
27
+ configRevision: number;
28
+ url: string;
29
+ provisionalVisitorId: string;
30
+ requireConsent?: boolean;
31
+ hasConsent?: boolean;
32
+ consentModeEnabled?: boolean;
33
+ consentProvider?: "custom" | "onetrust" | "usercentrics" | "cookiebot" | "ccm19" | "pandectes" | "cookiescript" | "borlabs";
34
+ consentRequiredGroup?: string;
35
+ respectDoNotTrack?: boolean;
36
+ }): PredecidedAssignments {
37
+ // Consent and DNT are evaluated before any identity or assignment store read.
38
+ const storageAllowed = Boolean(evaluateSynchronousConsentForProvider({
39
+ requireConsent: input.requireConsent,
40
+ hasConsent: input.hasConsent,
41
+ consentModeEnabled: input.consentModeEnabled,
42
+ configuredConsentGranted: input.consentProvider === "cookiescript"
43
+ ? readCookieScriptConsent(input.consentRequiredGroup)
44
+ : null,
45
+ provider: input.consentProvider,
46
+ consentCookieGranted: readConsentCookie("drip_consent") === "1",
47
+ respectDoNotTrack: input.respectDoNotTrack,
48
+ doNotTrackValue: dnt(),
49
+ }) & STORAGE_ALLOWED);
50
+ const visitorId = storageAllowed
51
+ ? local("drip_uid") || readConsentCookie("drip_uid") || input.provisionalVisitorId
52
+ : input.provisionalVisitorId;
53
+ const storedAssignments = storageAllowed
54
+ ? decodePersistedAssignments(local("drip_assignments_v1"), readConsentCookie("drip_assignments_v1"))
55
+ : {};
56
+ const storedGroupSelections = storageAllowed
57
+ ? decodePersistedGroupSelections(local("drip_xgroups_v1"), readConsentCookie("drip_xgroups_v1"))
58
+ : {};
59
+ return {
60
+ decision: decideVariationIdsPregated({
61
+ experiments: input.experiments.filter((experiment) => !experiment.hashAttribute),
62
+ holdout: input.holdout,
63
+ visitorId,
64
+ storedAssignments,
65
+ now: Date.now(),
66
+ url: input.url,
67
+ }),
68
+ configRevision: input.configRevision,
69
+ visitorId,
70
+ groups: storedGroupSelections,
71
+ consentAllowed: storageAllowed,
72
+ };
73
+ }
package/src/js.test.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { loadExternalJs, setJsPolicy } from "./js";
3
+ import {
4
+ loadExternalJs,
5
+ removeExternalJsForOwner,
6
+ setJsPolicy,
7
+ } from "./js";
4
8
 
5
9
  describe("external JavaScript policy", () => {
6
10
  afterEach(() => {
@@ -23,4 +27,50 @@ describe("external JavaScript policy", () => {
23
27
  script.onload?.(new Event("load"));
24
28
  await loading;
25
29
  });
30
+
31
+ it("keeps shared external JavaScript until its final Page owner tears down", async () => {
32
+ const loading = loadExternalJs(
33
+ "https://example.com/shared.js",
34
+ "shared",
35
+ ["page-a"],
36
+ );
37
+ const script = document.querySelector("script") as HTMLScriptElement;
38
+ script.onload?.(new Event("load"));
39
+ await loading;
40
+
41
+ await loadExternalJs(
42
+ "https://example.com/shared.js",
43
+ "shared",
44
+ ["page-b"],
45
+ );
46
+ removeExternalJsForOwner("page-a");
47
+ expect(document.querySelector("script")).toBe(script);
48
+
49
+ removeExternalJsForOwner("page-b");
50
+ expect(document.querySelector("script")).toBeNull();
51
+ });
52
+
53
+ it("removes single-owner external JavaScript and reloads it on reactivation", async () => {
54
+ const firstLoading = loadExternalJs(
55
+ "https://example.com/page-a.js",
56
+ "page-a",
57
+ ["page-a"],
58
+ );
59
+ const firstScript = document.querySelector("script") as HTMLScriptElement;
60
+ firstScript.onload?.(new Event("load"));
61
+ await firstLoading;
62
+
63
+ removeExternalJsForOwner("page-a");
64
+ expect(document.querySelector("script")).toBeNull();
65
+
66
+ const secondLoading = loadExternalJs(
67
+ "https://example.com/page-a.js",
68
+ "page-a",
69
+ ["page-a"],
70
+ );
71
+ const secondScript = document.querySelector("script") as HTMLScriptElement;
72
+ expect(secondScript).not.toBe(firstScript);
73
+ secondScript.onload?.(new Event("load"));
74
+ await secondLoading;
75
+ });
26
76
  });