@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,541 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ buildRedirectDestination,
4
+ finalizeRedirectAssignmentCarryHandover,
5
+ filterUnsafeRedirectAssignments,
6
+ hasGeneratedRedirectAssignmentCarry,
7
+ isRedirectLoop,
8
+ importRedirectAssignmentCarry,
9
+ readRedirectAssignmentCarry,
10
+ redirectForAssignment,
11
+ stripRedirectAssignmentCarry,
12
+ validateRedirectAssignmentCarry,
13
+ } from "./redirect";
14
+ import type { Assignment, Experiment } from "./types";
15
+
16
+ function decodeTestHandover(token: string): Record<string, unknown> {
17
+ const payload = token.split(".")[0];
18
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
19
+ return JSON.parse(atob(normalized + "=".repeat((4 - normalized.length % 4) % 4)));
20
+ }
21
+
22
+ function verifyTestHandover(token: string): Promise<unknown> {
23
+ return Promise.resolve({ ...decodeTestHandover(token), shop: "shop_123" });
24
+ }
25
+
26
+ const experiment: Experiment = { id: "exp_redirect", experimentType: "redirect", variations: [
27
+ { id: "control", weight: 0.5, mutations: [], isControl: true },
28
+ { id: "landing", weight: 0.5, mutations: [], redirectUrl: "https://landing.example.net/sale?theme=long" },
29
+ ] };
30
+ const assignment: Assignment = { experimentId: experiment.id, variationId: "landing", pageActive: true, index: 1, mutations: [], isControl: false,
31
+ hashValue: "visitor", redirectUrl: "https://landing.example.net/sale?theme=long" };
32
+
33
+ describe("redirect experiments", () => {
34
+ it("always strips malformed redirect carry parameters", () => {
35
+ const replaceState = vi.fn();
36
+ vi.stubGlobal("history", { state: undefined, replaceState });
37
+ stripRedirectAssignmentCarry("https://landing.test/sale?_apex_assignment=malformed&keep=1");
38
+ expect(replaceState).toHaveBeenCalledWith(undefined, "", "https://landing.test/sale?keep=1");
39
+ });
40
+ it("does not redirect without an explicit source URL rule", () => {
41
+ expect(redirectForAssignment([experiment], [assignment], "https://shop.example.com/cart")).toBeNull();
42
+ });
43
+ it("does not redirect with an exclusion-only source rule set", () => {
44
+ const exclusionOnly = {
45
+ ...experiment,
46
+ targeting: {
47
+ url: [
48
+ {
49
+ type: "simple" as const,
50
+ pattern: "/checkout/*",
51
+ include: false,
52
+ },
53
+ ],
54
+ },
55
+ };
56
+
57
+ expect(
58
+ redirectForAssignment(
59
+ [exclusionOnly],
60
+ [assignment],
61
+ "https://shop.example.com/products/a",
62
+ ),
63
+ ).toBeNull();
64
+ expect(
65
+ filterUnsafeRedirectAssignments(
66
+ [exclusionOnly],
67
+ [assignment],
68
+ "https://shop.example.com/products/a",
69
+ ),
70
+ ).toEqual([]);
71
+ });
72
+ it("preserves attribution query/hash and lets target params win", () => {
73
+ const result = buildRedirectDestination(
74
+ "https://shop.example.com/a?utm_source=ad&tag=a&tag=b&theme=short&theme=ignored#details",
75
+ "https://landing.example.net/sale?theme=long&theme=wide",
76
+ assignment,
77
+ )!;
78
+ expect(result.searchParams.get("utm_source")).toBe("ad");
79
+ expect(result.searchParams.getAll("tag")).toEqual(["a", "b"]);
80
+ expect(result.searchParams.getAll("theme")).toEqual(["long", "wide"]);
81
+ expect(result.searchParams.get("_apex_assignment")).toContain("exp_redirect");
82
+ expect(result.hash).toBe("#details");
83
+ });
84
+ it("preserves a merchant-authored reserved parameter and emits a collision signal", () => {
85
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
86
+ const result = buildRedirectDestination(
87
+ "https://shop.example.com/a?utm_source=ad",
88
+ "https://landing.example.net/sale?_apex_assignment=merchant-value",
89
+ assignment,
90
+ "visitor_source",
91
+ )!;
92
+
93
+ expect(result.searchParams.get("_apex_assignment")).toBe("merchant-value");
94
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("preserving the merchant-authored value"));
95
+ warn.mockRestore();
96
+ });
97
+ it("recognizes a generated redirect carry after its destination URL is cloned", () => {
98
+ const destination = buildRedirectDestination(
99
+ "https://shop.example.com/a",
100
+ assignment.redirectUrl!,
101
+ assignment,
102
+ "visitor_source",
103
+ )!;
104
+ const clonedDestination = new URL(destination.href);
105
+
106
+ expect(hasGeneratedRedirectAssignmentCarry(clonedDestination)).toBe(true);
107
+ finalizeRedirectAssignmentCarryHandover(clonedDestination, "verified-token");
108
+ expect(JSON.parse(
109
+ clonedDestination.searchParams.get("_apex_assignment")!,
110
+ ).handover).toBe("verified-token");
111
+ });
112
+ it("does not redirect when the assigned destination is already current (negative loop guard)", () => {
113
+ const same = { ...assignment, redirectUrl: "https://shop.example.com/sale?utm_source=ad#offer" };
114
+ expect(redirectForAssignment([experiment], [same], "https://shop.example.com/sale?utm_source=ad#offer")).toBeNull();
115
+ });
116
+ it("does not redirect into the experiment's own source targeting", () => {
117
+ const targeted = { ...experiment, targeting: { url: [{ type: "simple" as const, pattern: "/products/*", include: true }] } };
118
+ const sameTarget = { ...assignment, redirectUrl: "https://shop.example.com/products/destination" };
119
+ expect(redirectForAssignment([targeted], [sameTarget], "https://shop.example.com/products/source")).toBeNull();
120
+ expect(
121
+ filterUnsafeRedirectAssignments(
122
+ [targeted],
123
+ [sameTarget],
124
+ "https://shop.example.com/products/source",
125
+ ),
126
+ ).toEqual([]);
127
+ });
128
+ it("redirects cross-origin when the destination only shares a path-only source rule", () => {
129
+ const targeted = { ...experiment, targeting: { url: [{ type: "simple" as const, pattern: "/products/*", include: true }] } };
130
+ const crossOriginSamePath = {
131
+ ...assignment,
132
+ redirectUrl: "https://landing.example.com/products/destination",
133
+ };
134
+
135
+ expect(
136
+ redirectForAssignment(
137
+ [targeted],
138
+ [crossOriginSamePath],
139
+ "https://shop.example.com/products/source",
140
+ )?.origin,
141
+ ).toBe("https://landing.example.com");
142
+ expect(
143
+ filterUnsafeRedirectAssignments(
144
+ [targeted],
145
+ [crossOriginSamePath],
146
+ "https://shop.example.com/products/source",
147
+ ),
148
+ ).toEqual([crossOriginSamePath]);
149
+ });
150
+ it("still blocks a same-origin destination that shares a path-only source rule", () => {
151
+ const targeted = { ...experiment, targeting: { url: [{ type: "simple" as const, pattern: "/products/*", include: true }] } };
152
+ const sameOriginSamePath = {
153
+ ...assignment,
154
+ redirectUrl: "https://shop.example.com/products/destination",
155
+ };
156
+
157
+ expect(
158
+ redirectForAssignment(
159
+ [targeted],
160
+ [sameOriginSamePath],
161
+ "https://shop.example.com/products/source",
162
+ ),
163
+ ).toBeNull();
164
+ });
165
+ it.each([
166
+ "https://landing.example.com/products/*",
167
+ "landing.example.com/products/*",
168
+ ])("blocks a cross-origin destination when a source rule specifies its host: %s", (hostPattern) => {
169
+ const targeted = {
170
+ ...experiment,
171
+ targeting: {
172
+ url: [
173
+ {
174
+ type: "simple" as const,
175
+ pattern: "/products/*",
176
+ include: true,
177
+ },
178
+ {
179
+ type: "simple" as const,
180
+ pattern: hostPattern,
181
+ include: true,
182
+ },
183
+ ],
184
+ },
185
+ };
186
+ const crossOriginSamePath = {
187
+ ...assignment,
188
+ redirectUrl: "https://landing.example.com/products/destination",
189
+ };
190
+
191
+ expect(
192
+ redirectForAssignment(
193
+ [targeted],
194
+ [crossOriginSamePath],
195
+ "https://shop.example.com/products/source",
196
+ ),
197
+ ).toBeNull();
198
+ });
199
+ it("allows a cross-origin destination excluded by a host-agnostic rule", () => {
200
+ const targeted = {
201
+ ...experiment,
202
+ targeting: {
203
+ url: [
204
+ {
205
+ type: "simple" as const,
206
+ pattern: "https://shop.example.com/products/*",
207
+ include: true,
208
+ },
209
+ {
210
+ type: "simple" as const,
211
+ pattern: "https://landing.example.com/products/*",
212
+ include: true,
213
+ },
214
+ {
215
+ type: "simple" as const,
216
+ pattern: "/products/excluded",
217
+ include: false,
218
+ },
219
+ ],
220
+ },
221
+ };
222
+ const excludedDestination = {
223
+ ...assignment,
224
+ redirectUrl: "https://landing.example.com/products/excluded",
225
+ };
226
+
227
+ expect(
228
+ redirectForAssignment(
229
+ [targeted],
230
+ [excludedDestination],
231
+ "https://shop.example.com/products/source",
232
+ )?.href,
233
+ ).toContain("https://landing.example.com/products/excluded");
234
+ expect(
235
+ filterUnsafeRedirectAssignments(
236
+ [targeted],
237
+ [excludedDestination],
238
+ "https://shop.example.com/products/source",
239
+ ),
240
+ ).toEqual([excludedDestination]);
241
+ });
242
+ it("does not redirect a sticky treatment from a page outside source targeting", () => {
243
+ const targeted = { ...experiment, targeting: { url: [{ type: "simple" as const, pattern: "/products/*", include: true }] } };
244
+ expect(redirectForAssignment([targeted], [assignment], "https://shop.example.com/cart")).toBeNull();
245
+ });
246
+ it("redirects when destination differs", () => {
247
+ const destination = buildRedirectDestination("https://shop.example.com/a", assignment.redirectUrl!, assignment)!;
248
+ expect(isRedirectLoop("https://shop.example.com/a", destination)).toBe(false);
249
+ });
250
+
251
+ it.each([
252
+ [
253
+ "a protocol change",
254
+ "https://shop.example.com/products/a",
255
+ "http://shop.example.com/sale",
256
+ ],
257
+ [
258
+ "a port change",
259
+ "https://shop.example.com:8443/products/a",
260
+ "https://shop.example.com:9443/sale",
261
+ ],
262
+ ])("carries assignment identity across %s on the same hostname", (
263
+ _label,
264
+ currentHref,
265
+ redirectUrl,
266
+ ) => {
267
+ const destination = buildRedirectDestination(
268
+ currentHref,
269
+ redirectUrl,
270
+ assignment,
271
+ "visitor_source",
272
+ )!;
273
+
274
+ expect(destination.searchParams.get("_apex_assignment")).toContain(
275
+ "exp_redirect",
276
+ );
277
+ });
278
+
279
+ it("adopts a Worker-verified carried visitor identity only inside the handover window", async () => {
280
+ const destination = buildRedirectDestination(
281
+ "https://shop.example.com/products/a",
282
+ assignment.redirectUrl!,
283
+ { ...assignment, assignmentEpoch: 0 },
284
+ "visitor_source",
285
+ { handoverTtlSeconds: 120 },
286
+ )!;
287
+ await expect(readRedirectAssignmentCarry(
288
+ [{ ...experiment, assignmentEpoch: -2 }],
289
+ destination.href,
290
+ "shop_123",
291
+ verifyTestHandover,
292
+ )).resolves.toMatchObject({
293
+ variationId: "landing",
294
+ visitorId: "visitor_source",
295
+ });
296
+ });
297
+
298
+ it("imports carry on the configured destination entry path but not another path on the host", async () => {
299
+ const destination = buildRedirectDestination(
300
+ "https://shop.example.com/products/a",
301
+ assignment.redirectUrl!,
302
+ assignment,
303
+ "visitor_source",
304
+ { handoverTtlSeconds: 120 },
305
+ )!;
306
+ await expect(readRedirectAssignmentCarry(
307
+ [experiment],
308
+ destination.href,
309
+ "shop_123",
310
+ verifyTestHandover,
311
+ )).resolves.toMatchObject({ variationId: "landing" });
312
+
313
+ const unrelated = new URL(destination.href);
314
+ unrelated.pathname = "/account";
315
+ const verify = vi.fn(verifyTestHandover);
316
+ await expect(readRedirectAssignmentCarry(
317
+ [experiment],
318
+ unrelated.href,
319
+ "shop_123",
320
+ verify,
321
+ )).resolves.toBeNull();
322
+ expect(verify).not.toHaveBeenCalled();
323
+ });
324
+
325
+ it("rejects a valid handover when destinationPath is tampered to match a non-destination page", async () => {
326
+ const destination = buildRedirectDestination(
327
+ "https://shop.example.com/products/a",
328
+ assignment.redirectUrl!,
329
+ assignment,
330
+ "visitor_source",
331
+ { handoverTtlSeconds: 120 },
332
+ )!;
333
+ const rawCarry = JSON.parse(destination.searchParams.get("_apex_assignment")!);
334
+ rawCarry.destinationPath = "/account";
335
+ destination.searchParams.set("_apex_assignment", JSON.stringify(rawCarry));
336
+ destination.pathname = "/account";
337
+
338
+ await expect(readRedirectAssignmentCarry(
339
+ [experiment],
340
+ destination.href,
341
+ "shop_123",
342
+ verifyTestHandover,
343
+ )).resolves.toBeNull();
344
+ });
345
+
346
+ it.each([
347
+ ["relative", "/sale", "https://landing.example.net/sale?merchant=kept"],
348
+ ["relative trailing slash", "/sale/", "https://landing.example.net/sale?merchant=kept"],
349
+ ["absolute", "https://landing.example.net/sale", "https://landing.example.net/sale?merchant=kept"],
350
+ ["absolute trailing slash", "https://landing.example.net/sale/", "https://landing.example.net/sale?merchant=kept"],
351
+ ])("imports a carry at its %s configured destination", async (_label, redirectUrl, arrivalHref) => {
352
+ const configuredExperiment: Experiment = {
353
+ ...experiment,
354
+ variations: experiment.variations.map((variation) => variation.id === "landing"
355
+ ? { ...variation, redirectUrl }
356
+ : variation),
357
+ };
358
+ const destination = buildRedirectDestination(
359
+ "https://shop.example.com/products/a",
360
+ arrivalHref,
361
+ assignment,
362
+ "visitor_source",
363
+ { handoverTtlSeconds: 120 },
364
+ )!;
365
+
366
+ await expect(readRedirectAssignmentCarry(
367
+ [configuredExperiment],
368
+ destination.href,
369
+ "shop_123",
370
+ verifyTestHandover,
371
+ )).resolves.toMatchObject({ variationId: "landing" });
372
+ });
373
+
374
+ it("rejects an absolute configured destination on a different host", async () => {
375
+ const destination = buildRedirectDestination(
376
+ "https://shop.example.com/products/a",
377
+ assignment.redirectUrl!,
378
+ assignment,
379
+ "visitor_source",
380
+ { handoverTtlSeconds: 120 },
381
+ )!;
382
+ destination.hostname = "other.example.net";
383
+
384
+ await expect(readRedirectAssignmentCarry(
385
+ [experiment],
386
+ destination.href,
387
+ "shop_123",
388
+ verifyTestHandover,
389
+ )).resolves.toBeNull();
390
+ });
391
+
392
+ it("treats trailing slashes as equivalent on a scoped destination entry path", async () => {
393
+ const destination = buildRedirectDestination(
394
+ "https://shop.example.com/products/a",
395
+ "https://landing.example.net/sale/",
396
+ assignment,
397
+ "visitor_source",
398
+ { handoverTtlSeconds: 120 },
399
+ )!;
400
+ destination.pathname = "/sale";
401
+
402
+ await expect(readRedirectAssignmentCarry(
403
+ [experiment],
404
+ destination.href,
405
+ "shop_123",
406
+ verifyTestHandover,
407
+ )).resolves.toMatchObject({ variationId: "landing" });
408
+ });
409
+
410
+ it("preserves href-less validation for legacy redirect carries without destinationPath", async () => {
411
+ const destination = buildRedirectDestination(
412
+ "https://shop.example.com/products/a",
413
+ assignment.redirectUrl!,
414
+ assignment,
415
+ "visitor_source",
416
+ { handoverTtlSeconds: 120 },
417
+ )!;
418
+ const rawCarry = JSON.parse(destination.searchParams.get("_apex_assignment")!);
419
+ delete rawCarry.destinationPath;
420
+ destination.searchParams.set("_apex_assignment", JSON.stringify(rawCarry));
421
+ const verifiedCarry = {
422
+ experimentId: rawCarry.e,
423
+ variationId: rawCarry.v,
424
+ assignmentEpoch: rawCarry.epoch,
425
+ handoverToken: rawCarry.handover,
426
+ expiresAt: Date.now() + 120_000,
427
+ exposureSent: false,
428
+ visitorId: "visitor_source",
429
+ };
430
+
431
+ expect(validateRedirectAssignmentCarry([experiment], verifiedCarry)).toMatchObject({
432
+ variationId: "landing",
433
+ });
434
+ });
435
+
436
+ it("rejects a forged carried visitor identity when Worker verification fails", async () => {
437
+ const destination = buildRedirectDestination(
438
+ "https://shop.example.com/products/a",
439
+ assignment.redirectUrl!,
440
+ assignment,
441
+ "attacker_chosen_visitor",
442
+ { handoverTtlSeconds: 120 },
443
+ )!;
444
+ const verify = vi.fn(async () => null);
445
+
446
+ await expect(readRedirectAssignmentCarry(
447
+ [experiment],
448
+ destination.href,
449
+ "shop_123",
450
+ verify,
451
+ )).resolves.toBeNull();
452
+ expect(verify).toHaveBeenCalledTimes(1);
453
+ });
454
+
455
+ it("imports edge-carried attribution only on the signed destination entry URL", () => {
456
+ const cookieDocument = { cookie: "" };
457
+ vi.stubGlobal("document", cookieDocument);
458
+ const carriedExperiment = { ...experiment, assignmentEpoch: 1 };
459
+ const carry = {
460
+ experiment: carriedExperiment,
461
+ variationId: "landing",
462
+ visitorId: "visitor_edge",
463
+ exposureSent: true,
464
+ attributionToken: "signed-attribution",
465
+ attributionDestinationUrl: "https://landing.example.net/sale?campaign=edge",
466
+ attributionIssuedAt: Date.now(),
467
+ };
468
+
469
+ importRedirectAssignmentCarry(carry, "https://landing.example.net/other?campaign=edge");
470
+ expect(cookieDocument.cookie).not.toContain("drip_ra=");
471
+
472
+ importRedirectAssignmentCarry(
473
+ carry,
474
+ "https://landing.example.net/sale?campaign=edge&_apex_assignment=carry",
475
+ );
476
+ expect(cookieDocument.cookie).toContain("drip_ra=signed-attribution");
477
+ expect(cookieDocument.cookie).toContain("SameSite=Lax");
478
+ vi.unstubAllGlobals();
479
+ });
480
+
481
+ it("does not persist landing attribution after the edge runtime already adopted the carry", () => {
482
+ const cookieDocument = { cookie: "" };
483
+ vi.stubGlobal("document", cookieDocument);
484
+
485
+ importRedirectAssignmentCarry({
486
+ experiment: { ...experiment, assignmentEpoch: 1 },
487
+ variationId: "landing",
488
+ visitorId: "visitor_edge",
489
+ exposureSent: true,
490
+ attributionToken: "signed-attribution",
491
+ attributionDestinationUrl: "https://landing.example.net/sale",
492
+ attributionIssuedAt: Date.now(),
493
+ }, "https://landing.example.net/sale?_apex_assignment=carry", false);
494
+
495
+ expect(cookieDocument.cookie).not.toContain("drip_ra=");
496
+ vi.unstubAllGlobals();
497
+ });
498
+
499
+ it("imports edge-carried attribution when slash, hash, query order, and encoding differ", () => {
500
+ const cookieDocument = { cookie: "" };
501
+ vi.stubGlobal("document", cookieDocument);
502
+
503
+ importRedirectAssignmentCarry(
504
+ {
505
+ experiment: { ...experiment, assignmentEpoch: 1 },
506
+ variationId: "landing",
507
+ visitorId: "visitor_edge",
508
+ exposureSent: true,
509
+ attributionToken: "signed-attribution",
510
+ attributionDestinationUrl: "https://landing.example.net/sale/?campaign=spring%20sale&tag=one&tag=two&B=upper&a=lower#signed",
511
+ attributionIssuedAt: Date.now(),
512
+ },
513
+ "https://landing.example.net/sale?tag=two&a=lower&_apex_assignment=carry&tag=one&B=upper&campaign=spring+sale#received",
514
+ );
515
+
516
+ expect(cookieDocument.cookie).toContain("drip_ra=signed-attribution");
517
+ vi.unstubAllGlobals();
518
+ });
519
+
520
+ it("ignores a stale carried visitor identity before verification", async () => {
521
+ vi.useFakeTimers();
522
+ vi.setSystemTime(new Date("2026-07-26T12:00:00Z"));
523
+ const destination = buildRedirectDestination(
524
+ "https://shop.example.com/products/a",
525
+ assignment.redirectUrl!,
526
+ assignment,
527
+ "visitor_source",
528
+ { handoverTtlSeconds: 120 },
529
+ )!;
530
+ vi.setSystemTime(new Date("2026-07-26T12:02:01Z"));
531
+ const verify = vi.fn(verifyTestHandover);
532
+ await expect(readRedirectAssignmentCarry(
533
+ [experiment],
534
+ destination.href,
535
+ "shop_123",
536
+ verify,
537
+ )).resolves.toBeNull();
538
+ expect(verify).not.toHaveBeenCalled();
539
+ vi.useRealTimers();
540
+ });
541
+ });