@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,2156 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { Mutation } 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 redirects", () => {
8
+ registerInitTestLifecycle();
9
+
10
+
11
+ it("exposes only the redirect assignment before navigation", async () => {
12
+ const replace = vi.fn();
13
+ (globalThis as any).location.replace = replace;
14
+ const onExperimentViewed = vi.fn();
15
+ const { init } = await import("./index");
16
+
17
+ init({
18
+ antiFlickerMode: "off",
19
+ endpoint: "https://events.test",
20
+ shopId: "shop_123",
21
+ trackingStart: "immediate",
22
+ userId: "visitor_redirect",
23
+ experiments: [
24
+ {
25
+ id: "exp_redirect",
26
+ experimentType: "redirect",
27
+ status: "running",
28
+ targeting: { url: [{ type: "simple", pattern: "/", include: true }] },
29
+ trafficAllocation: 1,
30
+ variations: [{ id: "redirect_treatment", weight: 1, mutations: [], redirectUrl: "https://landing.test/sale" }],
31
+ },
32
+ {
33
+ id: "exp_mutation",
34
+ status: "running",
35
+ trafficAllocation: 1,
36
+ // The non-redirect arm must stay unapplied; keep the payload key the
37
+ // runtime ignores (`value`, not `text`) instead of making it apply.
38
+ variations: [{ id: "mutation_treatment", weight: 1, mutations: [{ selector: "body", action: "text", value: "changed" } as unknown as Mutation] }],
39
+ },
40
+ ],
41
+ onExperimentViewed,
42
+ });
43
+
44
+ expect(replace).toHaveBeenCalledTimes(1);
45
+ expect(onExperimentViewed.mock.calls.map(([viewed]) => viewed.experimentId)).toEqual(["exp_redirect"]);
46
+ });
47
+
48
+
49
+ it("does not navigate a holdout visitor into a redirect treatment", async () => {
50
+ const replace = vi.fn();
51
+ (globalThis as any).location.replace = replace;
52
+ const { getAssignments, init } = await import("./index");
53
+ const { isHoldoutVisitor } = await import("./hash");
54
+ const userId = Array.from({ length: 1000 }, (_, index) => `redirect-holdout-${index}`).find((id) =>
55
+ isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, id),
56
+ );
57
+ expect(userId).toBeTruthy();
58
+
59
+ init({
60
+ antiFlickerMode: "off",
61
+ endpoint: "https://events.test",
62
+ shopId: "shop_123",
63
+ trackingStart: "immediate",
64
+ userId: userId!,
65
+ holdout_v1_enabled: true,
66
+ holdout_pct: 10,
67
+ experiments: [{
68
+ id: "exp_redirect",
69
+ experimentType: "redirect",
70
+ status: "running",
71
+ targeting: { url: [{ type: "simple", pattern: "/", include: true }] },
72
+ trafficAllocation: 1,
73
+ variations: [{
74
+ id: "redirect_treatment",
75
+ weight: 1,
76
+ mutations: [],
77
+ redirectUrl: "https://landing.test/sale",
78
+ }],
79
+ }],
80
+ });
81
+
82
+ expect(getAssignments()).toEqual([]);
83
+ expect(replace).not.toHaveBeenCalled();
84
+ });
85
+
86
+
87
+ it("flushes a network redirect exposure before navigation when cached visual hydration is pending", async () => {
88
+ installMutationObserverStub();
89
+ const replace = vi.fn();
90
+ Object.assign((globalThis as any).location, {
91
+ href: "https://plain.test/products/source",
92
+ pathname: "/products/source",
93
+ replace,
94
+ });
95
+ (globalThis as any).window.location = (globalThis as any).location;
96
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
97
+ fetchedAt: Date.now(),
98
+ hash: "cached-visual-before-network-redirect",
99
+ experiments: [{
100
+ id: "exp_cached_visual_before_redirect",
101
+ status: "running",
102
+ trafficAllocation: 1,
103
+ variations: [{
104
+ id: "var_cached_visual_before_redirect",
105
+ weight: 1,
106
+ mutations: [{
107
+ selector: "#product-title",
108
+ action: "text",
109
+ text: "Cached treatment",
110
+ }],
111
+ }],
112
+ }],
113
+ runtime: {
114
+ spa: {
115
+ applyTiming: "after_hydration",
116
+ hydrationTimeoutMs: 5_000,
117
+ },
118
+ },
119
+ }));
120
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => (
121
+ String(input).includes("/config")
122
+ ? Response.json({
123
+ experiments: [{
124
+ id: "exp_network_redirect",
125
+ experimentType: "redirect",
126
+ status: "running",
127
+ targeting: {
128
+ url: [{
129
+ type: "simple",
130
+ pattern: "/products/*",
131
+ include: true,
132
+ }],
133
+ },
134
+ trafficAllocation: 1,
135
+ variations: [{
136
+ id: "var_network_redirect",
137
+ weight: 1,
138
+ mutations: [],
139
+ redirectUrl: "https://landing.test/sale",
140
+ }],
141
+ }],
142
+ })
143
+ : new Response("ok", { status: 202 })
144
+ )) as any;
145
+ const { flush, getDiagnostics, init } = await import("./index");
146
+
147
+ init({
148
+ antiFlickerMode: "off",
149
+ endpoint: "https://events.test",
150
+ lateConfigPolicy: "apply",
151
+ shopId: "shop_123",
152
+ trackingStart: "immediate",
153
+ userId: "visitor_network_redirect",
154
+ });
155
+ for (
156
+ let attempt = 0;
157
+ attempt < 20 && getDiagnostics().configSource !== "network";
158
+ attempt += 1
159
+ ) {
160
+ await Promise.resolve();
161
+ }
162
+ await flush();
163
+
164
+ expect(getDiagnostics().configSource).toBe("network");
165
+ expect(replace).toHaveBeenCalledWith("https://landing.test/sale");
166
+ const fetchMock = vi.mocked(globalThis.fetch);
167
+ const eventsCallIndex = fetchMock.mock.calls.findIndex(
168
+ (call) => call[0] === "https://events.test/v1/events",
169
+ );
170
+ expect(eventsCallIndex).toBeGreaterThanOrEqual(0);
171
+ expect(fetchMock.mock.invocationCallOrder[eventsCallIndex]).toBeLessThan(
172
+ replace.mock.invocationCallOrder[0],
173
+ );
174
+ const events = JSON.parse(
175
+ String(fetchMock.mock.calls[eventsCallIndex]?.[1]?.body),
176
+ ).events;
177
+ expect(events).toEqual(expect.arrayContaining([
178
+ expect.objectContaining({
179
+ type: "experiment_viewed",
180
+ experimentId: "exp_network_redirect",
181
+ variationId: "var_network_redirect",
182
+ }),
183
+ ]));
184
+ expect(events).not.toEqual(expect.arrayContaining([
185
+ expect.objectContaining({
186
+ type: "experiment_viewed",
187
+ experimentId: "exp_cached_visual_before_redirect",
188
+ }),
189
+ ]));
190
+ });
191
+
192
+
193
+ it("does not navigate a cached redirect when the refresh stops the experiment", async () => {
194
+ const replace = vi.fn();
195
+ Object.assign((globalThis as any).location, {
196
+ href: "https://plain.test/products/source",
197
+ pathname: "/products/source",
198
+ replace,
199
+ });
200
+ (globalThis as any).window.location = (globalThis as any).location;
201
+ const cachedRedirect = {
202
+ id: "exp_cached_redirect_stopped",
203
+ experimentType: "redirect" as const,
204
+ status: "running",
205
+ targeting: {
206
+ url: [{ type: "simple" as const, pattern: "/products/*", include: true }],
207
+ },
208
+ trafficAllocation: 1,
209
+ variations: [{
210
+ id: "var_cached_redirect_stopped",
211
+ weight: 1,
212
+ mutations: [],
213
+ redirectUrl: "https://landing.test/old-sale",
214
+ }],
215
+ };
216
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
217
+ fetchedAt: Date.now(),
218
+ hash: configPayloadHash([cachedRedirect]),
219
+ experiments: [cachedRedirect],
220
+ }));
221
+ let resolveConfig!: (response: Response) => void;
222
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => (
223
+ String(input).includes("/config")
224
+ ? new Promise<Response>((resolve) => {
225
+ resolveConfig = resolve;
226
+ })
227
+ : Promise.resolve(new Response("ok", { status: 202 }))
228
+ )) as any;
229
+ const { getDiagnostics, init } = await import("./index");
230
+
231
+ init({
232
+ antiFlickerMode: "off",
233
+ endpoint: "https://events.test",
234
+ shopId: "shop_123",
235
+ trackingStart: "immediate",
236
+ userId: "visitor_cached_redirect_stopped",
237
+ });
238
+ await Promise.resolve();
239
+
240
+ expect(replace).not.toHaveBeenCalled();
241
+ expect(appendedAntiFlickerStyle()).toBeDefined();
242
+ await vi.advanceTimersByTimeAsync(1_300);
243
+ expect(appendedAntiFlickerStyle()).toBeDefined();
244
+
245
+ resolveConfig(Response.json({
246
+ experiments: [{ ...cachedRedirect, status: "paused" }],
247
+ }));
248
+ for (
249
+ let attempt = 0;
250
+ attempt < 20 && getDiagnostics().configSource !== "network";
251
+ attempt += 1
252
+ ) {
253
+ await Promise.resolve();
254
+ }
255
+ expect(getDiagnostics().configSource).toBe("network");
256
+ expect(replace).not.toHaveBeenCalled();
257
+ });
258
+
259
+
260
+ it("navigates a cached redirect once after an unchanged refresh confirms it", async () => {
261
+ const replace = vi.fn();
262
+ Object.assign((globalThis as any).location, {
263
+ href: "https://plain.test/products/source",
264
+ pathname: "/products/source",
265
+ replace,
266
+ });
267
+ (globalThis as any).window.location = (globalThis as any).location;
268
+ const cachedRedirect = {
269
+ id: "exp_cached_redirect_confirmed",
270
+ experimentType: "redirect" as const,
271
+ status: "running",
272
+ targeting: {
273
+ url: [{ type: "simple" as const, pattern: "/products/*", include: true }],
274
+ },
275
+ trafficAllocation: 1,
276
+ variations: [{
277
+ id: "var_cached_redirect_confirmed",
278
+ weight: 1,
279
+ mutations: [],
280
+ redirectUrl: "https://landing.test/confirmed-sale",
281
+ }],
282
+ };
283
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
284
+ fetchedAt: Date.now(),
285
+ hash: configPayloadHash([cachedRedirect]),
286
+ experiments: [cachedRedirect],
287
+ }));
288
+ let resolveConfig!: (response: Response) => void;
289
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => (
290
+ String(input).includes("/config")
291
+ ? new Promise<Response>((resolve) => {
292
+ resolveConfig = resolve;
293
+ })
294
+ : Promise.resolve(new Response("ok", { status: 202 }))
295
+ )) as any;
296
+ const { getDiagnostics, init } = await import("./index");
297
+
298
+ init({
299
+ antiFlickerMode: "off",
300
+ endpoint: "https://events.test",
301
+ shopId: "shop_123",
302
+ trackingStart: "immediate",
303
+ userId: "visitor_cached_redirect_confirmed",
304
+ });
305
+ await Promise.resolve();
306
+
307
+ expect(replace).not.toHaveBeenCalled();
308
+ expect(appendedAntiFlickerStyle()).toBeDefined();
309
+ await vi.advanceTimersByTimeAsync(1_300);
310
+ expect(appendedAntiFlickerStyle()).toBeDefined();
311
+
312
+ resolveConfig(Response.json({ experiments: [cachedRedirect] }));
313
+ for (
314
+ let attempt = 0;
315
+ attempt < 20 && getDiagnostics().load.configFetchStatus !== "ok";
316
+ attempt += 1
317
+ ) {
318
+ await Promise.resolve();
319
+ }
320
+ expect(getDiagnostics().load.configFetchStatus).toBe("ok");
321
+ expect(getDiagnostics().configSource).toBe("cache");
322
+ expect(replace).toHaveBeenCalledTimes(1);
323
+ expect(replace).toHaveBeenCalledWith(
324
+ "https://landing.test/confirmed-sale",
325
+ );
326
+ expect(appendedAntiFlickerStyle()).toBeDefined();
327
+ });
328
+
329
+
330
+ it("falls back to the cached redirect after the bounded refresh timeout", async () => {
331
+ const replace = vi.fn();
332
+ Object.assign((globalThis as any).location, {
333
+ href: "https://plain.test/products/source",
334
+ pathname: "/products/source",
335
+ replace,
336
+ });
337
+ (globalThis as any).window.location = (globalThis as any).location;
338
+ const cachedRedirect = {
339
+ id: "exp_cached_redirect_timeout",
340
+ experimentType: "redirect" as const,
341
+ status: "running",
342
+ targeting: {
343
+ url: [{ type: "simple" as const, pattern: "/products/*", include: true }],
344
+ },
345
+ trafficAllocation: 1,
346
+ variations: [{
347
+ id: "var_cached_redirect_timeout",
348
+ weight: 1,
349
+ mutations: [],
350
+ redirectUrl: "https://landing.test/fallback-sale",
351
+ }],
352
+ };
353
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
354
+ fetchedAt: Date.now(),
355
+ hash: configPayloadHash([cachedRedirect]),
356
+ experiments: [cachedRedirect],
357
+ }));
358
+ globalThis.fetch = vi.fn((
359
+ input: RequestInfo | URL,
360
+ requestInit?: RequestInit,
361
+ ) => {
362
+ if (!String(input).includes("/config")) {
363
+ return Promise.resolve(new Response("ok", { status: 202 }));
364
+ }
365
+ return new Promise<Response>((_resolve, reject) => {
366
+ requestInit?.signal?.addEventListener("abort", () => {
367
+ reject(new DOMException("The operation was aborted.", "AbortError"));
368
+ });
369
+ });
370
+ }) as any;
371
+ vi.spyOn(console, "error").mockImplementation(() => {});
372
+ const { getDiagnostics, init } = await import("./index");
373
+
374
+ init({
375
+ antiFlickerMode: "off",
376
+ endpoint: "https://events.test",
377
+ shopId: "shop_123",
378
+ trackingStart: "immediate",
379
+ userId: "visitor_cached_redirect_timeout",
380
+ });
381
+ await Promise.resolve();
382
+
383
+ expect(replace).not.toHaveBeenCalled();
384
+ expect(appendedAntiFlickerStyle()).toBeDefined();
385
+ await vi.advanceTimersByTimeAsync(14_999);
386
+ expect(appendedAntiFlickerStyle()).toBeDefined();
387
+ expect(replace).not.toHaveBeenCalled();
388
+ await vi.advanceTimersByTimeAsync(1);
389
+ expect(getDiagnostics().load.configFetchStatus).toBe("timeout");
390
+ expect(replace).toHaveBeenCalledTimes(1);
391
+ expect(replace).toHaveBeenCalledWith(
392
+ "https://landing.test/fallback-sale",
393
+ );
394
+ expect(appendedAntiFlickerStyle()).toBeDefined();
395
+ });
396
+
397
+
398
+ it("persists redirect bucketing without unsigned carry when the tracking prerequisite is closed", async () => {
399
+ const replace = vi.fn();
400
+ Object.assign((globalThis as any).location, {
401
+ href: "https://plain.test/products/source",
402
+ pathname: "/products/source",
403
+ replace,
404
+ });
405
+ (globalThis as any).window.location = (globalThis as any).location;
406
+ const onExperimentViewed = vi.fn();
407
+ const { getDiagnostics, init } = await import("./index");
408
+
409
+ init({
410
+ antiFlickerMode: "off",
411
+ endpoint: "https://events.test",
412
+ experiments: [{
413
+ id: "exp_redirect_tracking_closed",
414
+ assignmentEpoch: 4,
415
+ experimentType: "redirect",
416
+ status: "running",
417
+ targeting: {
418
+ url: [{ type: "simple", pattern: "/products/*", include: true }],
419
+ },
420
+ trafficAllocation: 1,
421
+ variations: [{
422
+ id: "var_redirect_tracking_closed",
423
+ weight: 1,
424
+ mutations: [],
425
+ redirectUrl: "https://landing.test/tracking-closed",
426
+ }],
427
+ }],
428
+ onExperimentViewed,
429
+ projectPrerequisites: {
430
+ startTrackingRule: "return false;",
431
+ },
432
+ shopId: "shop_123",
433
+ trackingStart: "immediate",
434
+ userId: "visitor_redirect_tracking_closed",
435
+ });
436
+
437
+ expect(getDiagnostics().trackerStarted).toBe(false);
438
+ expect(onExperimentViewed).not.toHaveBeenCalled();
439
+ expect(replace).toHaveBeenCalledTimes(1);
440
+ const destination = new URL(String(replace.mock.calls[0]?.[0]));
441
+ expect(destination.searchParams.has("_apex_assignment")).toBe(false);
442
+ expect(
443
+ JSON.parse(localStorage.getItem("drip_assignments_v1") ?? "{}"),
444
+ ).toHaveProperty(
445
+ "exp_redirect_tracking_closed",
446
+ expect.arrayContaining(["var_redirect_tracking_closed", 4]),
447
+ );
448
+ });
449
+
450
+
451
+ it("drops an exclusion-only redirect assignment before application or exposure", async () => {
452
+ const replace = vi.fn();
453
+ (globalThis as any).location.replace = replace;
454
+ (globalThis as any).window.location = (globalThis as any).location;
455
+ const onExperimentViewed = vi.fn();
456
+ const { getAssignments, init } = await import("./index");
457
+
458
+ init({
459
+ antiFlickerMode: "off",
460
+ endpoint: "https://events.test",
461
+ shopId: "shop_123",
462
+ trackingStart: "immediate",
463
+ userId: "visitor_exclusion_only_redirect",
464
+ experiments: [
465
+ {
466
+ id: "exp_exclusion_only_redirect",
467
+ experimentType: "redirect",
468
+ status: "running",
469
+ targeting: {
470
+ url: [
471
+ {
472
+ type: "simple",
473
+ pattern: "/checkout/*",
474
+ include: false,
475
+ },
476
+ ],
477
+ },
478
+ trafficAllocation: 1,
479
+ variations: [
480
+ {
481
+ id: "redirect_treatment",
482
+ weight: 1,
483
+ mutations: [],
484
+ redirectUrl: "https://landing.test/sale",
485
+ },
486
+ ],
487
+ },
488
+ ],
489
+ onExperimentViewed,
490
+ });
491
+
492
+ expect(getAssignments()).toEqual([]);
493
+ expect(replace).not.toHaveBeenCalled();
494
+ expect(onExperimentViewed).not.toHaveBeenCalled();
495
+ });
496
+
497
+
498
+ it("drops a redirect assignment whose destination matches its source rules", async () => {
499
+ const replace = vi.fn();
500
+ Object.assign((globalThis as any).location, {
501
+ href: "https://plain.test/products/source",
502
+ pathname: "/products/source",
503
+ replace,
504
+ });
505
+ (globalThis as any).window.location = (globalThis as any).location;
506
+ const onExperimentViewed = vi.fn();
507
+ const { getAssignments, init } = await import("./index");
508
+
509
+ init({
510
+ antiFlickerMode: "off",
511
+ endpoint: "https://events.test",
512
+ shopId: "shop_123",
513
+ trackingStart: "immediate",
514
+ userId: "visitor_matching_destination",
515
+ experiments: [
516
+ {
517
+ id: "exp_matching_destination",
518
+ experimentType: "redirect",
519
+ status: "running",
520
+ targeting: {
521
+ url: [
522
+ {
523
+ type: "simple",
524
+ pattern: "/products/*",
525
+ include: true,
526
+ },
527
+ ],
528
+ },
529
+ trafficAllocation: 1,
530
+ variations: [
531
+ {
532
+ id: "redirect_treatment",
533
+ weight: 1,
534
+ mutations: [],
535
+ redirectUrl: "https://plain.test/products/destination",
536
+ },
537
+ ],
538
+ },
539
+ ],
540
+ onExperimentViewed,
541
+ });
542
+
543
+ expect(getAssignments()).toEqual([]);
544
+ expect(replace).not.toHaveBeenCalled();
545
+ expect(onExperimentViewed).not.toHaveBeenCalled();
546
+ });
547
+
548
+
549
+ it("does not enroll or expose a direct destination visitor when a host-agnostic source rule shares its path", async () => {
550
+ const replace = vi.fn();
551
+ Object.assign((globalThis as any).location, {
552
+ hostname: "landing.test",
553
+ href: "https://landing.test/products/destination",
554
+ origin: "https://landing.test",
555
+ pathname: "/products/destination",
556
+ replace,
557
+ });
558
+ (globalThis as any).window.location = (globalThis as any).location;
559
+ const onExperimentViewed = vi.fn();
560
+ const { getAssignments, init } = await import("./index");
561
+
562
+ init({
563
+ antiFlickerMode: "off",
564
+ endpoint: "https://events.test",
565
+ shopId: "shop_123",
566
+ storeOrigin: "https://plain.test",
567
+ trackingStart: "immediate",
568
+ userId: "visitor_direct_destination",
569
+ experiments: [
570
+ {
571
+ id: "exp_origin_scoped_redirect",
572
+ experimentType: "redirect",
573
+ status: "running",
574
+ targeting: {
575
+ url: [
576
+ {
577
+ type: "simple",
578
+ pattern: "/products/*",
579
+ include: true,
580
+ },
581
+ ],
582
+ },
583
+ trafficAllocation: 1,
584
+ variations: [
585
+ {
586
+ id: "redirect_treatment",
587
+ weight: 1,
588
+ mutations: [],
589
+ redirectUrl: "https://landing.test/products/destination",
590
+ },
591
+ ],
592
+ },
593
+ ],
594
+ onExperimentViewed,
595
+ });
596
+
597
+ expect(getAssignments()).toEqual([]);
598
+ expect(replace).not.toHaveBeenCalled();
599
+ expect(onExperimentViewed).not.toHaveBeenCalled();
600
+ });
601
+
602
+
603
+ it("fires a path-only redirect on a registered public storefront without requiring the backend origin", async () => {
604
+ const replace = vi.fn();
605
+ Object.assign((globalThis as any).location, {
606
+ hostname: "de.storefront.test",
607
+ href: "https://de.storefront.test/products/source",
608
+ origin: "https://de.storefront.test",
609
+ pathname: "/products/source",
610
+ replace,
611
+ });
612
+ (globalThis as any).window.location = (globalThis as any).location;
613
+ const onExperimentViewed = vi.fn();
614
+ const { getAssignments, init } = await import("./index");
615
+
616
+ init({
617
+ antiFlickerMode: "off",
618
+ endpoint: "https://events.test",
619
+ shopId: "shop_123",
620
+ storeOrigin: "https://backend-app.test",
621
+ storeOrigins: [
622
+ "https://storefront.test",
623
+ "https://de.storefront.test",
624
+ ],
625
+ trackingStart: "immediate",
626
+ userId: "visitor_source_origin",
627
+ experiments: [
628
+ {
629
+ id: "exp_origin_scoped_redirect",
630
+ experimentType: "redirect",
631
+ status: "running",
632
+ targeting: {
633
+ url: [
634
+ {
635
+ type: "simple",
636
+ pattern: "/products/*",
637
+ include: true,
638
+ },
639
+ ],
640
+ },
641
+ trafficAllocation: 1,
642
+ variations: [
643
+ {
644
+ id: "redirect_treatment",
645
+ weight: 1,
646
+ mutations: [],
647
+ redirectUrl: "https://landing.test/products/destination",
648
+ },
649
+ ],
650
+ },
651
+ ],
652
+ onExperimentViewed,
653
+ });
654
+
655
+ expect(getAssignments()).toEqual([
656
+ expect.objectContaining({
657
+ experimentId: "exp_origin_scoped_redirect",
658
+ variationId: "redirect_treatment",
659
+ }),
660
+ ]);
661
+ expect(onExperimentViewed).toHaveBeenCalledTimes(1);
662
+ expect(replace).toHaveBeenCalledTimes(1);
663
+ expect(new URL(String(replace.mock.calls[0]?.[0])).origin).toBe(
664
+ "https://landing.test",
665
+ );
666
+ });
667
+
668
+
669
+ it("records one exposure when DOM activation discovers a redirect after analytics started", async () => {
670
+ const replace = vi.fn();
671
+ (globalThis as any).location.replace = replace;
672
+ (globalThis as any).window.location = (globalThis as any).location;
673
+ const onExperimentViewed = vi.fn();
674
+ const { flush, init } = await import("./index");
675
+
676
+ init({
677
+ antiFlickerMode: "off",
678
+ endpoint: "https://events.test",
679
+ shopId: "shop_123",
680
+ trackingStart: "immediate",
681
+ userId: "visitor_dom_redirect",
682
+ experiments: [
683
+ {
684
+ id: "exp_dom_redirect",
685
+ experimentType: "redirect",
686
+ status: "running",
687
+ targeting: {
688
+ url: [{ type: "simple", pattern: "/", include: true }],
689
+ pageRules: [
690
+ {
691
+ id: "page_dom_redirect",
692
+ includeRules: [
693
+ { type: "simple", pattern: "/", include: true },
694
+ ],
695
+ trigger: {
696
+ mode: "manual-callback",
697
+ signal: "page.trigger.dom_redirect",
698
+ triggerJs: "window.__activateDomRedirect = activate;",
699
+ },
700
+ },
701
+ ],
702
+ },
703
+ trafficAllocation: 1,
704
+ variations: [
705
+ {
706
+ id: "redirect_treatment",
707
+ weight: 1,
708
+ mutations: [],
709
+ redirectUrl: "https://landing.test/sale",
710
+ },
711
+ ],
712
+ },
713
+ ],
714
+ onExperimentViewed,
715
+ });
716
+
717
+ expect(onExperimentViewed).not.toHaveBeenCalled();
718
+ (globalThis as any).window.__activateDomRedirect();
719
+ await flush();
720
+
721
+ const eventsCall = vi.mocked(globalThis.fetch).mock.calls.find(
722
+ (call) => call[0] === "https://events.test/v1/events",
723
+ );
724
+ const events = JSON.parse(String(eventsCall?.[1]?.body)).events;
725
+ expect(
726
+ events.filter(
727
+ (event: { type: string }) => event.type === "experiment_viewed",
728
+ ),
729
+ ).toHaveLength(1);
730
+ expect(onExperimentViewed).toHaveBeenCalledTimes(1);
731
+ expect(replace).toHaveBeenCalledTimes(1);
732
+ });
733
+
734
+
735
+ it("still applies redirect artifacts on the source page before late navigation", async () => {
736
+ const replace = vi.fn();
737
+ (globalThis as any).location.replace = replace;
738
+ (globalThis as any).window.location = (globalThis as any).location;
739
+ const sourceTitle = { textContent: "Original source headline" };
740
+ const sourceStyles: Array<Record<string, any>> = [];
741
+ (globalThis as any).document.createElement = vi.fn((tag: string) => ({
742
+ id: "",
743
+ className: "",
744
+ remove: vi.fn(),
745
+ tagName: tag.toUpperCase(),
746
+ textContent: "",
747
+ }));
748
+ (globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
749
+ selector === "#redirect-source-title" ? [sourceTitle] : []
750
+ ));
751
+ (globalThis as any).document.getElementById = vi.fn((id: string) => (
752
+ sourceStyles.find((element) => element.id === id) ?? null
753
+ ));
754
+ (globalThis as any).document.head.appendChild = vi.fn((element: Record<string, any>) => {
755
+ sourceStyles.push(element);
756
+ return element;
757
+ });
758
+ const { init } = await import("./index");
759
+
760
+ init({
761
+ antiFlickerMode: "off",
762
+ endpoint: "https://events.test",
763
+ shopId: "shop_123",
764
+ trackingStart: "immediate",
765
+ userId: "visitor_source_artifacts",
766
+ experiments: [{
767
+ id: "exp_redirect_source_artifacts",
768
+ experimentType: "redirect",
769
+ status: "running",
770
+ targeting: {
771
+ url: [{ type: "simple", pattern: "/", include: true }],
772
+ pageRules: [{
773
+ id: "page_redirect_source_artifacts",
774
+ includeRules: [{ type: "simple", pattern: "/", include: true }],
775
+ trigger: {
776
+ mode: "manual-callback",
777
+ signal: "page.trigger.redirect_source_artifacts",
778
+ triggerJs: "window.__activateRedirectSourceArtifacts = activate;",
779
+ },
780
+ }],
781
+ },
782
+ trafficAllocation: 1,
783
+ variations: [{
784
+ id: "redirect_source_artifacts_treatment",
785
+ weight: 1,
786
+ mutations: [{
787
+ selector: "#redirect-source-title",
788
+ action: "text",
789
+ text: "Source treatment headline",
790
+ }],
791
+ css: "#redirect-source-title{color:red}",
792
+ js: "window.__redirectSourceJs=true;",
793
+ redirectUrl: "https://landing.test/sale",
794
+ }],
795
+ }],
796
+ spa: { activationMode: "manual" },
797
+ });
798
+
799
+ (globalThis as any).window.__activateRedirectSourceArtifacts();
800
+ await Promise.resolve();
801
+ await Promise.resolve();
802
+
803
+ expect(sourceTitle.textContent).toBe("Source treatment headline");
804
+ expect((globalThis as any).window.__redirectSourceJs).toBe(true);
805
+ expect(sourceStyles.find(
806
+ (style) =>
807
+ style.id ===
808
+ "drip-css-exp_redirect_source_artifacts-redirect_source_artifacts_treatment",
809
+ )?.textContent).toBe("#redirect-source-title{color:red}");
810
+ expect(replace).toHaveBeenCalledTimes(1);
811
+ });
812
+
813
+
814
+ it("plans and exposes a redirect after SPA navigation enters its source route", async () => {
815
+ const replace = vi.fn();
816
+ (globalThis as any).location.replace = replace;
817
+ const onExperimentViewed = vi.fn();
818
+ const { init } = await import("./index");
819
+
820
+ init({
821
+ antiFlickerMode: "off",
822
+ endpoint: "https://events.test",
823
+ shopId: "shop_123",
824
+ trackingStart: "immediate",
825
+ userId: "visitor_spa_redirect",
826
+ experiments: [
827
+ {
828
+ id: "exp_redirect_spa",
829
+ experimentType: "redirect",
830
+ status: "running",
831
+ targeting: {
832
+ url: [
833
+ {
834
+ type: "simple",
835
+ pattern: "/products/source",
836
+ include: true,
837
+ },
838
+ ],
839
+ },
840
+ trafficAllocation: 1,
841
+ variations: [
842
+ {
843
+ id: "redirect_spa_treatment",
844
+ weight: 1,
845
+ mutations: [],
846
+ redirectUrl: "https://landing.test/sale",
847
+ },
848
+ ],
849
+ },
850
+ {
851
+ id: "exp_mutation_spa_peer",
852
+ status: "running",
853
+ trafficAllocation: 1,
854
+ variations: [
855
+ {
856
+ id: "mutation_spa_peer",
857
+ weight: 1,
858
+ mutations: [],
859
+ },
860
+ ],
861
+ },
862
+ ],
863
+ onExperimentViewed,
864
+ });
865
+
866
+ expect(replace).not.toHaveBeenCalled();
867
+ expect(onExperimentViewed.mock.calls.map(([viewed]) => viewed.experimentId)).toEqual([
868
+ "exp_mutation_spa_peer",
869
+ ]);
870
+
871
+ Object.assign((globalThis as any).location, {
872
+ href: "https://plain.test/products/source",
873
+ pathname: "/products/source",
874
+ });
875
+ (globalThis as any).history.pushState({}, "", "/products/source");
876
+
877
+ expect(replace).toHaveBeenCalledTimes(1);
878
+ const redirectDestination = new URL(String(replace.mock.calls[0]?.[0]));
879
+ expect(redirectDestination.origin + redirectDestination.pathname).toBe(
880
+ "https://landing.test/sale",
881
+ );
882
+ expect(redirectDestination.searchParams.has("_apex_assignment")).toBe(
883
+ false,
884
+ );
885
+ expect(onExperimentViewed.mock.calls.map(([viewed]) => viewed.experimentId)).toEqual([
886
+ "exp_mutation_spa_peer",
887
+ "exp_redirect_spa",
888
+ ]);
889
+ });
890
+
891
+
892
+ it.each([
893
+ {
894
+ label: "missing ingest signature",
895
+ ingestSignature: undefined,
896
+ externalTracking: undefined,
897
+ },
898
+ {
899
+ label: "signed handover disabled",
900
+ ingestSignature: "worker-issued-ingest-signature",
901
+ externalTracking: { signed: false },
902
+ },
903
+ ])(
904
+ "omits cross-origin redirect carry when signing is unavailable: $label",
905
+ async ({ ingestSignature, externalTracking }) => {
906
+ const replace = vi.fn();
907
+ Object.assign((globalThis as any).location, {
908
+ hostname: "plain.test",
909
+ href: "https://plain.test/products/source",
910
+ origin: "https://plain.test",
911
+ pathname: "/products/source",
912
+ search: "",
913
+ replace,
914
+ });
915
+ const { init } = await import("./index");
916
+ const { parseRedirectAssignmentCarry } = await import("./redirect");
917
+
918
+ init({
919
+ antiFlickerMode: "off",
920
+ endpoint: "https://events.test",
921
+ experiments: [{
922
+ id: "exp_redirect_unsigned",
923
+ assignmentEpoch: 1,
924
+ experimentType: "redirect",
925
+ status: "running",
926
+ targeting: {
927
+ url: [{ type: "simple", pattern: "/products/*", include: true }],
928
+ },
929
+ trafficAllocation: 1,
930
+ variations: [{
931
+ id: "redirect_consent_treatment",
932
+ weight: 1,
933
+ mutations: [],
934
+ redirectUrl: "https://landing.test/sale",
935
+ }],
936
+ }],
937
+ externalTracking,
938
+ ingestSignature,
939
+ shopId: "shop_123",
940
+ trackingStart: "immediate",
941
+ });
942
+
943
+ expect(replace).toHaveBeenCalledTimes(1);
944
+ const destination = String(replace.mock.calls[0]?.[0]);
945
+ expect(new URL(destination).searchParams.has("_apex_assignment")).toBe(false);
946
+ expect(parseRedirectAssignmentCarry(destination)).toBeNull();
947
+ expect(
948
+ vi.mocked(globalThis.fetch).mock.calls.some(
949
+ (call) => String(call[0]).endsWith("/v1/handover/mint"),
950
+ ),
951
+ ).toBe(false);
952
+ },
953
+ );
954
+
955
+
956
+ it("mints a signed handover before navigating with a cross-origin redirect carry", async () => {
957
+ const replace = vi.fn();
958
+ Object.assign((globalThis as any).location, {
959
+ hostname: "plain.test",
960
+ href: "https://plain.test/products/source",
961
+ origin: "https://plain.test",
962
+ pathname: "/products/source",
963
+ search: "",
964
+ replace,
965
+ });
966
+ const { createBucketingHandoverPayload } = await import("./handover");
967
+ globalThis.fetch = vi.fn(async (
968
+ input: RequestInfo | URL,
969
+ requestInit?: RequestInit,
970
+ ) => {
971
+ if (String(input).endsWith("/v1/handover/mint")) {
972
+ const body = JSON.parse(String(requestInit?.body)) as {
973
+ assignments: Array<{ experimentId: string; variationId: string }>;
974
+ visitorId: string;
975
+ };
976
+ const payload = createBucketingHandoverPayload(
977
+ body.visitorId,
978
+ body.assignments,
979
+ );
980
+ return new Response(JSON.stringify({ token: `${payload}.worker-signature` }), {
981
+ status: 200,
982
+ });
983
+ }
984
+ return new Response("ok", { status: 202 });
985
+ }) as any;
986
+ const { init } = await import("./index");
987
+ const { parseRedirectAssignmentCarry } = await import("./redirect");
988
+
989
+ init({
990
+ antiFlickerMode: "off",
991
+ endpoint: "https://events.test",
992
+ experiments: [{
993
+ id: "exp_redirect_signed_carry",
994
+ assignmentEpoch: 1,
995
+ experimentType: "redirect",
996
+ status: "running",
997
+ targeting: {
998
+ url: [{ type: "simple", pattern: "/products/*", include: true }],
999
+ },
1000
+ trafficAllocation: 1,
1001
+ variations: [{
1002
+ id: "redirect_signed_carry_treatment",
1003
+ weight: 1,
1004
+ mutations: [],
1005
+ redirectUrl: "https://landing.test/sale",
1006
+ }],
1007
+ }],
1008
+ ingestSignature: "worker-issued-ingest-signature",
1009
+ shopId: "shop_123",
1010
+ trackingStart: "immediate",
1011
+ userId: "visitor_signed_redirect",
1012
+ });
1013
+ for (let attempt = 0; attempt < 20 && replace.mock.calls.length === 0; attempt += 1) {
1014
+ await Promise.resolve();
1015
+ }
1016
+
1017
+ expect(replace).toHaveBeenCalledTimes(1);
1018
+ expect(
1019
+ parseRedirectAssignmentCarry(String(replace.mock.calls[0]?.[0]))
1020
+ ?.handoverToken,
1021
+ ).toMatch(/\.worker-signature$/);
1022
+ });
1023
+
1024
+
1025
+ it("reads and cleans a fresh redirect carry from the browser URL when config.url is canonical", async () => {
1026
+ const { buildRedirectDestination } = await import("./redirect");
1027
+ const assignment = {
1028
+ experimentId: "exp_redirect_carry",
1029
+ variationId: "redirect_treatment",
1030
+ assignmentEpoch: 1,
1031
+ index: 0,
1032
+ mutations: [],
1033
+ isControl: false,
1034
+ hashValue: "source",
1035
+ };
1036
+ const destination = buildRedirectDestination(
1037
+ "https://plain.test/products/a",
1038
+ "https://landing.test/sale",
1039
+ assignment,
1040
+ "visitor_source",
1041
+ undefined,
1042
+ true,
1043
+ true,
1044
+ )!;
1045
+ Object.assign((globalThis as any).location, {
1046
+ hostname: "landing.test",
1047
+ href: destination.href,
1048
+ origin: "https://landing.test",
1049
+ pathname: "/sale",
1050
+ search: destination.search,
1051
+ replace: vi.fn(),
1052
+ });
1053
+ const replaceState = globalThis.history.replaceState;
1054
+ const onExperimentViewed = vi.fn();
1055
+ globalThis.fetch = vi.fn(async (
1056
+ input: RequestInfo | URL,
1057
+ requestInit?: RequestInit,
1058
+ ) => verifiedHandoverResponse(input, requestInit)
1059
+ ?? new Response("ok", { status: 202 })) as any;
1060
+ const { getAssignments, init } = await import("./index");
1061
+ const { getUserId } = await import("./hash");
1062
+
1063
+ init({
1064
+ antiFlickerMode: "off",
1065
+ endpoint: "https://events.test",
1066
+ url: "https://landing.test/sale",
1067
+ shopId: "shop_123",
1068
+ trackingStart: "immediate",
1069
+ experiments: [{
1070
+ id: "exp_redirect_carry",
1071
+ assignmentEpoch: 1,
1072
+ experimentType: "redirect",
1073
+ status: "running",
1074
+ targeting: { url: [{ type: "simple", pattern: "/products/*", include: true }] },
1075
+ trafficAllocation: 1,
1076
+ variations: [{ id: "redirect_treatment", weight: 1, mutations: [], redirectUrl: "https://landing.test/sale" }],
1077
+ }],
1078
+ onExperimentViewed,
1079
+ });
1080
+ for (let attempt = 0; attempt < 20 && getUserId() !== "visitor_source"; attempt += 1) {
1081
+ await Promise.resolve();
1082
+ }
1083
+
1084
+ expect(getUserId()).toBe("visitor_source");
1085
+ expect(getAssignments()).toEqual([expect.objectContaining({
1086
+ experimentId: "exp_redirect_carry",
1087
+ variationId: "redirect_treatment",
1088
+ })]);
1089
+ expect(onExperimentViewed).not.toHaveBeenCalled();
1090
+ expect(replaceState).toHaveBeenCalledWith(
1091
+ undefined,
1092
+ "",
1093
+ "https://landing.test/sale",
1094
+ );
1095
+ });
1096
+
1097
+
1098
+ it("cleans and retains a tampered redirect carry without adopting its forged identity", async () => {
1099
+ const {
1100
+ buildRedirectDestination,
1101
+ REDIRECT_ASSIGNMENT_PARAM,
1102
+ REDIRECT_ASSIGNMENT_PENDING_KEY,
1103
+ } = await import("./redirect");
1104
+ const destination = buildRedirectDestination(
1105
+ "https://plain.test/products/a",
1106
+ "https://landing.test/sale",
1107
+ {
1108
+ experimentId: "exp_redirect_tampered",
1109
+ variationId: "redirect_tampered_treatment",
1110
+ assignmentEpoch: 1,
1111
+ },
1112
+ "attacker_chosen_visitor",
1113
+ )!;
1114
+ const rawCarry = JSON.parse(
1115
+ destination.searchParams.get(REDIRECT_ASSIGNMENT_PARAM)!,
1116
+ ) as { handover: string };
1117
+ rawCarry.handover = `${rawCarry.handover}.tampered-signature`;
1118
+ destination.searchParams.set(
1119
+ REDIRECT_ASSIGNMENT_PARAM,
1120
+ JSON.stringify(rawCarry),
1121
+ );
1122
+ Object.assign((globalThis as any).location, {
1123
+ hostname: "landing.test",
1124
+ href: destination.href,
1125
+ origin: destination.origin,
1126
+ pathname: destination.pathname,
1127
+ search: destination.search,
1128
+ replace: vi.fn(),
1129
+ });
1130
+ const replaceState = globalThis.history.replaceState;
1131
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => (
1132
+ String(input).endsWith("/v1/handover/verify")
1133
+ ? new Response("Invalid handover token", { status: 401 })
1134
+ : new Response("ok", { status: 202 })
1135
+ )) as any;
1136
+ const { init } = await import("./index");
1137
+ const { getUserId } = await import("./hash");
1138
+
1139
+ init({
1140
+ antiFlickerMode: "off",
1141
+ endpoint: "https://events.test",
1142
+ experiments: [{
1143
+ id: "exp_redirect_tampered",
1144
+ assignmentEpoch: 1,
1145
+ experimentType: "redirect",
1146
+ status: "running",
1147
+ targeting: {
1148
+ url: [{ type: "simple", pattern: "/products/*", include: true }],
1149
+ },
1150
+ trafficAllocation: 1,
1151
+ variations: [{
1152
+ id: "redirect_tampered_treatment",
1153
+ weight: 1,
1154
+ mutations: [],
1155
+ redirectUrl: "https://landing.test/sale",
1156
+ }],
1157
+ }],
1158
+ shopId: "shop_123",
1159
+ trackingStart: "immediate",
1160
+ });
1161
+ for (let attempt = 0; attempt < 20 && vi.mocked(globalThis.fetch).mock.calls.length === 0; attempt += 1) {
1162
+ await Promise.resolve();
1163
+ }
1164
+
1165
+ expect(getUserId()).not.toBe("attacker_chosen_visitor");
1166
+ expect(replaceState).toHaveBeenCalledWith(
1167
+ undefined,
1168
+ "",
1169
+ "https://landing.test/sale",
1170
+ );
1171
+ expect(sessionStorage.getItem(REDIRECT_ASSIGNMENT_PENDING_KEY)).not.toBeNull();
1172
+ });
1173
+
1174
+
1175
+ it("keeps a persisted cross-origin redirect assignment active at its destination across reloads", async () => {
1176
+ const redirectExperiment = {
1177
+ id: "exp_redirect_destination_reload",
1178
+ assignmentEpoch: 1,
1179
+ experimentType: "redirect" as const,
1180
+ status: "running" as const,
1181
+ targeting: {
1182
+ url: [{
1183
+ type: "simple" as const,
1184
+ pattern: "/products/*",
1185
+ include: true,
1186
+ }],
1187
+ },
1188
+ trafficAllocation: 1,
1189
+ variations: [{
1190
+ id: "redirect_destination_reload_treatment",
1191
+ weight: 1,
1192
+ mutations: [{
1193
+ selector: "#source-only-title",
1194
+ action: "text" as const,
1195
+ text: "Source treatment",
1196
+ }],
1197
+ css: "#source-only-title{color:red}",
1198
+ js: "window.__sourceOnlyRedirectJs=(window.__sourceOnlyRedirectJs||0)+1;",
1199
+ redirectUrl: "https://landing.test/products/destination",
1200
+ }],
1201
+ };
1202
+ const sourceReplace = vi.fn();
1203
+ Object.assign((globalThis as any).location, {
1204
+ hostname: "plain.test",
1205
+ href: "https://plain.test/products/source",
1206
+ origin: "https://plain.test",
1207
+ pathname: "/products/source",
1208
+ search: "",
1209
+ replace: sourceReplace,
1210
+ });
1211
+ const { createBucketingHandoverPayload } = await import("./handover");
1212
+ globalThis.fetch = vi.fn(async (
1213
+ input: RequestInfo | URL,
1214
+ requestInit?: RequestInit,
1215
+ ) => {
1216
+ if (String(input).endsWith("/v1/handover/mint")) {
1217
+ const body = JSON.parse(String(requestInit?.body)) as {
1218
+ assignments: Array<{ experimentId: string; variationId: string }>;
1219
+ visitorId: string;
1220
+ };
1221
+ const payload = createBucketingHandoverPayload(
1222
+ body.visitorId,
1223
+ body.assignments,
1224
+ );
1225
+ return Response.json({ token: `${payload}.worker-signature` });
1226
+ }
1227
+ return new Response("ok", { status: 202 });
1228
+ }) as any;
1229
+ const sourceRuntime = await import("./index");
1230
+
1231
+ sourceRuntime.init({
1232
+ antiFlickerMode: "off",
1233
+ endpoint: "https://events.test",
1234
+ experiments: [redirectExperiment],
1235
+ ingestSignature: "worker-issued-ingest-signature",
1236
+ shopId: "shop_123",
1237
+ storeOrigin: "https://plain.test",
1238
+ trackingStart: "immediate",
1239
+ userId: "visitor_destination_reload",
1240
+ });
1241
+ for (
1242
+ let attempt = 0;
1243
+ attempt < 20 && sourceReplace.mock.calls.length === 0;
1244
+ attempt += 1
1245
+ ) {
1246
+ await Promise.resolve();
1247
+ }
1248
+
1249
+ expect(sourceReplace).toHaveBeenCalledTimes(1);
1250
+ const destination = new URL(String(sourceReplace.mock.calls[0]?.[0]));
1251
+ expect(destination.searchParams.has("_apex_assignment")).toBe(true);
1252
+ const { parseRedirectAssignmentCarry } = await import("./redirect");
1253
+ expect(parseRedirectAssignmentCarry(destination.href)).toMatchObject({
1254
+ exposureSent: true,
1255
+ });
1256
+
1257
+ // Browser storage is origin-scoped. The destination starts empty and
1258
+ // receives its first assignment only through the redirect carry.
1259
+ (globalThis as any).localStorage = makeStorage();
1260
+ (globalThis as any).sessionStorage = makeStorage();
1261
+ vi.clearAllTimers();
1262
+ vi.resetModules();
1263
+ delete (globalThis as any).__drip;
1264
+ const firstDestinationReplace = vi.fn();
1265
+ const firstDestinationExposure = vi.fn();
1266
+ const destinationTitle = { textContent: "Destination headline" };
1267
+ const destinationStyles: Array<Record<string, any>> = [];
1268
+ (globalThis as any).document.createElement = vi.fn((tag: string) => ({
1269
+ id: "",
1270
+ className: "",
1271
+ remove: vi.fn(),
1272
+ tagName: tag.toUpperCase(),
1273
+ textContent: "",
1274
+ }));
1275
+ (globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
1276
+ selector === "#source-only-title" ? [destinationTitle] : []
1277
+ ));
1278
+ (globalThis as any).document.getElementById = vi.fn((id: string) => (
1279
+ destinationStyles.find((element) => element.id === id) ?? null
1280
+ ));
1281
+ (globalThis as any).document.head.appendChild = vi.fn((element: Record<string, any>) => {
1282
+ destinationStyles.push(element);
1283
+ return element;
1284
+ });
1285
+ Object.assign((globalThis as any).location, {
1286
+ hostname: "landing.test",
1287
+ href: destination.href,
1288
+ origin: destination.origin,
1289
+ pathname: destination.pathname,
1290
+ search: destination.search,
1291
+ replace: firstDestinationReplace,
1292
+ });
1293
+ globalThis.fetch = vi.fn(async (
1294
+ input: RequestInfo | URL,
1295
+ requestInit?: RequestInit,
1296
+ ) => verifiedHandoverResponse(input, requestInit)
1297
+ ?? new Response("ok", { status: 202 })) as any;
1298
+ const firstDestinationRuntime = await import("./index");
1299
+
1300
+ firstDestinationRuntime.init({
1301
+ antiFlickerMode: "off",
1302
+ endpoint: "https://events.test",
1303
+ experiments: [redirectExperiment],
1304
+ onExperimentViewed: firstDestinationExposure,
1305
+ shopId: "shop_123",
1306
+ storeOrigin: "https://plain.test",
1307
+ trackingStart: "immediate",
1308
+ });
1309
+ for (
1310
+ let attempt = 0;
1311
+ attempt < 20 && firstDestinationRuntime.getAssignments().length === 0;
1312
+ attempt += 1
1313
+ ) {
1314
+ await Promise.resolve();
1315
+ }
1316
+
1317
+ expect(firstDestinationRuntime.getAssignments()).toEqual([
1318
+ expect.objectContaining({
1319
+ experimentId: redirectExperiment.id,
1320
+ variationId: "redirect_destination_reload_treatment",
1321
+ }),
1322
+ ]);
1323
+ expect(firstDestinationReplace).not.toHaveBeenCalled();
1324
+ expect(firstDestinationExposure).not.toHaveBeenCalled();
1325
+ expect(destinationTitle.textContent).toBe("Destination headline");
1326
+ expect((globalThis as any).window.__sourceOnlyRedirectJs).toBeUndefined();
1327
+ expect(destinationStyles).toHaveLength(0);
1328
+
1329
+ vi.clearAllTimers();
1330
+ vi.resetModules();
1331
+ delete (globalThis as any).__drip;
1332
+ const reloadReplace = vi.fn();
1333
+ const reloadExposure = vi.fn();
1334
+ Object.assign((globalThis as any).location, {
1335
+ hostname: "landing.test",
1336
+ href: "https://landing.test/products/destination",
1337
+ origin: "https://landing.test",
1338
+ pathname: "/products/destination",
1339
+ search: "",
1340
+ replace: reloadReplace,
1341
+ });
1342
+ const reloadRuntime = await import("./index");
1343
+
1344
+ reloadRuntime.init({
1345
+ antiFlickerMode: "off",
1346
+ endpoint: "https://events.test",
1347
+ experiments: [redirectExperiment],
1348
+ onExperimentViewed: reloadExposure,
1349
+ shopId: "shop_123",
1350
+ storeOrigin: "https://plain.test",
1351
+ trackingStart: "immediate",
1352
+ });
1353
+ await Promise.resolve();
1354
+ await Promise.resolve();
1355
+
1356
+ expect(reloadRuntime.getAssignments()).toEqual([
1357
+ expect.objectContaining({
1358
+ experimentId: redirectExperiment.id,
1359
+ variationId: "redirect_destination_reload_treatment",
1360
+ }),
1361
+ ]);
1362
+ expect(reloadReplace).not.toHaveBeenCalled();
1363
+ expect(reloadExposure).not.toHaveBeenCalled();
1364
+ expect(
1365
+ reloadRuntime.getDiagnostics().activation.experiments[0]?.reason,
1366
+ ).toBe("persisted_assignment_off_target");
1367
+ expect(destinationTitle.textContent).toBe("Destination headline");
1368
+ expect((globalThis as any).window.__sourceOnlyRedirectJs).toBeUndefined();
1369
+ expect(destinationStyles).toHaveLength(0);
1370
+ });
1371
+
1372
+
1373
+ it("emits the first redirect exposure on the destination when the source tracking rule rejects", async () => {
1374
+ const redirectExperiment = {
1375
+ id: "exp_redirect_tracking_handover",
1376
+ assignmentEpoch: 1,
1377
+ experimentType: "redirect" as const,
1378
+ status: "running" as const,
1379
+ targeting: {
1380
+ url: [{
1381
+ type: "simple" as const,
1382
+ pattern: "https://plain.test/products/*",
1383
+ include: true,
1384
+ }],
1385
+ },
1386
+ trafficAllocation: 1,
1387
+ variations: [{
1388
+ id: "redirect_tracking_handover_treatment",
1389
+ weight: 1,
1390
+ mutations: [],
1391
+ redirectUrl: "https://landing.test/sale",
1392
+ }],
1393
+ };
1394
+ const sourceReplace = vi.fn();
1395
+ const sourceExposure = vi.fn();
1396
+ Object.assign((globalThis as any).location, {
1397
+ hostname: "plain.test",
1398
+ href: "https://plain.test/products/source",
1399
+ origin: "https://plain.test",
1400
+ pathname: "/products/source",
1401
+ search: "",
1402
+ replace: sourceReplace,
1403
+ });
1404
+ const { createBucketingHandoverPayload } = await import("./handover");
1405
+ globalThis.fetch = vi.fn(async (
1406
+ input: RequestInfo | URL,
1407
+ requestInit?: RequestInit,
1408
+ ) => {
1409
+ if (String(input).endsWith("/v1/handover/mint")) {
1410
+ const body = JSON.parse(String(requestInit?.body)) as {
1411
+ assignments: Array<{ experimentId: string; variationId: string }>;
1412
+ visitorId: string;
1413
+ };
1414
+ const payload = createBucketingHandoverPayload(
1415
+ body.visitorId,
1416
+ body.assignments,
1417
+ );
1418
+ return Response.json({ token: `${payload}.worker-signature` });
1419
+ }
1420
+ return new Response("ok", { status: 202 });
1421
+ }) as any;
1422
+ const sourceRuntime = await import("./index");
1423
+
1424
+ sourceRuntime.init({
1425
+ antiFlickerMode: "off",
1426
+ endpoint: "https://events.test",
1427
+ experiments: [redirectExperiment],
1428
+ ingestSignature: "worker-issued-ingest-signature",
1429
+ onExperimentViewed: sourceExposure,
1430
+ projectPrerequisites: {
1431
+ startTrackingRule: "return context.url.startsWith('https://landing.test/');",
1432
+ },
1433
+ shopId: "shop_123",
1434
+ trackingStart: "immediate",
1435
+ userId: "visitor_tracking_handover",
1436
+ });
1437
+ for (
1438
+ let attempt = 0;
1439
+ attempt < 20 && sourceReplace.mock.calls.length === 0;
1440
+ attempt += 1
1441
+ ) {
1442
+ await Promise.resolve();
1443
+ }
1444
+
1445
+ expect(sourceReplace).toHaveBeenCalledTimes(1);
1446
+ expect(sourceExposure).not.toHaveBeenCalled();
1447
+ const destination = new URL(String(sourceReplace.mock.calls[0]?.[0]));
1448
+ const { parseRedirectAssignmentCarry } = await import("./redirect");
1449
+ expect(parseRedirectAssignmentCarry(destination.href)).toMatchObject({
1450
+ exposureSent: false,
1451
+ handoverToken: expect.any(String),
1452
+ });
1453
+
1454
+ // Browser storage is origin-scoped. Only the carry links this destination
1455
+ // to the source assignment and its not-yet-exposed state.
1456
+ (globalThis as any).localStorage = makeStorage();
1457
+ (globalThis as any).sessionStorage = makeStorage();
1458
+ vi.clearAllTimers();
1459
+ vi.resetModules();
1460
+ delete (globalThis as any).__drip;
1461
+ const destinationReplace = vi.fn();
1462
+ const destinationExposure = vi.fn();
1463
+ Object.assign((globalThis as any).location, {
1464
+ hostname: "landing.test",
1465
+ href: destination.href,
1466
+ origin: destination.origin,
1467
+ pathname: destination.pathname,
1468
+ search: destination.search,
1469
+ replace: destinationReplace,
1470
+ });
1471
+ globalThis.fetch = vi.fn(async (
1472
+ input: RequestInfo | URL,
1473
+ requestInit?: RequestInit,
1474
+ ) => verifiedHandoverResponse(input, requestInit)
1475
+ ?? new Response("ok", { status: 202 })) as any;
1476
+ const destinationRuntime = await import("./index");
1477
+
1478
+ destinationRuntime.init({
1479
+ antiFlickerMode: "off",
1480
+ endpoint: "https://events.test",
1481
+ experiments: [redirectExperiment],
1482
+ onExperimentViewed: destinationExposure,
1483
+ projectPrerequisites: {
1484
+ startTrackingRule: "return context.url.startsWith('https://landing.test/');",
1485
+ },
1486
+ shopId: "shop_123",
1487
+ trackingStart: "immediate",
1488
+ });
1489
+ for (
1490
+ let attempt = 0;
1491
+ attempt < 20 && destinationExposure.mock.calls.length === 0;
1492
+ attempt += 1
1493
+ ) {
1494
+ await Promise.resolve();
1495
+ }
1496
+ await destinationRuntime.flush();
1497
+
1498
+ expect(destinationReplace).not.toHaveBeenCalled();
1499
+ expect(destinationExposure).toHaveBeenCalledTimes(1);
1500
+ const exposureEvents = vi.mocked(globalThis.fetch).mock.calls.flatMap((call) => {
1501
+ if (call[0] !== "https://events.test/v1/events") return [];
1502
+ return JSON.parse(String(call[1]?.body)).events.filter(
1503
+ (event: { type: string; experimentId?: string }) =>
1504
+ event.type === "experiment_viewed" &&
1505
+ event.experimentId === redirectExperiment.id,
1506
+ );
1507
+ });
1508
+ expect(exposureEvents).toHaveLength(1);
1509
+ });
1510
+
1511
+
1512
+ it("retains a redirect carry from cached init until the fresh network config can import it", async () => {
1513
+ const { buildRedirectDestination } = await import("./redirect");
1514
+ const redirectExperiment = {
1515
+ id: "exp_redirect_refresh",
1516
+ assignmentEpoch: 2,
1517
+ experimentType: "redirect" as const,
1518
+ status: "running" as const,
1519
+ targeting: { url: [{ type: "simple" as const, pattern: "/products/*", include: true }] },
1520
+ trafficAllocation: 1,
1521
+ variations: [{
1522
+ id: "redirect_refresh_treatment",
1523
+ weight: 1,
1524
+ mutations: [],
1525
+ redirectUrl: "https://landing.test/sale",
1526
+ }],
1527
+ };
1528
+ const destination = buildRedirectDestination(
1529
+ "https://plain.test/products/a",
1530
+ "https://landing.test/sale",
1531
+ {
1532
+ experimentId: redirectExperiment.id,
1533
+ variationId: "redirect_refresh_treatment",
1534
+ assignmentEpoch: 2,
1535
+ },
1536
+ "visitor_from_source",
1537
+ )!;
1538
+ Object.assign((globalThis as any).location, {
1539
+ hostname: "landing.test",
1540
+ href: destination.href,
1541
+ origin: "https://landing.test",
1542
+ pathname: "/sale",
1543
+ search: destination.search,
1544
+ replace: vi.fn(),
1545
+ });
1546
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
1547
+ fetchedAt: Date.now(),
1548
+ hash: "cached-before-redirect",
1549
+ experiments: [],
1550
+ }));
1551
+ globalThis.fetch = vi.fn(async (
1552
+ input: RequestInfo | URL,
1553
+ requestInit?: RequestInit,
1554
+ ) => verifiedHandoverResponse(input, requestInit)
1555
+ ?? (String(input).includes("/config")
1556
+ ? new Response(JSON.stringify({ experiments: [redirectExperiment] }), { status: 200 })
1557
+ : new Response("ok", { status: 202 }))) as any;
1558
+ const replaceState = globalThis.history.replaceState;
1559
+ const { getAssignments, getDiagnostics, init } = await import("./index");
1560
+ const { getUserId } = await import("./hash");
1561
+
1562
+ init({
1563
+ antiFlickerMode: "off",
1564
+ endpoint: "https://events.test",
1565
+ lateConfigPolicy: "apply",
1566
+ shopId: "shop_123",
1567
+ trackingStart: "immediate",
1568
+ });
1569
+
1570
+ expect(replaceState).toHaveBeenCalledWith(
1571
+ undefined,
1572
+ "",
1573
+ "https://landing.test/sale",
1574
+ );
1575
+ for (let attempt = 0; attempt < 20 && getDiagnostics().configSource !== "network"; attempt += 1) {
1576
+ await Promise.resolve();
1577
+ }
1578
+ expect(getDiagnostics().configSource).toBe("network");
1579
+ expect(getUserId()).toBe("visitor_from_source");
1580
+ expect(getAssignments()).toEqual([expect.objectContaining({
1581
+ experimentId: "exp_redirect_refresh",
1582
+ variationId: "redirect_refresh_treatment",
1583
+ })]);
1584
+ });
1585
+
1586
+
1587
+ it("recovers a pending redirect carry after config retries are exhausted", async () => {
1588
+ vi.setSystemTime(new Date("2026-07-27T10:00:00Z"));
1589
+ const {
1590
+ buildRedirectDestination,
1591
+ REDIRECT_ASSIGNMENT_PENDING_KEY,
1592
+ } = await import("./redirect");
1593
+ const redirectExperiment = {
1594
+ id: "exp_redirect_retry_recovery",
1595
+ assignmentEpoch: 1,
1596
+ experimentType: "redirect" as const,
1597
+ status: "running" as const,
1598
+ targeting: {
1599
+ url: [{
1600
+ type: "simple" as const,
1601
+ pattern: "/products/*",
1602
+ include: true,
1603
+ }],
1604
+ },
1605
+ trafficAllocation: 1,
1606
+ variations: [{
1607
+ id: "redirect_retry_recovery_treatment",
1608
+ weight: 1,
1609
+ mutations: [],
1610
+ redirectUrl: "https://landing.test/sale",
1611
+ }],
1612
+ };
1613
+ const destination = buildRedirectDestination(
1614
+ "https://plain.test/products/source",
1615
+ "https://landing.test/sale",
1616
+ {
1617
+ experimentId: redirectExperiment.id,
1618
+ variationId: "redirect_retry_recovery_treatment",
1619
+ assignmentEpoch: 1,
1620
+ },
1621
+ "visitor_retry_recovery",
1622
+ )!;
1623
+ Object.assign((globalThis as any).location, {
1624
+ hostname: "landing.test",
1625
+ href: destination.href,
1626
+ origin: "https://landing.test",
1627
+ pathname: "/sale",
1628
+ search: destination.search,
1629
+ replace: vi.fn(),
1630
+ });
1631
+ const configFailure = new Error("config unavailable");
1632
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => (
1633
+ String(input).includes("/config")
1634
+ ? Promise.reject(configFailure)
1635
+ : Promise.resolve(new Response("ok", { status: 202 }))
1636
+ )) as any;
1637
+ vi.spyOn(console, "error").mockImplementation(() => {});
1638
+ const failedRuntime = await import("./index");
1639
+
1640
+ failedRuntime.init({
1641
+ antiFlickerMode: "off",
1642
+ configCache: false,
1643
+ endpoint: "https://events.test",
1644
+ shopId: "shop_123",
1645
+ trackingStart: "immediate",
1646
+ });
1647
+ await vi.advanceTimersByTimeAsync(1_600);
1648
+
1649
+ expect(vi.mocked(globalThis.fetch).mock.calls.filter(
1650
+ ([input]) => String(input).includes("/config"),
1651
+ )).toHaveLength(3);
1652
+ expect(sessionStorage.getItem(REDIRECT_ASSIGNMENT_PENDING_KEY)).not.toBeNull();
1653
+ const pendingRecord = JSON.parse(
1654
+ sessionStorage.getItem(REDIRECT_ASSIGNMENT_PENDING_KEY)!,
1655
+ ) as { expiresAt: number };
1656
+ expect(pendingRecord).toEqual(
1657
+ expect.objectContaining({
1658
+ assignmentEpoch: 1,
1659
+ experimentId: redirectExperiment.id,
1660
+ expiresAt: expect.any(Number),
1661
+ handoverToken: expect.any(String),
1662
+ variationId: "redirect_retry_recovery_treatment",
1663
+ }),
1664
+ );
1665
+
1666
+ vi.clearAllTimers();
1667
+ vi.setSystemTime(new Date("2026-07-27T10:00:01.600Z"));
1668
+ vi.resetModules();
1669
+ delete (globalThis as any).__drip;
1670
+ Object.assign((globalThis as any).location, {
1671
+ href: "https://landing.test/sale",
1672
+ search: "",
1673
+ replace: vi.fn(),
1674
+ });
1675
+ globalThis.fetch = vi.fn(async (
1676
+ input: RequestInfo | URL,
1677
+ requestInit?: RequestInit,
1678
+ ) => verifiedHandoverResponse(input, requestInit)
1679
+ ?? (String(input).includes("/config")
1680
+ ? new Response(
1681
+ JSON.stringify({ experiments: [redirectExperiment] }),
1682
+ { status: 200 },
1683
+ )
1684
+ : new Response("ok", { status: 202 }))) as any;
1685
+ expect(Date.now()).toBeLessThan(pendingRecord.expiresAt);
1686
+ expect(pendingRecord.expiresAt - Date.now()).toBeLessThanOrEqual(10 * 60 * 1000);
1687
+ expect(sessionStorage.getItem(REDIRECT_ASSIGNMENT_PENDING_KEY)).not.toBeNull();
1688
+ const pendingCarryRuntime = await import("./redirect");
1689
+ expect(pendingCarryRuntime.readPendingRedirectAssignmentCarry()).toEqual(
1690
+ expect.objectContaining({
1691
+ experimentId: redirectExperiment.id,
1692
+ handoverToken: expect.any(String),
1693
+ }),
1694
+ );
1695
+ const recoveredRuntime = await import("./index");
1696
+ const { getUserId } = await import("./hash");
1697
+
1698
+ recoveredRuntime.init({
1699
+ antiFlickerMode: "off",
1700
+ configCache: false,
1701
+ endpoint: "https://events.test",
1702
+ lateConfigPolicy: "apply",
1703
+ shopId: "shop_123",
1704
+ trackingStart: "immediate",
1705
+ });
1706
+ for (
1707
+ let attempt = 0;
1708
+ attempt < 20 && recoveredRuntime.getDiagnostics().configSource !== "network";
1709
+ attempt += 1
1710
+ ) {
1711
+ await Promise.resolve();
1712
+ }
1713
+
1714
+ expect(recoveredRuntime.getDiagnostics().configSource).toBe("network");
1715
+ expect(getUserId()).toBe("visitor_retry_recovery");
1716
+ expect(recoveredRuntime.getAssignments()).toEqual([
1717
+ expect.objectContaining({
1718
+ experimentId: redirectExperiment.id,
1719
+ variationId: "redirect_retry_recovery_treatment",
1720
+ }),
1721
+ ]);
1722
+ expect(sessionStorage.getItem(REDIRECT_ASSIGNMENT_PENDING_KEY)).toBeNull();
1723
+ });
1724
+
1725
+
1726
+ it("expires a pending redirect carry after its bounded handover TTL", async () => {
1727
+ vi.setSystemTime(new Date("2026-07-27T10:00:00Z"));
1728
+ const {
1729
+ buildRedirectDestination,
1730
+ REDIRECT_ASSIGNMENT_PENDING_KEY,
1731
+ } = await import("./redirect");
1732
+ const redirectExperiment = {
1733
+ id: "exp_redirect_pending_expiry",
1734
+ assignmentEpoch: 1,
1735
+ experimentType: "redirect" as const,
1736
+ status: "running" as const,
1737
+ targeting: {
1738
+ url: [{
1739
+ type: "simple" as const,
1740
+ pattern: "/products/*",
1741
+ include: true,
1742
+ }],
1743
+ },
1744
+ trafficAllocation: 1,
1745
+ variations: [{
1746
+ id: "redirect_pending_expiry_treatment",
1747
+ weight: 1,
1748
+ mutations: [],
1749
+ redirectUrl: "https://landing.test/sale",
1750
+ }],
1751
+ };
1752
+ const destination = buildRedirectDestination(
1753
+ "https://plain.test/products/source",
1754
+ "https://landing.test/sale",
1755
+ {
1756
+ experimentId: redirectExperiment.id,
1757
+ variationId: "redirect_pending_expiry_treatment",
1758
+ assignmentEpoch: 1,
1759
+ },
1760
+ "visitor_pending_expiry",
1761
+ { handoverTtlSeconds: 120 },
1762
+ )!;
1763
+ Object.assign((globalThis as any).location, {
1764
+ hostname: "landing.test",
1765
+ href: destination.href,
1766
+ origin: "https://landing.test",
1767
+ pathname: "/sale",
1768
+ search: destination.search,
1769
+ replace: vi.fn(),
1770
+ });
1771
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => (
1772
+ String(input).includes("/config")
1773
+ ? Promise.reject(new Error("config unavailable"))
1774
+ : Promise.resolve(new Response("ok", { status: 202 }))
1775
+ )) as any;
1776
+ vi.spyOn(console, "error").mockImplementation(() => {});
1777
+ const failedRuntime = await import("./index");
1778
+
1779
+ failedRuntime.init({
1780
+ antiFlickerMode: "off",
1781
+ configCache: false,
1782
+ endpoint: "https://events.test",
1783
+ shopId: "shop_123",
1784
+ trackingStart: "immediate",
1785
+ });
1786
+ await vi.advanceTimersByTimeAsync(1_600);
1787
+ expect(sessionStorage.getItem(REDIRECT_ASSIGNMENT_PENDING_KEY)).not.toBeNull();
1788
+
1789
+ vi.clearAllTimers();
1790
+ vi.setSystemTime(new Date("2026-07-27T10:02:01Z"));
1791
+ vi.resetModules();
1792
+ delete (globalThis as any).__drip;
1793
+ Object.assign((globalThis as any).location, {
1794
+ href: "https://landing.test/sale",
1795
+ search: "",
1796
+ replace: vi.fn(),
1797
+ });
1798
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => (
1799
+ String(input).includes("/config")
1800
+ ? new Response(
1801
+ JSON.stringify({ experiments: [redirectExperiment] }),
1802
+ { status: 200 },
1803
+ )
1804
+ : new Response("ok", { status: 202 })
1805
+ )) as any;
1806
+ const reloadedRuntime = await import("./index");
1807
+ const { getUserId } = await import("./hash");
1808
+
1809
+ reloadedRuntime.init({
1810
+ antiFlickerMode: "off",
1811
+ configCache: false,
1812
+ endpoint: "https://events.test",
1813
+ lateConfigPolicy: "apply",
1814
+ shopId: "shop_123",
1815
+ trackingStart: "immediate",
1816
+ });
1817
+ for (
1818
+ let attempt = 0;
1819
+ attempt < 20 && reloadedRuntime.getDiagnostics().configSource !== "network";
1820
+ attempt += 1
1821
+ ) {
1822
+ await Promise.resolve();
1823
+ }
1824
+
1825
+ expect(reloadedRuntime.getDiagnostics().configSource).toBe("network");
1826
+ expect(getUserId()).not.toBe("visitor_pending_expiry");
1827
+ expect(reloadedRuntime.getAssignments()).toEqual([]);
1828
+ expect(sessionStorage.getItem(REDIRECT_ASSIGNMENT_PENDING_KEY)).toBeNull();
1829
+ });
1830
+
1831
+
1832
+ it("discards an epoch-stale redirect carry after the fresh config has its chance", async () => {
1833
+ const { buildRedirectDestination } = await import("./redirect");
1834
+ const destination = buildRedirectDestination(
1835
+ "https://plain.test/products/a",
1836
+ "https://landing.test/sale",
1837
+ {
1838
+ experimentId: "exp_redirect_stale_refresh",
1839
+ variationId: "redirect_stale_treatment",
1840
+ assignmentEpoch: 1,
1841
+ },
1842
+ "visitor_from_source",
1843
+ )!;
1844
+ Object.assign((globalThis as any).location, {
1845
+ hostname: "landing.test",
1846
+ href: destination.href,
1847
+ origin: "https://landing.test",
1848
+ pathname: "/sale",
1849
+ search: destination.search,
1850
+ replace: vi.fn(),
1851
+ });
1852
+ localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
1853
+ fetchedAt: Date.now(),
1854
+ hash: "cached-before-redirect-epoch",
1855
+ experiments: [],
1856
+ }));
1857
+ globalThis.fetch = vi.fn(async (
1858
+ input: RequestInfo | URL,
1859
+ requestInit?: RequestInit,
1860
+ ) => verifiedHandoverResponse(input, requestInit)
1861
+ ?? (String(input).includes("/config")
1862
+ ? new Response(JSON.stringify({
1863
+ experiments: [{
1864
+ id: "exp_redirect_stale_refresh",
1865
+ assignmentEpoch: 2,
1866
+ experimentType: "redirect",
1867
+ status: "running",
1868
+ targeting: { url: [{ type: "simple", pattern: "/products/*", include: true }] },
1869
+ trafficAllocation: 1,
1870
+ variations: [{
1871
+ id: "redirect_stale_treatment",
1872
+ weight: 1,
1873
+ mutations: [],
1874
+ redirectUrl: "https://landing.test/sale",
1875
+ }],
1876
+ }],
1877
+ }), { status: 200 })
1878
+ : new Response("ok", { status: 202 }))) as any;
1879
+ const replaceState = globalThis.history.replaceState;
1880
+ const { getAssignments, getDiagnostics, init } = await import("./index");
1881
+ const { getUserId } = await import("./hash");
1882
+
1883
+ init({
1884
+ antiFlickerMode: "off",
1885
+ endpoint: "https://events.test",
1886
+ lateConfigPolicy: "apply",
1887
+ shopId: "shop_123",
1888
+ trackingStart: "immediate",
1889
+ });
1890
+
1891
+ expect(replaceState).toHaveBeenCalledWith(
1892
+ undefined,
1893
+ "",
1894
+ "https://landing.test/sale",
1895
+ );
1896
+ for (let attempt = 0; attempt < 20 && getDiagnostics().configSource !== "network"; attempt += 1) {
1897
+ await Promise.resolve();
1898
+ }
1899
+ expect(getDiagnostics().configSource).toBe("network");
1900
+ expect(getUserId()).not.toBe("visitor_from_source");
1901
+ expect(getAssignments()).toEqual([]);
1902
+ });
1903
+
1904
+
1905
+ it("honors a same-origin sticky redirect assignment when only its source URL gate fails", async () => {
1906
+ const redirectExperiment = {
1907
+ id: "exp_redirect_same_origin_url_only",
1908
+ assignmentEpoch: 1,
1909
+ experimentType: "redirect" as const,
1910
+ status: "running" as const,
1911
+ targeting: {
1912
+ url: [{
1913
+ type: "simple" as const,
1914
+ pattern: "/products/source",
1915
+ include: true,
1916
+ }],
1917
+ },
1918
+ trafficAllocation: 1,
1919
+ variations: [{
1920
+ id: "redirect_same_origin_url_only_treatment",
1921
+ weight: 1,
1922
+ mutations: [],
1923
+ redirectUrl: "https://plain.test/sale",
1924
+ }],
1925
+ };
1926
+ const sourceReplace = vi.fn();
1927
+ Object.assign((globalThis as any).location, {
1928
+ hostname: "plain.test",
1929
+ href: "https://plain.test/products/source",
1930
+ origin: "https://plain.test",
1931
+ pathname: "/products/source",
1932
+ search: "",
1933
+ replace: sourceReplace,
1934
+ });
1935
+ const sourceRuntime = await import("./index");
1936
+
1937
+ sourceRuntime.init({
1938
+ antiFlickerMode: "off",
1939
+ endpoint: "https://events.test",
1940
+ experiments: [redirectExperiment],
1941
+ shopId: "shop_123",
1942
+ trackingStart: "immediate",
1943
+ userId: "visitor_same_origin_url_only",
1944
+ });
1945
+
1946
+ expect(sourceReplace).toHaveBeenCalledTimes(1);
1947
+ const destination = new URL(String(sourceReplace.mock.calls[0]?.[0]));
1948
+ expect(destination.href).toBe("https://plain.test/sale");
1949
+ expect(destination.searchParams.has("_apex_assignment")).toBe(false);
1950
+ expect(
1951
+ JSON.parse(localStorage.getItem("drip_assignments_v1") ?? "{}"),
1952
+ ).toHaveProperty(
1953
+ redirectExperiment.id,
1954
+ expect.arrayContaining(["redirect_same_origin_url_only_treatment"]),
1955
+ );
1956
+
1957
+ vi.clearAllTimers();
1958
+ vi.resetModules();
1959
+ delete (globalThis as any).__drip;
1960
+ const destinationReplace = vi.fn();
1961
+ Object.assign((globalThis as any).location, {
1962
+ hostname: "plain.test",
1963
+ href: destination.href,
1964
+ origin: destination.origin,
1965
+ pathname: destination.pathname,
1966
+ search: destination.search,
1967
+ replace: destinationReplace,
1968
+ });
1969
+ const destinationRuntime = await import("./index");
1970
+
1971
+ destinationRuntime.init({
1972
+ antiFlickerMode: "off",
1973
+ endpoint: "https://events.test",
1974
+ experiments: [redirectExperiment],
1975
+ shopId: "shop_123",
1976
+ trackingStart: "immediate",
1977
+ userId: "visitor_same_origin_url_only",
1978
+ });
1979
+
1980
+ expect(destinationRuntime.getAssignments()).toEqual([
1981
+ expect.objectContaining({
1982
+ experimentId: redirectExperiment.id,
1983
+ variationId: "redirect_same_origin_url_only_treatment",
1984
+ }),
1985
+ ]);
1986
+ expect(destinationReplace).not.toHaveBeenCalled();
1987
+ });
1988
+
1989
+
1990
+ it("does not honor a sticky redirect assignment when its segment gate fails", async () => {
1991
+ const redirectExperiment = {
1992
+ id: "exp_redirect_same_origin_segment",
1993
+ assignmentEpoch: 1,
1994
+ experimentType: "redirect" as const,
1995
+ status: "running" as const,
1996
+ targeting: {
1997
+ url: [{
1998
+ type: "simple" as const,
1999
+ pattern: "/products/source",
2000
+ include: true,
2001
+ }],
2002
+ segmentRules: [{
2003
+ id: "vip-segment",
2004
+ rules: {
2005
+ version: 1 as const,
2006
+ operator: "and" as const,
2007
+ conditions: [{
2008
+ signal: "customer.tier",
2009
+ operator: "equals" as const,
2010
+ value: "vip",
2011
+ }],
2012
+ },
2013
+ }],
2014
+ },
2015
+ trafficAllocation: 1,
2016
+ variations: [{
2017
+ id: "redirect_same_origin_segment_treatment",
2018
+ weight: 1,
2019
+ mutations: [],
2020
+ redirectUrl: "https://plain.test/sale",
2021
+ }],
2022
+ };
2023
+ localStorage.setItem(
2024
+ "drip_assignments_v1",
2025
+ JSON.stringify({
2026
+ [redirectExperiment.id]: [
2027
+ "redirect_same_origin_segment_treatment",
2028
+ 1,
2029
+ Date.now(),
2030
+ ],
2031
+ }),
2032
+ );
2033
+ Object.assign((globalThis as any).location, {
2034
+ hostname: "plain.test",
2035
+ href: "https://plain.test/products/source",
2036
+ origin: "https://plain.test",
2037
+ pathname: "/products/source",
2038
+ search: "",
2039
+ replace: vi.fn(),
2040
+ });
2041
+ const removeHideStyle = vi.fn();
2042
+ vi.mocked(document.getElementById).mockImplementation((id: string) => (
2043
+ id === "drip-hide" ? { remove: removeHideStyle } as any : null
2044
+ ));
2045
+ const { getAssignments, getDiagnostics, init } = await import("./index");
2046
+
2047
+ init({
2048
+ antiFlickerMode: "off",
2049
+ attributes: { "customer.tier": "standard" },
2050
+ endpoint: "https://events.test",
2051
+ experiments: [redirectExperiment],
2052
+ shopId: "shop_123",
2053
+ trackingStart: "immediate",
2054
+ userId: "visitor_same_origin_segment",
2055
+ });
2056
+
2057
+ expect(getAssignments()).toEqual([]);
2058
+ expect(getDiagnostics().activation.experiments[0]).toMatchObject({
2059
+ experimentId: redirectExperiment.id,
2060
+ status: "failed",
2061
+ reason: "segment_mismatch",
2062
+ });
2063
+ expect(removeHideStyle).toHaveBeenCalledTimes(1);
2064
+ });
2065
+
2066
+
2067
+ it("honors a verified carry-imported redirect assignment without destination audience rechecks", async () => {
2068
+ const { buildRedirectDestination } = await import("./redirect");
2069
+ const redirectExperiment = {
2070
+ id: "exp_redirect_source_signal",
2071
+ assignmentEpoch: 1,
2072
+ experimentType: "redirect" as const,
2073
+ status: "running" as const,
2074
+ targeting: {
2075
+ url: [{ type: "simple" as const, pattern: "/products/*", include: true }],
2076
+ attributes: [{
2077
+ attribute: "customer.tier",
2078
+ operator: "equals" as const,
2079
+ value: "vip",
2080
+ }],
2081
+ segmentRules: [{
2082
+ id: "source-host-audience",
2083
+ rules: {
2084
+ version: 1 as const,
2085
+ operator: "and" as const,
2086
+ conditions: [{
2087
+ signal: "url.full",
2088
+ operator: "contains" as const,
2089
+ value: "plain.test",
2090
+ }],
2091
+ },
2092
+ }],
2093
+ },
2094
+ trafficAllocation: 1,
2095
+ variations: [{
2096
+ id: "redirect_source_signal_treatment",
2097
+ weight: 1,
2098
+ mutations: [],
2099
+ redirectUrl: "https://landing.test/sale",
2100
+ }],
2101
+ };
2102
+ const destination = buildRedirectDestination(
2103
+ "https://plain.test/products/a",
2104
+ "https://landing.test/sale",
2105
+ {
2106
+ experimentId: redirectExperiment.id,
2107
+ variationId: "redirect_source_signal_treatment",
2108
+ assignmentEpoch: 1,
2109
+ },
2110
+ "visitor_source_signal",
2111
+ )!;
2112
+ Object.assign((globalThis as any).location, {
2113
+ hostname: "landing.test",
2114
+ href: destination.href,
2115
+ origin: "https://landing.test",
2116
+ pathname: "/sale",
2117
+ search: destination.search,
2118
+ replace: vi.fn(),
2119
+ });
2120
+ globalThis.fetch = vi.fn(async (
2121
+ input: RequestInfo | URL,
2122
+ requestInit?: RequestInit,
2123
+ ) => verifiedHandoverResponse(input, requestInit)
2124
+ ?? new Response("ok", { status: 202 })) as any;
2125
+ const { getAssignments, getDiagnostics, init } = await import("./index");
2126
+
2127
+ init({
2128
+ antiFlickerMode: "off",
2129
+ attributes: { "customer.tier": "standard" },
2130
+ endpoint: "https://events.test",
2131
+ experiments: [redirectExperiment],
2132
+ shopId: "shop_123",
2133
+ trackingStart: "immediate",
2134
+ });
2135
+ for (let attempt = 0; attempt < 20 && getAssignments().length === 0; attempt += 1) {
2136
+ await Promise.resolve();
2137
+ }
2138
+
2139
+ expect(getAssignments()).toEqual([
2140
+ expect.objectContaining({
2141
+ experimentId: redirectExperiment.id,
2142
+ variationId: "redirect_source_signal_treatment",
2143
+ }),
2144
+ ]);
2145
+ expect(getDiagnostics().activation.experiments[0]).toMatchObject({
2146
+ experimentId: redirectExperiment.id,
2147
+ status: "passed",
2148
+ reason: "persisted_assignment_off_target",
2149
+ });
2150
+ expect(
2151
+ getDiagnostics().activation.experiments[0].gates.some(
2152
+ (gate) => gate.id === "page" || gate.id === "audience",
2153
+ ),
2154
+ ).toBe(false);
2155
+ });
2156
+ });