@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,1211 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { Assignment } from "./types";
3
+ import {
4
+ RECENT_MONDAY_NOON, documentListeners, windowListeners, appendedAntiFlickerStyle, configPayloadHash, flushDynamicImports, installCookieJar, installMutationObserverStub, makeClassList, makeStorage, readCookieValue, readDecodedDripContext, registerInitTestLifecycle, verifiedHandoverResponse,
5
+ } from "./init-test-helpers";
6
+
7
+ describe("init anti flicker", () => {
8
+ registerInitTestLifecycle();
9
+
10
+
11
+ it("clears a superseded visual hydration gate for an empty-project config", async () => {
12
+ const cachedExperiments = [
13
+ {
14
+ id: "exp_cached_visual",
15
+ status: "running",
16
+ trafficAllocation: 1,
17
+ variations: [{ id: "var_cached_visual", weight: 1, mutations: [], js: "window.__cachedVisual=true;" }],
18
+ },
19
+ ];
20
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
21
+ fetchedAt: Date.now(),
22
+ hash: "cached-visual-config",
23
+ experiments: cachedExperiments,
24
+ runtime: { spa: { applyTiming: "after_hydration", hydrationTimeoutMs: 3_000 } },
25
+ }));
26
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => (
27
+ String(input).includes("/config")
28
+ ? new Response(JSON.stringify({ experiments: [] }), { status: 200 })
29
+ : new Response("ok", { status: 202 })
30
+ )) as any;
31
+ const { flush, getDiagnostics, init, track } = await import("./index");
32
+
33
+ init({
34
+ antiFlickerMode: "off",
35
+ endpoint: "https://events.test",
36
+ lateConfigPolicy: "apply",
37
+ perfFlags: { perf_empty_project_v1: true },
38
+ profile: { platform: "shopify", version: 1 },
39
+ runtimeFeatures: { sdkVariant: "live" },
40
+ runtimeProfile: "empty_project",
41
+ shopId: "shop_123",
42
+ trackingStart: "immediate",
43
+ });
44
+ for (let attempt = 0; attempt < 20 && getDiagnostics().configSource !== "network"; attempt += 1) {
45
+ await Promise.resolve();
46
+ }
47
+ expect(getDiagnostics().configSource).toBe("network");
48
+
49
+ track("empty_project_ready");
50
+ await flush();
51
+
52
+ const eventsCall = vi.mocked(globalThis.fetch).mock.calls.find(
53
+ (call) => call[0] === "https://events.test/v1/events",
54
+ );
55
+ const events = JSON.parse(String(eventsCall?.[1]?.body)).events;
56
+ expect(events).toEqual(expect.arrayContaining([
57
+ expect.objectContaining({ type: "empty_project_ready" }),
58
+ ]));
59
+ });
60
+
61
+
62
+ it("keeps cached anti-flicker armed when refresh fails before hydration", async () => {
63
+ installMutationObserverStub();
64
+ const title = { textContent: "Original" };
65
+ (globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
66
+ selector === "#product-title" ? [title] : []
67
+ ));
68
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
69
+ fetchedAt: Date.now(),
70
+ hash: "cached-hydration-config",
71
+ experiments: [{
72
+ id: "exp_cached_hydration",
73
+ status: "running",
74
+ trafficAllocation: 1,
75
+ variations: [{
76
+ id: "var_cached_hydration",
77
+ weight: 1,
78
+ mutations: [{ selector: "#product-title", action: "text", text: "Treatment" }],
79
+ }],
80
+ }],
81
+ runtime: { spa: { applyTiming: "after_hydration", hydrationTimeoutMs: 5_000 } },
82
+ }));
83
+ globalThis.fetch = vi.fn(async () => { throw new Error("refresh unavailable"); }) as any;
84
+ vi.spyOn(console, "error").mockImplementation(() => {});
85
+ const { hydrated, init } = await import("./index");
86
+
87
+ init({
88
+ antiFlickerMode: "full",
89
+ antiFlickerTimeoutMs: 5_000,
90
+ endpoint: "https://events.test",
91
+ shopId: "shop_123",
92
+ });
93
+ await vi.advanceTimersByTimeAsync(0);
94
+
95
+ expect(appendedAntiFlickerStyle()).toBeDefined();
96
+ expect(title.textContent).toBe("Original");
97
+
98
+ await vi.advanceTimersByTimeAsync(1_600);
99
+ expect(vi.mocked(globalThis.fetch).mock.calls.filter(
100
+ (call) => String(call[0]).includes("/config"),
101
+ )).toHaveLength(3);
102
+ expect(appendedAntiFlickerStyle()).toBeDefined();
103
+ expect(title.textContent).toBe("Original");
104
+
105
+ hydrated();
106
+ await vi.advanceTimersByTimeAsync(0);
107
+
108
+ expect(title.textContent).toBe("Treatment");
109
+ });
110
+
111
+
112
+ it("reasserts SPA mutations immediately after reveal when hydration overwrites them", async () => {
113
+ const title = {
114
+ textContent: "Product detail loading",
115
+ };
116
+ (globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
117
+ selector === "#product-title" ? [title] : []
118
+ ));
119
+
120
+ const { init } = await import("./index");
121
+
122
+ init({
123
+ antiFlickerMode: "off",
124
+ endpoint: "https://events.test",
125
+ experiments: [
126
+ {
127
+ id: "exp_post_reveal_reassert",
128
+ status: "running",
129
+ trafficAllocation: 1,
130
+ variations: [
131
+ {
132
+ id: "var_post_reveal_reassert",
133
+ weight: 1,
134
+ mutations: [{ selector: "#product-title", action: "text", text: "Apex post-reveal text" }],
135
+ },
136
+ ],
137
+ },
138
+ ],
139
+ pageTrigger: { defaultMode: "url_change" },
140
+ shopId: "shop_123",
141
+ spa: { activationMode: "manual", debounceMs: 500 },
142
+ trackingStart: "immediate",
143
+ });
144
+
145
+ expect(title.textContent).toBe("Apex post-reveal text");
146
+ title.textContent = "Hydrated product title";
147
+
148
+ await vi.advanceTimersByTimeAsync(0);
149
+
150
+ expect(title.textContent).toBe("Apex post-reveal text");
151
+ });
152
+
153
+
154
+ it("never early-reasserts a contaminated assignment after partial rollback", async () => {
155
+ const title = { textContent: "Control title" };
156
+ installMutationObserverStub();
157
+ let resolveEarlyAssignments: (() => Assignment[]) | null = null;
158
+ let reapplyEarlyAssignments: ((assignments: Assignment[]) => void) | null = null;
159
+ const earlyReassert = await import("./early-reassert");
160
+ vi.spyOn(earlyReassert, "installEarlyReassert").mockImplementation((input) => {
161
+ resolveEarlyAssignments = input.resolveAssignments;
162
+ reapplyEarlyAssignments = input.reapply;
163
+ return vi.fn();
164
+ });
165
+ (globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
166
+ selector === "#product-title" ? [title] : []
167
+ ));
168
+ (globalThis as any).__dripPredecided = {
169
+ decision: [["exp_early_contaminated", "var_early_contaminated"]],
170
+ configRevision: 1557,
171
+ visitorId: "visitor_early_contaminated",
172
+ groups: {},
173
+ };
174
+ const { init } = await import("./index");
175
+
176
+ init({
177
+ antiFlickerMode: "off",
178
+ delivery: { revision: 1557 },
179
+ experiments: [{
180
+ id: "exp_early_contaminated",
181
+ status: "running",
182
+ trafficAllocation: 1,
183
+ variations: [{
184
+ id: "var_early_contaminated",
185
+ weight: 1,
186
+ mutations: [
187
+ { selector: "#product-title", action: "text", text: "Treatment title" },
188
+ { selector: "#missing-cta", action: "text", text: "Missing CTA" },
189
+ ],
190
+ }],
191
+ }],
192
+ shopId: "shop_123",
193
+ spa: { activationMode: "manual", applyTiming: "early_reassert" },
194
+ trackingStart: "immediate",
195
+ userId: "visitor_early_contaminated",
196
+ });
197
+
198
+ expect(title.textContent).toBe("Control title");
199
+ expect(resolveEarlyAssignments).not.toBeNull();
200
+ expect(resolveEarlyAssignments!()).toEqual([]);
201
+ title.textContent = "Hydrated control title";
202
+ reapplyEarlyAssignments!(resolveEarlyAssignments!());
203
+ expect(title.textContent).toBe("Hydrated control title");
204
+ });
205
+
206
+
207
+ it("keeps a contaminated commerce price plane at control and suppresses exposure", async () => {
208
+ const title = {
209
+ textContent: "Product detail loading",
210
+ };
211
+ (globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
212
+ selector === "#product-title" ? [title] : []
213
+ ));
214
+
215
+ const commercePrice = await import("./commerce-price");
216
+ const applyCommercePrice = vi.spyOn(commercePrice, "applyCommercePrice");
217
+ const { flush, getCartAttributes, init, reInit, trackGoal } = await import("./index");
218
+
219
+ init({
220
+ antiFlickerMode: "off",
221
+ endpoint: "https://events.test",
222
+ commerce: {
223
+ price: {
224
+ revision: 1,
225
+ checksum: "a".repeat(64),
226
+ experiments: [{
227
+ experiment_id: "exp_delivery_health",
228
+ currency: "EUR",
229
+ variants: {
230
+ var_delivery_health: [{
231
+ variant_gid: "gid://shopify/ProductVariant/1557",
232
+ fixedPricePerUnit: "9.99",
233
+ }],
234
+ },
235
+ }],
236
+ },
237
+ },
238
+ experiments: [
239
+ {
240
+ id: "exp_delivery_health",
241
+ status: "running",
242
+ trafficAllocation: 1,
243
+ variations: [
244
+ {
245
+ id: "var_delivery_health",
246
+ weight: 1,
247
+ mutations: [
248
+ { selector: "#product-title", action: "text", text: "Apex delivery health" },
249
+ { selector: "#missing-cta", action: "text", text: "Missing CTA" },
250
+ ],
251
+ },
252
+ ],
253
+ goals: [{ id: "goal_contaminated", type: "custom", eventName: "contaminated" }],
254
+ },
255
+ ],
256
+ shopId: "shop_123",
257
+ spa: { activationMode: "manual" },
258
+ trackingStart: "immediate",
259
+ });
260
+
261
+ expect(title.textContent).toBe("Product detail loading");
262
+ expect(applyCommercePrice).toHaveBeenLastCalledWith(
263
+ expect.anything(),
264
+ [],
265
+ expect.any(Function),
266
+ expect.any(Function),
267
+ expect.any(Function),
268
+ );
269
+
270
+ await flush();
271
+
272
+ const events = (globalThis.fetch as any).mock.calls
273
+ .flatMap((call: [string, RequestInit]) => JSON.parse(String(call[1].body)).events);
274
+ const deliveryEvent = events.find((event: { type: string }) => event.type === "delivery_health");
275
+ const rollbackEvent = events.find((event: { type: string }) => event.type === "variant_contamination_rollback");
276
+
277
+ expect(deliveryEvent).toMatchObject({
278
+ experimentId: "exp_delivery_health",
279
+ variationId: "var_delivery_health",
280
+ data: {
281
+ expected_mutations: 2,
282
+ applied_mutations: 1,
283
+ selector_misses: 1,
284
+ apply_status: "partial",
285
+ activation_mode: "manual",
286
+ },
287
+ });
288
+ expect(rollbackEvent).toMatchObject({
289
+ experimentId: "exp_delivery_health",
290
+ variationId: "var_delivery_health",
291
+ data: { missed_selector_count: 1 },
292
+ });
293
+ expect(events.some((event: { type: string }) => event.type === "experiment_viewed")).toBe(false);
294
+
295
+ // Deferred post-reveal replay must not restore a transaction that rolled back.
296
+ title.textContent = "Hydrated product title";
297
+ await vi.advanceTimersByTimeAsync(500);
298
+ expect(title.textContent).toBe("Hydrated product title");
299
+
300
+ // A contaminated assignment is neither goal-attributable nor cart-stampable.
301
+ trackGoal("goal_contaminated");
302
+ expect(getCartAttributes()).toEqual(expect.arrayContaining([
303
+ { key: "apex_assignments", value: "[]" },
304
+ ]));
305
+ await flush();
306
+ const afterGoal = (globalThis.fetch as any).mock.calls
307
+ .flatMap((call: [string, RequestInit]) => JSON.parse(String(call[1].body)).events);
308
+ expect(afterGoal.some((event: { type: string }) => event.type === "goal")).toBe(false);
309
+
310
+ // SPA re-evaluation cannot recreate a pending tracker or emit health twice.
311
+ reInit();
312
+ await vi.advanceTimersByTimeAsync(1_000);
313
+ await flush();
314
+ const afterReevaluation = (globalThis.fetch as any).mock.calls
315
+ .flatMap((call: [string, RequestInit]) => JSON.parse(String(call[1].body)).events);
316
+ expect(afterReevaluation.filter((event: { type: string }) => event.type === "delivery_health")).toHaveLength(1);
317
+ expect(afterReevaluation.filter((event: { type: string }) => event.type === "variant_contamination_rollback")).toHaveLength(1);
318
+ });
319
+
320
+
321
+ it("suppresses exposure when a variant's only mutation misses", async () => {
322
+ (globalThis as any).document.querySelectorAll = vi.fn(() => []);
323
+ const onExperimentViewed = vi.fn();
324
+ const { flush, init } = await import("./index");
325
+
326
+ init({
327
+ antiFlickerMode: "off",
328
+ endpoint: "https://events.test",
329
+ experiments: [{
330
+ id: "exp_single_miss",
331
+ status: "running",
332
+ trafficAllocation: 1,
333
+ variations: [{
334
+ id: "var_single_miss",
335
+ weight: 1,
336
+ mutations: [{ selector: "#missing", action: "text", text: "Never applied" }],
337
+ }],
338
+ }],
339
+ onExperimentViewed,
340
+ shopId: "shop_123",
341
+ spa: { activationMode: "manual" },
342
+ trackingStart: "immediate",
343
+ });
344
+
345
+ await flush();
346
+ const events = (globalThis.fetch as any).mock.calls
347
+ .flatMap((call: [string, RequestInit]) => JSON.parse(String(call[1].body)).events);
348
+ expect(events.some((event: { type: string }) => event.type === "experiment_viewed")).toBe(false);
349
+ expect(events).toEqual(expect.arrayContaining([
350
+ expect.objectContaining({
351
+ type: "variant_contamination_rollback",
352
+ experimentId: "exp_single_miss",
353
+ variationId: "var_single_miss",
354
+ data: expect.objectContaining({ missed_selector_count: 1 }),
355
+ }),
356
+ ]));
357
+ expect(onExperimentViewed).not.toHaveBeenCalled();
358
+ });
359
+
360
+
361
+ it("reports delayed SPA selector matches as applied instead of expiring health as a miss", async () => {
362
+ let observerCallback: MutationCallback | null = null;
363
+ (globalThis as any).MutationObserver = class MutationObserver {
364
+ constructor(callback: MutationCallback) {
365
+ observerCallback = callback;
366
+ }
367
+ observe = vi.fn();
368
+ disconnect = vi.fn();
369
+ };
370
+ (globalThis as any).Node = {
371
+ ELEMENT_NODE: 1,
372
+ DOCUMENT_FRAGMENT_NODE: 11,
373
+ };
374
+ const routeNode = {
375
+ nodeType: 1,
376
+ textContent: "Route shell",
377
+ matches: vi.fn((selector: string) => selector === "[data-route='pdp']"),
378
+ querySelectorAll: vi.fn(() => []),
379
+ };
380
+ (globalThis as any).document.querySelectorAll = vi.fn(() => []);
381
+
382
+ const { flush, init } = await import("./index");
383
+
384
+ init({
385
+ antiFlickerMode: "off",
386
+ endpoint: "https://events.test",
387
+ experiments: [
388
+ {
389
+ id: "exp_spa_delivery_health",
390
+ status: "running",
391
+ trafficAllocation: 1,
392
+ variations: [
393
+ {
394
+ id: "var_spa_delivery_health",
395
+ weight: 1,
396
+ mutations: [
397
+ { selector: "[data-route='pdp']", action: "text", text: "Apex applied after SPA mount" },
398
+ ],
399
+ },
400
+ ],
401
+ },
402
+ ],
403
+ shopId: "shop_123",
404
+ spa: { activationMode: "observer" },
405
+ trackingStart: "immediate",
406
+ });
407
+
408
+ (observerCallback as MutationCallback | null)?.(
409
+ [{ type: "attributes", target: routeNode, addedNodes: [] } as unknown as MutationRecord],
410
+ {} as MutationObserver,
411
+ );
412
+ await Promise.resolve();
413
+ await flush();
414
+
415
+ let events = (globalThis.fetch as any).mock.calls
416
+ .flatMap((call: [string, RequestInit]) => JSON.parse(String(call[1].body)).events);
417
+ const deliveryEvent = events.find((event: { type: string }) => event.type === "delivery_health");
418
+
419
+ expect(routeNode.textContent).toBe("Apex applied after SPA mount");
420
+ expect(deliveryEvent).toMatchObject({
421
+ experimentId: "exp_spa_delivery_health",
422
+ variationId: "var_spa_delivery_health",
423
+ data: {
424
+ expected_mutations: 1,
425
+ applied_mutations: 1,
426
+ selector_misses: 0,
427
+ apply_status: "applied",
428
+ activation_mode: "observer",
429
+ },
430
+ });
431
+ expect(events).toEqual(expect.arrayContaining([
432
+ expect.objectContaining({
433
+ type: "experiment_viewed",
434
+ experimentId: "exp_spa_delivery_health",
435
+ variationId: "var_spa_delivery_health",
436
+ }),
437
+ ]));
438
+
439
+ vi.advanceTimersByTime(30_001);
440
+ await flush();
441
+ events = (globalThis.fetch as any).mock.calls
442
+ .flatMap((call: [string, RequestInit]) => JSON.parse(String(call[1].body)).events);
443
+ const healthEvents = events.filter((event: { type: string }) => event.type === "delivery_health");
444
+ expect(healthEvents).toHaveLength(1);
445
+ });
446
+
447
+
448
+ it("keeps pending mutation identity off revenue and analytics until clean resolution", async () => {
449
+ let observerCallback: MutationCallback | null = null;
450
+ (globalThis as any).MutationObserver = class MutationObserver {
451
+ constructor(callback: MutationCallback) {
452
+ observerCallback = callback;
453
+ }
454
+ observe = vi.fn();
455
+ disconnect = vi.fn();
456
+ };
457
+ (globalThis as any).Node = {
458
+ ELEMENT_NODE: 1,
459
+ DOCUMENT_FRAGMENT_NODE: 11,
460
+ };
461
+ const routeNode = {
462
+ nodeType: 1,
463
+ textContent: "Route shell",
464
+ matches: vi.fn((selector: string) => selector === "[data-route='pending']"),
465
+ querySelectorAll: vi.fn(() => []),
466
+ };
467
+ (globalThis as any).document.querySelectorAll = vi.fn(() => []);
468
+
469
+ const { flush, init, track, trackRevenue } = await import("./index");
470
+ init({
471
+ antiFlickerMode: "off",
472
+ endpoint: "https://events.test",
473
+ experiments: [{
474
+ id: "exp_pending_identity",
475
+ status: "running",
476
+ trafficAllocation: 1,
477
+ variations: [{
478
+ id: "var_pending_identity",
479
+ weight: 1,
480
+ mutations: [{
481
+ selector: "[data-route='pending']",
482
+ action: "text",
483
+ text: "Resolved treatment",
484
+ }],
485
+ }],
486
+ }],
487
+ shopId: "shop_123",
488
+ spa: { activationMode: "observer" },
489
+ trackingStart: "immediate",
490
+ });
491
+
492
+ track("analytics_probe", { phase: "pending" });
493
+ trackRevenue(55, { orderId: "pending-order" });
494
+ await flush();
495
+
496
+ let events = (globalThis.fetch as any).mock.calls
497
+ .flatMap((call: [string, RequestInit]) => JSON.parse(String(call[1].body)).events);
498
+ const pendingPageviews = events.filter((event: { type: string }) => event.type === "pageview");
499
+ expect(pendingPageviews).toHaveLength(1);
500
+ expect(pendingPageviews[0]).not.toHaveProperty("experimentId");
501
+ expect(pendingPageviews[0]).not.toHaveProperty("variationId");
502
+ expect(events.filter((event: { type: string }) => event.type === "experiment_pageview")).toEqual([]);
503
+ const pendingEvents = events.filter((event: { data?: { phase?: string; orderId?: string } }) =>
504
+ event.data?.phase === "pending" || event.data?.orderId === "pending-order"
505
+ );
506
+ expect(pendingEvents).toHaveLength(2);
507
+ for (const event of pendingEvents) {
508
+ expect(event).not.toHaveProperty("experimentId");
509
+ expect(event).not.toHaveProperty("variationId");
510
+ }
511
+
512
+ (observerCallback as MutationCallback | null)?.(
513
+ [{ type: "attributes", target: routeNode, addedNodes: [] } as unknown as MutationRecord],
514
+ {} as MutationObserver,
515
+ );
516
+ await Promise.resolve();
517
+ track("analytics_probe", { phase: "resolved" });
518
+ await flush();
519
+
520
+ events = (globalThis.fetch as any).mock.calls
521
+ .flatMap((call: [string, RequestInit]) => JSON.parse(String(call[1].body)).events);
522
+ expect(events).toEqual(expect.arrayContaining([
523
+ expect.objectContaining({
524
+ type: "analytics_probe",
525
+ experimentId: "exp_pending_identity",
526
+ variationId: "var_pending_identity",
527
+ data: expect.objectContaining({ phase: "resolved" }),
528
+ }),
529
+ ]));
530
+ });
531
+
532
+
533
+ it("keeps click goal listeners after dataLayer-triggered SPA reassignment", async () => {
534
+ const clickHandlers: Array<(event: { target: unknown }) => void> = [];
535
+ (globalThis as any).document.addEventListener = vi.fn((type: string, handler: (event: { target: unknown }) => void) => {
536
+ if (type === "click") clickHandlers.push(handler);
537
+ });
538
+
539
+ const { flush, getAssignments, init } = await import("./index");
540
+
541
+ (globalThis as any).window.dataLayer = [];
542
+ init({
543
+ antiFlickerMode: "off",
544
+ dataLayerTriggers: [
545
+ {
546
+ eventName: "virtPageView",
547
+ mappings: [{ from: "page_type", to: "page.type", valueType: "string" }],
548
+ trackPageview: false,
549
+ },
550
+ ],
551
+ endpoint: "https://events.test",
552
+ experiments: [
553
+ {
554
+ id: "exp_pdp_click",
555
+ status: "running",
556
+ targeting: {
557
+ segmentRules: [
558
+ {
559
+ id: "segment_pdp",
560
+ rules: {
561
+ version: 1,
562
+ operator: "and",
563
+ conditions: [{ signal: "page.type", operator: "equals", value: "pdp" }],
564
+ },
565
+ },
566
+ ],
567
+ },
568
+ goals: [
569
+ {
570
+ id: "goal_add_to_cart",
571
+ type: "click",
572
+ selector: "[data-action='add-cart']",
573
+ capture: [{ property: "liveRunId", source: "dataset", attribute: "data-live-run-id" }],
574
+ },
575
+ ],
576
+ trafficAllocation: 1,
577
+ variations: [{ id: "var_pdp", weight: 1, mutations: [] }],
578
+ },
579
+ ],
580
+ shopId: "shop_123",
581
+ trackingStart: "immediate",
582
+ });
583
+
584
+ expect(getAssignments()).toHaveLength(0);
585
+ (globalThis as any).window.dataLayer.push({ event: "virtPageView", page_type: "pdp" });
586
+ expect(getAssignments()).toHaveLength(1);
587
+
588
+ const matched = {
589
+ dataset: { liveRunId: "run-123" },
590
+ getAttribute: () => null,
591
+ textContent: "Add to cart",
592
+ };
593
+ const target = {
594
+ closest: (selector: string) => selector === "[data-action='add-cart']" ? matched : null,
595
+ };
596
+ for (const handler of clickHandlers) handler({ target });
597
+ await flush();
598
+
599
+ const calls = vi.mocked(globalThis.fetch).mock.calls;
600
+ const body = JSON.parse(String(calls.at(-1)?.[1]?.body ?? "{}"));
601
+ expect(body.events).toEqual(
602
+ expect.arrayContaining([
603
+ expect.objectContaining({
604
+ type: "goal",
605
+ data: expect.objectContaining({
606
+ goalId: "goal_add_to_cart",
607
+ liveRunId: "run-123",
608
+ }),
609
+ }),
610
+ ]),
611
+ );
612
+ });
613
+
614
+
615
+ it("captures DOM and global runtime signals before assignment evaluation", async () => {
616
+ (globalThis as any).window.Shopify = {
617
+ analytics: undefined,
618
+ routes: { root: "/de" },
619
+ };
620
+ (globalThis as any).Shopify = (globalThis as any).window.Shopify;
621
+ (globalThis as any).document.querySelector = vi.fn((selector: string) => {
622
+ if (selector === "[data-product-type]") {
623
+ return {
624
+ getAttribute: (name: string) => (name === "data-product-type" ? "recovery" : null),
625
+ textContent: "Recovery",
626
+ };
627
+ }
628
+ return null;
629
+ });
630
+
631
+ const { getAssignments, getDiagnostics, getSignals, init } = await import("./index");
632
+
633
+ init({
634
+ antiFlickerMode: "off",
635
+ endpoint: "https://events.test",
636
+ experiments: [
637
+ {
638
+ id: "exp_recovery",
639
+ status: "running",
640
+ targeting: {
641
+ segmentRules: [
642
+ {
643
+ id: "segment_recovery",
644
+ rules: {
645
+ version: 1,
646
+ operator: "and",
647
+ conditions: [
648
+ { signal: "product.type", operator: "equals", value: "recovery" },
649
+ { signal: "shopify.root", operator: "equals", value: "/de" },
650
+ ],
651
+ },
652
+ },
653
+ ],
654
+ },
655
+ trafficAllocation: 1,
656
+ variations: [{ id: "var_recovery", weight: 1, mutations: [] }],
657
+ },
658
+ ],
659
+ shopId: "shop_123",
660
+ signals: [
661
+ {
662
+ key: "product.type",
663
+ valueType: "string",
664
+ capture: { mode: "dom_selector", selector: "[data-product-type]", source: "attribute", attribute: "data-product-type" },
665
+ },
666
+ {
667
+ key: "shopify.root",
668
+ valueType: "string",
669
+ capture: { mode: "global", path: "Shopify.routes.root" },
670
+ },
671
+ ],
672
+ trackingStart: "immediate",
673
+ });
674
+
675
+ expect(getSignals()).toMatchObject({
676
+ "product.type": "recovery",
677
+ "shopify.root": "/de",
678
+ });
679
+ expect(getDiagnostics().signals.sources).toMatchObject({
680
+ "product.type": "dom_selector",
681
+ "shopify.root": "global",
682
+ });
683
+ expect(getAssignments()).toHaveLength(1);
684
+ });
685
+
686
+
687
+ it("keeps runtime signal captures out of persistent storage while consent is denied", async () => {
688
+ (globalThis as any).location.href = "https://plain.test/?utm_content=creator_a";
689
+ (globalThis as any).location.search = "?utm_content=creator_a";
690
+ (globalThis as any).Shopware = { State: { cart: { count: 2 } } };
691
+ (globalThis as any).window.dataLayer = [];
692
+ (globalThis as any).document.querySelector = vi.fn((selector: string) => {
693
+ if (selector === "[data-product-type]") {
694
+ return {
695
+ getAttribute: (name: string) => (name === "data-product-type" ? "recovery" : null),
696
+ textContent: "Recovery",
697
+ };
698
+ }
699
+ return null;
700
+ });
701
+
702
+ const { getAssignments, getDiagnostics, init, reInit } = await import("./index");
703
+
704
+ init({
705
+ antiFlickerMode: "off",
706
+ consentMode: { enabled: true },
707
+ dataLayerTriggers: [
708
+ {
709
+ eventName: "virtPageView",
710
+ mappings: [{ from: "page_type", to: "page.type", valueType: "string" }],
711
+ reEvaluate: true,
712
+ },
713
+ ],
714
+ endpoint: "https://events.test",
715
+ experiments: [
716
+ {
717
+ id: "exp_initial_runtime_signals",
718
+ status: "running",
719
+ targeting: {
720
+ segmentRules: [
721
+ {
722
+ id: "initial_runtime_signals",
723
+ rules: {
724
+ version: 1,
725
+ operator: "and",
726
+ conditions: [
727
+ { signal: "utm.content", operator: "equals", value: "creator_a" },
728
+ { signal: "product.type", operator: "equals", value: "recovery" },
729
+ { signal: "shopware.cart_count", operator: "equals", value: 2 },
730
+ ],
731
+ },
732
+ },
733
+ ],
734
+ },
735
+ trafficAllocation: 1,
736
+ variations: [{ id: "var_initial_runtime_signals", weight: 1, mutations: [] }],
737
+ },
738
+ {
739
+ id: "exp_data_layer_runtime_signal",
740
+ status: "running",
741
+ targeting: {
742
+ segmentRules: [
743
+ {
744
+ id: "data_layer_runtime_signal",
745
+ rules: {
746
+ version: 1,
747
+ operator: "and",
748
+ conditions: [{ signal: "page.type", operator: "equals", value: "pdp" }],
749
+ },
750
+ },
751
+ ],
752
+ },
753
+ trafficAllocation: 1,
754
+ variations: [{ id: "var_data_layer_runtime_signal", weight: 1, mutations: [] }],
755
+ },
756
+ {
757
+ id: "exp_manual_runtime_signal",
758
+ status: "running",
759
+ targeting: {
760
+ segmentRules: [
761
+ {
762
+ id: "manual_runtime_signal",
763
+ rules: {
764
+ version: 1,
765
+ operator: "and",
766
+ conditions: [{ signal: "manual.signal", operator: "equals", value: "yes" }],
767
+ },
768
+ },
769
+ ],
770
+ },
771
+ trafficAllocation: 1,
772
+ variations: [{ id: "var_manual_runtime_signal", weight: 1, mutations: [] }],
773
+ },
774
+ {
775
+ id: "exp_queued_runtime_signal",
776
+ status: "running",
777
+ targeting: {
778
+ segmentRules: [
779
+ {
780
+ id: "queued_runtime_signal",
781
+ rules: {
782
+ version: 1,
783
+ operator: "and",
784
+ conditions: [{ signal: "queued.signal", operator: "equals", value: "yes" }],
785
+ },
786
+ },
787
+ ],
788
+ },
789
+ trafficAllocation: 1,
790
+ variations: [{ id: "var_queued_runtime_signal", weight: 1, mutations: [] }],
791
+ },
792
+ ],
793
+ hasConsent: false,
794
+ shopId: "shop_123",
795
+ signals: [
796
+ {
797
+ key: "utm.content",
798
+ valueType: "string",
799
+ capture: { mode: "url_parameter", parameter: "utm_content" },
800
+ },
801
+ {
802
+ key: "product.type",
803
+ valueType: "string",
804
+ capture: { mode: "dom_selector", selector: "[data-product-type]", source: "attribute", attribute: "data-product-type" },
805
+ },
806
+ {
807
+ key: "shopware.cart_count",
808
+ valueType: "number",
809
+ capture: { mode: "global", path: "Shopware.State.cart.count" },
810
+ },
811
+ ],
812
+ trackingStart: "immediate",
813
+ });
814
+
815
+ expect(getAssignments()).toEqual([
816
+ expect.objectContaining({ experimentId: "exp_initial_runtime_signals" }),
817
+ ]);
818
+ expect(getDiagnostics().signals.values).toMatchObject({
819
+ "utm.content": "creator_a",
820
+ "product.type": "recovery",
821
+ "shopware.cart_count": 2,
822
+ });
823
+ expect((globalThis as any).localStorage.getItem("drip_signals_v1")).toBeNull();
824
+ expect((globalThis as any).localStorage.getItem("drip_visitor_seen_v1")).toBeNull();
825
+
826
+ (globalThis as any).window.dataLayer.push({ event: "virtPageView", page_type: "pdp" });
827
+ (globalThis as any).drip.signal("manual.signal", "yes");
828
+ (globalThis as any).drip.push({ type: "signal", key: "queued.signal", value: "yes" });
829
+ reInit();
830
+
831
+ expect(getAssignments()).toEqual(expect.arrayContaining([
832
+ expect.objectContaining({ experimentId: "exp_data_layer_runtime_signal" }),
833
+ expect.objectContaining({ experimentId: "exp_manual_runtime_signal" }),
834
+ expect.objectContaining({ experimentId: "exp_queued_runtime_signal" }),
835
+ ]));
836
+ expect((globalThis as any).drip.getSignals()).toMatchObject({
837
+ "page.type": "pdp",
838
+ "manual.signal": "yes",
839
+ "queued.signal": "yes",
840
+ });
841
+ expect((globalThis as any).localStorage.getItem("drip_signals_v1")).toBeNull();
842
+ expect((globalThis as any).localStorage.getItem("drip_visitor_seen_v1")).toBeNull();
843
+ });
844
+
845
+
846
+ it("clears persisted signal storage when consent is revoked", async () => {
847
+ const { init, setConsent } = await import("./index");
848
+
849
+ init({
850
+ antiFlickerMode: "off",
851
+ consentMode: { enabled: true },
852
+ endpoint: "https://events.test",
853
+ experiments: [],
854
+ hasConsent: true,
855
+ shopId: "shop_123",
856
+ trackingStart: "immediate",
857
+ });
858
+
859
+ (globalThis as any).drip.signal("manual.signal", "yes");
860
+ expect((globalThis as any).localStorage.getItem("drip_signals_v1")).toContain("manual.signal");
861
+
862
+ setConsent(false);
863
+
864
+ expect((globalThis as any).localStorage.getItem("drip_signals_v1")).toBeNull();
865
+ expect((globalThis as any).localStorage.getItem("drip_visitor_seen_v1")).toBeNull();
866
+ });
867
+
868
+
869
+ it("uses full-page anti-flicker while fetching forced QA config without scoped selectors", async () => {
870
+ (globalThis as any).location = {
871
+ hash: "",
872
+ hostname: "plain.test",
873
+ href: "https://plain.test/products/a?drip_qa=1&drip_force=exp_1:var_1",
874
+ origin: "https://plain.test",
875
+ pathname: "/products/a",
876
+ search: "?drip_qa=1&drip_force=exp_1:var_1",
877
+ };
878
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
879
+ const url = String(input);
880
+ if (url.includes("/config")) {
881
+ return new Response(JSON.stringify({
882
+ experiments: [],
883
+ profile: { platform: "auto", version: 1 },
884
+ }), { status: 200 });
885
+ }
886
+ return new Response("ok", { status: 202 });
887
+ }) as any;
888
+
889
+ const { init } = await import("./index");
890
+
891
+ init({
892
+ antiFlickerMode: "scoped",
893
+ antiFlickerTimeoutMs: 1200,
894
+ endpoint: "https://events.test",
895
+ shopId: "shop_123",
896
+ });
897
+
898
+ expect(appendedAntiFlickerStyle()).toBeDefined();
899
+ expect(document.head.appendChild).toHaveBeenCalled();
900
+ expect(globalThis.fetch).toHaveBeenCalledWith(
901
+ "https://events.test/config?shopId=shop_123&drip_qa=1&drip_force=exp_1%3Avar_1",
902
+ expect.objectContaining({ signal: expect.any(Object) }),
903
+ );
904
+
905
+ await Promise.resolve();
906
+ await Promise.resolve();
907
+ await Promise.resolve();
908
+ await Promise.resolve();
909
+ });
910
+
911
+
912
+ it.each([
913
+ ["QA", "?drip_qa=1&drip_force=exp_off_target_force:var_off_target_force"],
914
+ ["screenshot", "?drip_screenshot=1&drip_force=exp_off_target_force:var_off_target_force"],
915
+ ] as const)("keeps a %s-forced off-target network assignment attributable without rendering it", async (mode, search) => {
916
+ Object.assign((globalThis as any).location, {
917
+ href: `https://plain.test/thank-you${search}`,
918
+ pathname: "/thank-you",
919
+ search,
920
+ });
921
+ const headline = { textContent: "Order confirmed" };
922
+ (globalThis as any).document.querySelectorAll = vi.fn((selector: string) =>
923
+ selector === "#forced-headline" ? [headline] : [],
924
+ );
925
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
926
+ if (String(input).includes("/config")) {
927
+ return Response.json({
928
+ experiments: [{
929
+ id: "exp_off_target_force",
930
+ status: "running",
931
+ targeting: { url: [{ type: "simple", pattern: "/products/*" }] },
932
+ trafficAllocation: 1,
933
+ goalPageRules: [{
934
+ id: "page_thank_you",
935
+ includeRules: [{ type: "simple", pattern: "/thank-you", include: true }],
936
+ }],
937
+ variations: [{
938
+ id: "var_off_target_force",
939
+ weight: 1,
940
+ mutations: [{
941
+ selector: "#forced-headline",
942
+ action: "text",
943
+ text: "Treatment rendered",
944
+ }],
945
+ }],
946
+ goals: [{
947
+ id: "goal_off_target_force",
948
+ type: "click",
949
+ selector: "#complete-order",
950
+ pageId: "page_thank_you",
951
+ }],
952
+ }],
953
+ });
954
+ }
955
+ return new Response("ok", { status: 202 });
956
+ }) as any;
957
+ const { __test, flush, getAssignments, init } = await import("./index");
958
+
959
+ init({
960
+ antiFlickerMode: "off",
961
+ endpoint: "https://events.test",
962
+ shopId: "shop_123",
963
+ trackingStart: "immediate",
964
+ userId: "visitor_off_target_force",
965
+ });
966
+ await flushDynamicImports();
967
+
968
+ const assignments = getAssignments();
969
+ expect(assignments).toEqual([
970
+ expect.objectContaining({
971
+ experimentId: "exp_off_target_force",
972
+ variationId: "var_off_target_force",
973
+ pageActive: false,
974
+ }),
975
+ ]);
976
+ expect(__test!.artifactRenderableAssignments(assignments)).toEqual([]);
977
+ expect(headline.textContent).toBe("Order confirmed");
978
+
979
+ const target = {
980
+ closest: vi.fn((selector: string) => selector === "#complete-order" ? target : null),
981
+ };
982
+ if (mode === "QA") expect(documentListeners.click ?? []).not.toHaveLength(0);
983
+ for (const handler of documentListeners.click ?? []) handler({ target });
984
+ await flush();
985
+
986
+ const events = vi.mocked(globalThis.fetch).mock.calls
987
+ .filter(([input]) => String(input) === "https://events.test/v1/events")
988
+ .flatMap(([, request]) => JSON.parse(String(request?.body)).events);
989
+ if (mode === "QA") {
990
+ expect(events).toEqual(expect.arrayContaining([
991
+ expect.objectContaining({
992
+ type: "goal",
993
+ experimentId: "exp_off_target_force",
994
+ variationId: "var_off_target_force",
995
+ data: expect.objectContaining({ goalId: "goal_off_target_force" }),
996
+ }),
997
+ ]));
998
+ }
999
+ });
1000
+
1001
+
1002
+ it("keeps an unforced off-target network experiment unassigned in QA", async () => {
1003
+ Object.assign((globalThis as any).location, {
1004
+ href: "https://plain.test/thank-you?drip_qa=1",
1005
+ pathname: "/thank-you",
1006
+ search: "?drip_qa=1",
1007
+ });
1008
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
1009
+ if (String(input).includes("/config")) {
1010
+ return Response.json({
1011
+ experiments: [{
1012
+ id: "exp_unforced_off_target",
1013
+ status: "running",
1014
+ targeting: { url: [{ type: "simple", pattern: "/products/*" }] },
1015
+ trafficAllocation: 1,
1016
+ variations: [{ id: "var_unforced_off_target", weight: 1, mutations: [] }],
1017
+ }],
1018
+ });
1019
+ }
1020
+ return new Response("ok", { status: 202 });
1021
+ }) as any;
1022
+ const { getAssignments, init } = await import("./index");
1023
+
1024
+ init({
1025
+ antiFlickerMode: "off",
1026
+ endpoint: "https://events.test",
1027
+ shopId: "shop_123",
1028
+ trackingStart: "immediate",
1029
+ userId: "visitor_unforced_off_target",
1030
+ });
1031
+ await flushDynamicImports();
1032
+
1033
+ expect(getAssignments()).toEqual([]);
1034
+ });
1035
+
1036
+
1037
+ it("reports an unavailable forced experiment on a cold inline QA load", async () => {
1038
+ (globalThis as any).location = {
1039
+ hash: "",
1040
+ hostname: "plain.test",
1041
+ href: "https://plain.test/products/a?drip_qa=1&drip_force=exp_missing:var_missing",
1042
+ origin: "https://plain.test",
1043
+ pathname: "/products/a",
1044
+ search: "?drip_qa=1&drip_force=exp_missing:var_missing",
1045
+ };
1046
+ const { __getQaAssistantRuntimeSnapshot, init } = await import("./index");
1047
+
1048
+ init({ experiments: [] });
1049
+
1050
+ expect(__getQaAssistantRuntimeSnapshot().forcedUnavailable).toEqual([
1051
+ { experimentId: "exp_missing", reason: "not_in_config" },
1052
+ ]);
1053
+ });
1054
+
1055
+
1056
+ it("copies server-classified QA warnings from a fetched config", async () => {
1057
+ (globalThis as any).location = {
1058
+ hash: "",
1059
+ hostname: "plain.test",
1060
+ href: "https://plain.test/products/a?drip_qa=1&drip_force=exp_archived:var_archived",
1061
+ origin: "https://plain.test",
1062
+ pathname: "/products/a",
1063
+ search: "?drip_qa=1&drip_force=exp_archived:var_archived",
1064
+ };
1065
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
1066
+ if (String(input).includes("/config")) {
1067
+ return Response.json({
1068
+ experiments: [],
1069
+ qa_warnings: [{
1070
+ code: "forced_experiment_unavailable",
1071
+ experiment_id: "exp_archived",
1072
+ reason: "archived",
1073
+ }],
1074
+ });
1075
+ }
1076
+ return new Response("ok", { status: 202 });
1077
+ }) as any;
1078
+ const { __getQaAssistantRuntimeSnapshot, init } = await import("./index");
1079
+
1080
+ init({ antiFlickerMode: "off", endpoint: "https://events.test", shopId: "shop_123" });
1081
+ await flushDynamicImports();
1082
+
1083
+ expect(__getQaAssistantRuntimeSnapshot().forcedUnavailable).toEqual([
1084
+ { experimentId: "exp_archived", reason: "archived" },
1085
+ ]);
1086
+ });
1087
+
1088
+
1089
+ it("delivers edge device-audience warnings from the real QA /config shape to the assistant state", async () => {
1090
+ (globalThis as any).location = {
1091
+ hash: "",
1092
+ hostname: "plain.test",
1093
+ href: "https://plain.test/products/a?drip_qa=1",
1094
+ origin: "https://plain.test",
1095
+ pathname: "/products/a",
1096
+ search: "?drip_qa=1",
1097
+ };
1098
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
1099
+ fetchedAt: Date.now(),
1100
+ hash: "non-qa-cached-config",
1101
+ experiments: [],
1102
+ }));
1103
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
1104
+ if (String(input).includes("/config")) {
1105
+ return Response.json({
1106
+ experiments: [{
1107
+ id: "exp_redirect",
1108
+ type: "redirect",
1109
+ targeting: { segmentRules: [] },
1110
+ variations: [{ id: "control", weight: 1, mutations: [] }],
1111
+ }],
1112
+ geo: { country: "UNKNOWN" },
1113
+ qa_warnings: [{
1114
+ code: "edge_device_audience_unsupported",
1115
+ experiment_id: "exp_redirect",
1116
+ audience_name: "Mobile shoppers",
1117
+ }],
1118
+ });
1119
+ }
1120
+ return new Response("ok", { status: 202 });
1121
+ }) as any;
1122
+ const { __getQaAssistantRuntimeSnapshot, init } = await import("./index");
1123
+
1124
+ init({ antiFlickerMode: "off", endpoint: "https://events.test", shopId: "shop_123" });
1125
+ await flushDynamicImports();
1126
+
1127
+ expect(globalThis.fetch).toHaveBeenCalledWith(
1128
+ "https://events.test/config?shopId=shop_123&drip_qa=1",
1129
+ expect.objectContaining({ signal: expect.any(Object) }),
1130
+ );
1131
+ expect(__getQaAssistantRuntimeSnapshot().edgeDeviceAudienceUnsupported).toEqual([
1132
+ { experimentId: "exp_redirect", audienceName: "Mobile shoppers" },
1133
+ ]);
1134
+ });
1135
+
1136
+
1137
+ it("preserves scoped anti-flicker when network config supplies non-commerce mutations", async () => {
1138
+ installMutationObserverStub();
1139
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
1140
+ if (String(input).includes("/config")) {
1141
+ return Response.json({
1142
+ experiments: [{
1143
+ id: "exp_network",
1144
+ status: "running",
1145
+ trafficAllocation: 1,
1146
+ variations: [{
1147
+ id: "var_network",
1148
+ weight: 1,
1149
+ mutations: [{ selector: ".network-title", action: "text", text: "Fetched" }],
1150
+ }],
1151
+ }],
1152
+ });
1153
+ }
1154
+ return new Response("ok", { status: 202 });
1155
+ }) as any;
1156
+ const { init } = await import("./index");
1157
+
1158
+ init({
1159
+ antiFlickerMode: "scoped",
1160
+ endpoint: "https://events.test",
1161
+ lateConfigPolicy: "apply",
1162
+ shopId: "shop_123",
1163
+ });
1164
+ await flushDynamicImports();
1165
+
1166
+ expect(document.head.appendChild).toHaveBeenCalled();
1167
+ });
1168
+
1169
+
1170
+ it("includes URL block mutation selectors in scoped anti-flicker CSS", async () => {
1171
+ installMutationObserverStub();
1172
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
1173
+ if (String(input).includes("/config")) {
1174
+ return Response.json({
1175
+ experiments: [{
1176
+ id: "exp_network",
1177
+ status: "running",
1178
+ trafficAllocation: 1,
1179
+ variations: [{
1180
+ id: "var_network",
1181
+ weight: 1,
1182
+ mutations: [],
1183
+ urlBlocks: [{
1184
+ id: "product-a",
1185
+ name: "Product A",
1186
+ urlRules: [{ type: "simple", pattern: "/products/a" }],
1187
+ mutations: [{
1188
+ selector: ".block-title",
1189
+ action: "text",
1190
+ text: "Fetched",
1191
+ }],
1192
+ }],
1193
+ }],
1194
+ }],
1195
+ });
1196
+ }
1197
+ return new Response("ok", { status: 202 });
1198
+ }) as any;
1199
+ const { init } = await import("./index");
1200
+
1201
+ init({
1202
+ antiFlickerMode: "scoped",
1203
+ endpoint: "https://events.test",
1204
+ lateConfigPolicy: "apply",
1205
+ shopId: "shop_123",
1206
+ });
1207
+ await flushDynamicImports();
1208
+
1209
+ expect(appendedAntiFlickerStyle()?.textContent).toContain(".block-title");
1210
+ });
1211
+ });