@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
@@ -24,12 +24,277 @@ describe("syncShopifyCartAttributes", () => {
24
24
  });
25
25
 
26
26
  afterEach(() => {
27
+ vi.useRealTimers();
27
28
  vi.restoreAllMocks();
28
29
  (globalThis as any).window = originalWindow;
29
30
  (globalThis as any).sessionStorage = originalSessionStorage;
30
31
  globalThis.fetch = originalFetch;
31
32
  });
32
33
 
34
+ it("serializes cross-signature writes so the newest state is submitted last", async () => {
35
+ let resolveFirst!: (response: Response) => void;
36
+ globalThis.fetch = vi.fn(() => new Promise<Response>((resolve) => {
37
+ resolveFirst = resolve;
38
+ })) as any;
39
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
40
+ const first = syncShopifyCartAttributes({
41
+ assignments: [{ experimentId: "exp_123", variationId: "var_old" }],
42
+ visitorId: "visitor_123",
43
+ sessionId: "session_old",
44
+ });
45
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
46
+ const newest = syncShopifyCartAttributes({
47
+ assignments: [{ experimentId: "exp_123", variationId: "var_new" }],
48
+ visitorId: "visitor_123",
49
+ sessionId: "session_new",
50
+ });
51
+
52
+ await new Promise((resolve) => setTimeout(resolve, 0));
53
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
54
+ resolveFirst(new Response("{}", { status: 200 }));
55
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
56
+ const finalBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body));
57
+ expect(JSON.parse(finalBody.attributes.apex_assignments)).toEqual([
58
+ { e: "exp_123", v: "var_new" },
59
+ ]);
60
+ expect(finalBody.attributes.apex_session_id).toBe("session_new");
61
+ resolveFirst(new Response("{}", { status: 200 }));
62
+ await expect(Promise.all([first, newest])).resolves.toEqual([
63
+ { status: "skipped", reason: "superseded" },
64
+ { status: "sent" },
65
+ ]);
66
+ });
67
+
68
+ it("coalesces overlapping mutations into exactly one newest trailing write", async () => {
69
+ let resolveWrite!: (response: Response) => void;
70
+ globalThis.fetch = vi.fn(() => new Promise<Response>((resolve) => {
71
+ resolveWrite = resolve;
72
+ })) as any;
73
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
74
+ const inputs = ["old", "middle", "newest"].map((variationId) => ({
75
+ assignments: [{ experimentId: "exp_123", variationId }],
76
+ visitorId: "visitor_123",
77
+ sessionId: `session_${variationId}`,
78
+ }));
79
+
80
+ const writes = [syncShopifyCartAttributes(inputs[0]!)];
81
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
82
+ writes.push(syncShopifyCartAttributes(inputs[1]!));
83
+ await Promise.resolve();
84
+ writes.push(syncShopifyCartAttributes(inputs[2]!));
85
+ await new Promise((resolve) => setTimeout(resolve, 0));
86
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
87
+ resolveWrite(new Response("{}", { status: 200 }));
88
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
89
+ expect(JSON.parse(
90
+ JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body))
91
+ .attributes.apex_assignments,
92
+ )).toEqual([{ e: "exp_123", v: "newest" }]);
93
+ resolveWrite(new Response("{}", { status: 200 }));
94
+ await expect(Promise.all(writes)).resolves.toEqual([
95
+ { status: "skipped", reason: "superseded" },
96
+ { status: "skipped", reason: "superseded" },
97
+ { status: "sent" },
98
+ ]);
99
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
100
+ });
101
+
102
+ it("atomically drains a write queued in the writer's exit microtask window", async () => {
103
+ vi.useFakeTimers();
104
+ let resolveWrite!: (response: Response) => void;
105
+ globalThis.fetch = vi.fn(() => new Promise<Response>((resolve) => {
106
+ resolveWrite = resolve;
107
+ })) as any;
108
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
109
+ const input = {
110
+ assignments: [{ experimentId: "exp_123", variationId: "treatment" }],
111
+ visitorId: "visitor_123",
112
+ sessionId: "session_123",
113
+ };
114
+
115
+ const first = syncShopifyCartAttributes(input);
116
+ await vi.advanceTimersByTimeAsync(0);
117
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
118
+ let reissued!: ReturnType<typeof syncShopifyCartAttributes>;
119
+ queueMicrotask(() => {
120
+ reissued = syncShopifyCartAttributes(input, { force: true });
121
+ });
122
+ resolveWrite(new Response("{}", { status: 200 }));
123
+ await vi.advanceTimersByTimeAsync(0);
124
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
125
+ resolveWrite(new Response("{}", { status: 200 }));
126
+ await vi.advanceTimersByTimeAsync(0);
127
+
128
+ await expect(Promise.all([first, reissued])).resolves.toEqual([
129
+ { status: "skipped", reason: "superseded" },
130
+ { status: "sent" },
131
+ ]);
132
+ });
133
+
134
+ it("discards a queued cart write when sync state is invalidated", async () => {
135
+ vi.useFakeTimers();
136
+ globalThis.fetch = vi.fn(() => new Promise<Response>(() => {})) as any;
137
+ const {
138
+ invalidateShopifyCartAttributeSync,
139
+ syncShopifyCartAttributes,
140
+ } = await import("./shopify-cart");
141
+
142
+ const active = syncShopifyCartAttributes({
143
+ assignments: [{ experimentId: "exp_123", variationId: "old" }],
144
+ visitorId: "visitor_123",
145
+ sessionId: "session_old",
146
+ });
147
+ await vi.advanceTimersByTimeAsync(0);
148
+ const queued = syncShopifyCartAttributes({
149
+ assignments: [{ experimentId: "exp_123", variationId: "queued" }],
150
+ visitorId: "visitor_123",
151
+ sessionId: "session_queued",
152
+ });
153
+
154
+ invalidateShopifyCartAttributeSync();
155
+ await vi.advanceTimersByTimeAsync(0);
156
+
157
+ await expect(queued).resolves.toEqual({ status: "skipped", reason: "superseded" });
158
+ await expect(active).resolves.toEqual({ status: "failed", reason: "cart_write_aborted" });
159
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
160
+ });
161
+
162
+ it("discards an ABA re-issue scheduled before sync state is invalidated", async () => {
163
+ vi.useFakeTimers();
164
+ globalThis.fetch = vi.fn(() => new Promise<Response>(() => {})) as any;
165
+ const {
166
+ invalidateShopifyCartAttributeSync,
167
+ syncShopifyCartAttributes,
168
+ } = await import("./shopify-cart");
169
+ const input = (variationId: string) => ({
170
+ assignments: [{ experimentId: "exp_123", variationId }],
171
+ visitorId: "visitor_123",
172
+ sessionId: `session_${variationId}`,
173
+ });
174
+
175
+ const firstA = syncShopifyCartAttributes(input("a"));
176
+ await vi.advanceTimersByTimeAsync(0);
177
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
178
+ const middleB = syncShopifyCartAttributes(input("b"));
179
+ const finalA = syncShopifyCartAttributes(input("a"));
180
+
181
+ invalidateShopifyCartAttributeSync();
182
+ await vi.advanceTimersByTimeAsync(0);
183
+
184
+ await expect(firstA).resolves.toEqual({ status: "failed", reason: "cart_write_aborted" });
185
+ await expect(middleB).resolves.toEqual({ status: "skipped", reason: "superseded" });
186
+ await expect(finalA).resolves.toEqual({ status: "skipped", reason: "superseded" });
187
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
188
+ });
189
+
190
+ it("times out a stalled write and lets the newest queued sync proceed", async () => {
191
+ vi.useFakeTimers();
192
+ globalThis.fetch = vi.fn()
193
+ .mockImplementationOnce(() => new Promise<Response>(() => {}))
194
+ .mockResolvedValueOnce(new Response("{}", { status: 200 })) as any;
195
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
196
+
197
+ const stalled = syncShopifyCartAttributes({
198
+ assignments: [{ experimentId: "exp_123", variationId: "stalled" }],
199
+ visitorId: "visitor_123",
200
+ sessionId: "session_stalled",
201
+ });
202
+ await vi.advanceTimersByTimeAsync(0);
203
+ const newest = syncShopifyCartAttributes({
204
+ assignments: [{ experimentId: "exp_123", variationId: "newest" }],
205
+ visitorId: "visitor_123",
206
+ sessionId: "session_newest",
207
+ });
208
+
209
+ await vi.advanceTimersByTimeAsync(10_000);
210
+ await expect(stalled).resolves.toEqual({ status: "failed", reason: "cart_write_timeout" });
211
+ await expect(newest).resolves.toEqual({ status: "sent" });
212
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
213
+ });
214
+
215
+ it("classifies an AbortError caused by the writer deadline as timeout", async () => {
216
+ vi.useFakeTimers();
217
+ globalThis.fetch = vi.fn((_url, init) => new Promise<Response>((_resolve, reject) => {
218
+ init?.signal?.addEventListener("abort", () => {
219
+ reject(new DOMException("ambient abort text", "AbortError"));
220
+ }, { once: true });
221
+ })) as any;
222
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
223
+
224
+ const write = syncShopifyCartAttributes({
225
+ assignments: [{ experimentId: "exp_123", variationId: "stalled" }],
226
+ visitorId: "visitor_123",
227
+ sessionId: "session_stalled",
228
+ });
229
+ await vi.advanceTimersByTimeAsync(10_000);
230
+
231
+ await expect(write).resolves.toEqual({ status: "failed", reason: "cart_write_timeout" });
232
+ });
233
+
234
+ it("re-issues A after an intervening B even while the first A is in flight", async () => {
235
+ const resolvers: Array<(response: Response) => void> = [];
236
+ globalThis.fetch = vi.fn(() => new Promise<Response>((resolve) => {
237
+ resolvers.push(resolve);
238
+ })) as any;
239
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
240
+ const input = (variationId: string) => ({
241
+ assignments: [{ experimentId: "exp_123", variationId }],
242
+ visitorId: "visitor_123",
243
+ sessionId: `session_${variationId}`,
244
+ });
245
+
246
+ const firstA = syncShopifyCartAttributes(input("A"));
247
+ await vi.waitFor(() => expect(resolvers).toHaveLength(1));
248
+ const writeB = syncShopifyCartAttributes(input("B"));
249
+ const finalA = syncShopifyCartAttributes(input("A"));
250
+
251
+ resolvers[0]!(new Response("{}", { status: 200 }));
252
+ await vi.waitFor(() => expect(resolvers).toHaveLength(2));
253
+ resolvers[1]!(new Response("{}", { status: 200 }));
254
+ await vi.waitFor(() => expect(resolvers).toHaveLength(3));
255
+ const finalBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[2]?.[1]?.body));
256
+ expect(JSON.parse(finalBody.attributes.apex_assignments)).toEqual([{ e: "exp_123", v: "A" }]);
257
+ resolvers[2]!(new Response("{}", { status: 200 }));
258
+
259
+ await expect(Promise.all([firstA, writeB, finalA])).resolves.toEqual([
260
+ { status: "skipped", reason: "superseded" },
261
+ { status: "skipped", reason: "superseded" },
262
+ { status: "sent" },
263
+ ]);
264
+ });
265
+
266
+ it("re-drives the newest mutation after an in-flight write fails", async () => {
267
+ let rejectFirst!: (error: Error) => void;
268
+ globalThis.fetch = vi.fn()
269
+ .mockImplementationOnce(() => new Promise<Response>((_resolve, reject) => {
270
+ rejectFirst = reject;
271
+ }))
272
+ .mockResolvedValueOnce(new Response("{}", { status: 200 })) as any;
273
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
274
+ const first = syncShopifyCartAttributes({
275
+ assignments: [{ experimentId: "exp_123", variationId: "var_old" }],
276
+ visitorId: "visitor_123",
277
+ sessionId: "session_old",
278
+ });
279
+ await vi.waitFor(() => expect(rejectFirst).toBeTypeOf("function"));
280
+ const newest = syncShopifyCartAttributes({
281
+ assignments: [{ experimentId: "exp_123", variationId: "var_new" }],
282
+ visitorId: "visitor_123",
283
+ sessionId: "session_new",
284
+ });
285
+
286
+ rejectFirst(new Error("cart unavailable"));
287
+ await expect(Promise.all([first, newest])).resolves.toEqual([
288
+ { status: "failed", reason: "cart unavailable" },
289
+ { status: "sent" },
290
+ ]);
291
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
292
+ const finalBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body));
293
+ expect(JSON.parse(finalBody.attributes.apex_assignments)).toEqual([
294
+ { e: "exp_123", v: "var_new" },
295
+ ]);
296
+ });
297
+
33
298
  it("posts all Apex assignments plus primary attribution keys to the Shopify cart", async () => {
34
299
  const { syncShopifyCartAttributes } = await import("./shopify-cart");
35
300
 
@@ -81,7 +346,7 @@ describe("syncShopifyCartAttributes", () => {
81
346
  });
82
347
 
83
348
  it("builds ordered Storefront API cart attribute entries with compact assignment keys", async () => {
84
- const { buildCartAttributeEntries } = await import("./shopify-cart");
349
+ const { APEX_CART_ATTRIBUTE_KEYS, buildCartAttributeEntries } = await import("./shopify-cart");
85
350
 
86
351
  const entries = buildCartAttributeEntries({
87
352
  assignments: [
@@ -101,6 +366,15 @@ describe("syncShopifyCartAttributes", () => {
101
366
  "apex_visitor_id",
102
367
  "apex_session_id",
103
368
  ]);
369
+ expect(entries.every((entry) => APEX_CART_ATTRIBUTE_KEYS.has(entry.key))).toBe(true);
370
+ expect(
371
+ buildCartAttributeEntries({
372
+ assignments: [],
373
+ identityOnly: true,
374
+ visitorId: "visitor_123",
375
+ sessionId: "session_123",
376
+ }).every((entry) => APEX_CART_ATTRIBUTE_KEYS.has(entry.key)),
377
+ ).toBe(true);
104
378
  expect(JSON.parse(entries[0]!.value)).toEqual([
105
379
  { e: "exp_123", v: "var_123", ep: 2 },
106
380
  { e: "exp_456", v: "var_456" },
@@ -119,6 +393,264 @@ describe("syncShopifyCartAttributes", () => {
119
393
  });
120
394
  });
121
395
 
396
+ it("keeps the consent-pending stamp to arm labels only against the consented fixture", async () => {
397
+ const { buildCartAttributeEntries } = await import("./shopify-cart");
398
+ const fixture = {
399
+ assignments: [
400
+ { experimentId: "exp_first", variationId: "var_first", assignmentEpoch: 4 },
401
+ { experimentId: "exp_second", variationId: "var_second", assignmentEpoch: 2 },
402
+ ],
403
+ visitorId: "visitor_123",
404
+ sessionId: "session_123",
405
+ };
406
+ const consented = buildCartAttributeEntries(fixture, "signed-envelope");
407
+ const sdkCartMint = buildCartAttributeEntries({ ...fixture, visitorId: "", sessionId: "" });
408
+
409
+ expect(sdkCartMint).toEqual([
410
+ {
411
+ key: "apex_assignments",
412
+ value: JSON.stringify([
413
+ { e: "exp_first", v: "var_first", ep: 4 },
414
+ { e: "exp_second", v: "var_second", ep: 2 },
415
+ ]),
416
+ },
417
+ { key: "apex_assignment_authorization", value: "" },
418
+ { key: "apex_experiment_id", value: "" },
419
+ { key: "apex_variation_id", value: "" },
420
+ { key: "apex_assignment_epoch", value: "" },
421
+ { key: "apex_visitor_id", value: "" },
422
+ { key: "apex_session_id", value: "" },
423
+ ]);
424
+ const identityKeys = consented
425
+ .map(({ key }) => key)
426
+ .filter((key) => [
427
+ "apex_assignment_authorization",
428
+ "apex_visitor_id",
429
+ "apex_session_id",
430
+ ].includes(key));
431
+ expect(identityKeys).toEqual([
432
+ "apex_assignment_authorization",
433
+ "apex_visitor_id",
434
+ "apex_session_id",
435
+ ]);
436
+ expect(sdkCartMint.filter(({ value }) => value !== "").map(({ key }) => key)).toEqual([
437
+ "apex_assignments",
438
+ ]);
439
+ expect(sdkCartMint.map(({ key }) => key)).toEqual(expect.arrayContaining(identityKeys));
440
+ });
441
+
442
+ it("clears stale consented identity when a consent-pending arm stamp merges into the cart", async () => {
443
+ const { buildCartAttributeEntries } = await import("./shopify-cart");
444
+ const staleCartAttributes = {
445
+ apex_assignments: JSON.stringify([{ e: "exp_prior", v: "var_prior", ep: 8 }]),
446
+ apex_assignment_authorization: "signed-prior-treatment",
447
+ apex_experiment_id: "exp_prior",
448
+ apex_variation_id: "var_prior",
449
+ apex_assignment_epoch: "8",
450
+ apex_visitor_id: "visitor_prior",
451
+ apex_session_id: "session_prior",
452
+ };
453
+ const sdkCartMintEntries = buildCartAttributeEntries({
454
+ assignments: [{
455
+ experimentId: "exp_sdk_cart_mint",
456
+ variationId: "var_sdk_cart_mint",
457
+ assignmentEpoch: 3,
458
+ }],
459
+ visitorId: "",
460
+ sessionId: "",
461
+ });
462
+ const merged = {
463
+ ...staleCartAttributes,
464
+ ...Object.fromEntries(sdkCartMintEntries.map(({ key, value }) => [key, value])),
465
+ };
466
+
467
+ expect(merged).toEqual({
468
+ apex_assignments: JSON.stringify([{ e: "exp_sdk_cart_mint", v: "var_sdk_cart_mint", ep: 3 }]),
469
+ apex_assignment_authorization: "",
470
+ apex_experiment_id: "",
471
+ apex_variation_id: "",
472
+ apex_assignment_epoch: "",
473
+ apex_visitor_id: "",
474
+ apex_session_id: "",
475
+ });
476
+ });
477
+
478
+ it("maps non-empty cart attributes 1:1 to composite metafield keys and type metadata", async () => {
479
+ const { buildCartAttributeEntries, buildCartMetafieldInputs } = await import("./shopify-cart");
480
+ const input = {
481
+ assignments: [{ experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 }],
482
+ visitorId: "visitor_123",
483
+ sessionId: "session_123",
484
+ };
485
+ const attributes = buildCartAttributeEntries(input);
486
+ const metafields = buildCartMetafieldInputs(input);
487
+
488
+ const nonEmptyAttributes = attributes.filter(({ value }) => value !== "");
489
+ expect(metafields.map(({ key, value }) => ({
490
+ key: key.replace(/^apex\./, ""),
491
+ value,
492
+ }))).toEqual(nonEmptyAttributes);
493
+ expect(metafields.every((entry) => entry.key.startsWith("apex."))).toBe(true);
494
+ expect(metafields.map((entry) => entry.type)).toEqual([
495
+ "json",
496
+ ...Array(nonEmptyAttributes.length - 1).fill("single_line_text_field"),
497
+ ]);
498
+ expect(buildCartMetafieldInputs(input, "merchant_bi").every(
499
+ (entry) => entry.key.startsWith("merchant_bi."),
500
+ )).toBe(true);
501
+ });
502
+
503
+ it("omits cart attributes whose metafield values would be empty", async () => {
504
+ const { buildCartMetafieldInputs } = await import("./shopify-cart");
505
+ const metafields = buildCartMetafieldInputs({
506
+ assignments: [{ experimentId: "exp_123", variationId: "var_123" }],
507
+ visitorId: "visitor_123",
508
+ sessionId: "session_123",
509
+ });
510
+
511
+ expect(metafields.some(({ value }) => value === "")).toBe(false);
512
+ expect(metafields.map(({ key }) => key)).not.toContain("apex.apex_assignment_authorization");
513
+ expect(metafields.map(({ key }) => key)).not.toContain("apex.apex_assignment_epoch");
514
+ });
515
+
516
+ it("returns the complement of the current cart-metafield mirror", async () => {
517
+ const { getCartMetafieldStaleKeys } = await import("./shopify-cart");
518
+
519
+ expect(getCartMetafieldStaleKeys([
520
+ { key: "merchant_bi.apex_assignments" },
521
+ { key: "merchant_bi.apex_experiment_id" },
522
+ { key: "merchant_bi.apex_visitor_id" },
523
+ ], "merchant_bi")).toEqual([
524
+ "merchant_bi.apex_assignment_authorization",
525
+ "merchant_bi.apex_variation_id",
526
+ "merchant_bi.apex_assignment_epoch",
527
+ "merchant_bi.apex_session_id",
528
+ ]);
529
+ });
530
+
531
+ it("returns every cart-metafield key when the current mirror is empty", async () => {
532
+ const { getCartMetafieldStaleKeys } = await import("./shopify-cart");
533
+
534
+ expect(getCartMetafieldStaleKeys([])).toEqual([
535
+ "apex.apex_assignments",
536
+ "apex.apex_assignment_authorization",
537
+ "apex.apex_experiment_id",
538
+ "apex.apex_variation_id",
539
+ "apex.apex_assignment_epoch",
540
+ "apex.apex_visitor_id",
541
+ "apex.apex_session_id",
542
+ ]);
543
+ });
544
+
545
+ it("returns no stale keys when the current cart-metafield mirror is full", async () => {
546
+ const { APEX_CART_ATTRIBUTE_KEYS, getCartMetafieldStaleKeys } = await import("./shopify-cart");
547
+ const fullMirror = Array.from(APEX_CART_ATTRIBUTE_KEYS, (key) => ({ key: `apex.${key}` }));
548
+
549
+ expect(getCartMetafieldStaleKeys(fullMirror)).toEqual([]);
550
+ });
551
+
552
+ it("builds only the visitor cart metafield for identity-only input", async () => {
553
+ const { buildCartMetafieldInputs } = await import("./shopify-cart");
554
+ expect(buildCartMetafieldInputs({
555
+ assignments: [],
556
+ identityOnly: true,
557
+ visitorId: "visitor_123",
558
+ sessionId: "session_123",
559
+ })).toEqual([{
560
+ key: "apex.apex_visitor_id",
561
+ type: "single_line_text_field",
562
+ value: "visitor_123",
563
+ }]);
564
+ });
565
+
566
+ it("does not let an identity-only stamp suppress a later holdout control clear", async () => {
567
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
568
+ const identity = {
569
+ assignments: [],
570
+ visitorId: "visitor_123",
571
+ sessionId: "session_123",
572
+ };
573
+
574
+ await expect(syncShopifyCartAttributes({
575
+ ...identity,
576
+ identityOnly: true,
577
+ })).resolves.toEqual({ status: "sent" });
578
+ await expect(syncShopifyCartAttributes({
579
+ ...identity,
580
+ controlStamp: true,
581
+ })).resolves.toEqual({ status: "sent" });
582
+
583
+ const writes = vi.mocked(globalThis.fetch).mock.calls.map(([, init]) =>
584
+ JSON.parse(String(init?.body)).attributes
585
+ );
586
+ expect(writes).toEqual([
587
+ { apex_visitor_id: "visitor_123" },
588
+ {
589
+ apex_assignments: "[]",
590
+ apex_assignment_authorization: "",
591
+ apex_experiment_id: "",
592
+ apex_variation_id: "",
593
+ apex_assignment_epoch: "",
594
+ apex_visitor_id: "visitor_123",
595
+ apex_session_id: "session_123",
596
+ },
597
+ ]);
598
+ });
599
+
600
+ it("stops cart writes for the session after Shopify returns 405", async () => {
601
+ globalThis.fetch = vi.fn()
602
+ .mockResolvedValueOnce(new Response("method not allowed", { status: 405 })) as any;
603
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
604
+ const input = {
605
+ assignments: [{ experimentId: "exp_405", variationId: "var_405" }],
606
+ visitorId: "visitor_405",
607
+ sessionId: "session_405",
608
+ };
609
+
610
+ await expect(syncShopifyCartAttributes(input)).resolves.toEqual({
611
+ status: "failed",
612
+ reason: "cart_update_405",
613
+ });
614
+ await expect(syncShopifyCartAttributes({
615
+ ...input,
616
+ sessionId: "session_after_405",
617
+ })).resolves.toEqual({
618
+ status: "skipped",
619
+ reason: "cart_update_405_circuit_break",
620
+ });
621
+
622
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
623
+ });
624
+
625
+ it("keeps the 405 circuit breaker active when sessionStorage throws", async () => {
626
+ (globalThis as any).sessionStorage = {
627
+ getItem: () => { throw new Error("storage blocked"); },
628
+ setItem: () => { throw new Error("storage blocked"); },
629
+ };
630
+ globalThis.fetch = vi.fn(async () =>
631
+ new Response("method not allowed", { status: 405 })) as any;
632
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
633
+ const input = {
634
+ assignments: [{ experimentId: "exp_405_blocked_storage", variationId: "var_405" }],
635
+ visitorId: "visitor_405",
636
+ sessionId: "session_405",
637
+ };
638
+
639
+ await expect(syncShopifyCartAttributes(input)).resolves.toEqual({
640
+ status: "failed",
641
+ reason: "cart_update_405",
642
+ });
643
+ await expect(syncShopifyCartAttributes({
644
+ ...input,
645
+ sessionId: "session_after_405",
646
+ })).resolves.toEqual({
647
+ status: "skipped",
648
+ reason: "cart_update_405_circuit_break",
649
+ });
650
+
651
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
652
+ });
653
+
122
654
  it("keeps ordinary cart attribution best-effort when assignment proof is unavailable", async () => {
123
655
  globalThis.fetch = vi.fn()
124
656
  .mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
@@ -486,8 +1018,8 @@ describe("syncShopifyCartAttributes", () => {
486
1018
  assignments: [],
487
1019
  }));
488
1020
  await expect(older).resolves.toEqual({
489
- status: "failed",
490
- reason: "authorization_failed",
1021
+ status: "skipped",
1022
+ reason: "superseded",
491
1023
  });
492
1024
 
493
1025
  expect(globalThis.fetch).toHaveBeenCalledTimes(3);
@@ -1249,6 +1781,170 @@ describe("syncShopifyCartAttributes", () => {
1249
1781
  });
1250
1782
  });
1251
1783
 
1784
+ it("keeps page-inactive attribution out of authoritative price adoption", async () => {
1785
+ const token = JSON.stringify({
1786
+ v: 2,
1787
+ p: {
1788
+ price: {
1789
+ v: 1,
1790
+ r: 3,
1791
+ c: "b".repeat(64),
1792
+ x: "2026-07-20",
1793
+ cartHash: "c".repeat(64),
1794
+ a: [{ e: "exp_price", v: "var_treatment", ep: 2 }],
1795
+ s: "d".repeat(64),
1796
+ },
1797
+ },
1798
+ });
1799
+ globalThis.fetch = vi.fn()
1800
+ .mockResolvedValueOnce(Response.json({
1801
+ assignments: [{
1802
+ experiment_id: "exp_price",
1803
+ variation_id: "var_treatment",
1804
+ assignment_epoch: 2,
1805
+ commerce_price_only: true,
1806
+ }],
1807
+ commerce_authorization: token,
1808
+ }))
1809
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
1810
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1811
+ const adoptPriceAssignments = vi.fn(() => true);
1812
+
1813
+ await expect(syncShopifyCartAttributes({
1814
+ assignments: [{
1815
+ experimentId: "exp_price",
1816
+ variationId: "var_treatment",
1817
+ assignmentEpoch: 2,
1818
+ pageActive: false,
1819
+ }],
1820
+ visitorId: "visitor_123",
1821
+ sessionId: "session_123",
1822
+ shopId: "shop_123",
1823
+ endpoint: "https://events.example.com/events",
1824
+ ingestSignature: "ingest-token",
1825
+ cartToken: "cart-token",
1826
+ priceAuthorizationRevision: 3,
1827
+ priceExperimentIds: ["exp_price"],
1828
+ adoptPriceAssignments,
1829
+ })).resolves.toEqual({ status: "sent" });
1830
+
1831
+ expect(adoptPriceAssignments).not.toHaveBeenCalled();
1832
+ const cartUpdateBody = JSON.parse(
1833
+ String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
1834
+ );
1835
+ expect(cartUpdateBody.attributes).toMatchObject({
1836
+ apex_assignments: JSON.stringify([{ e: "exp_price", v: "var_treatment", ep: 2 }]),
1837
+ apex_assignment_authorization: token,
1838
+ });
1839
+ });
1840
+
1841
+ it("preserves page-inactive price attribution across a signed empty price plane", async () => {
1842
+ const token = JSON.stringify({
1843
+ v: 2,
1844
+ p: {
1845
+ price: {
1846
+ v: 1,
1847
+ r: 3,
1848
+ c: "b".repeat(64),
1849
+ x: "2026-07-20",
1850
+ cartHash: "c".repeat(64),
1851
+ a: [],
1852
+ s: "d".repeat(64),
1853
+ },
1854
+ },
1855
+ });
1856
+ globalThis.fetch = vi.fn()
1857
+ .mockResolvedValueOnce(Response.json({
1858
+ assignments: [],
1859
+ commerce_authorization: token,
1860
+ }))
1861
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
1862
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1863
+ const adoptPriceAssignments = vi.fn(() => true);
1864
+
1865
+ await expect(syncShopifyCartAttributes({
1866
+ assignments: [{
1867
+ experimentId: "exp_price",
1868
+ variationId: "var_treatment",
1869
+ assignmentEpoch: 2,
1870
+ pageActive: false,
1871
+ }],
1872
+ visitorId: "visitor_123",
1873
+ sessionId: "session_123",
1874
+ shopId: "shop_123",
1875
+ endpoint: "https://events.example.com/events",
1876
+ ingestSignature: "ingest-token",
1877
+ cartToken: "cart-token",
1878
+ priceAuthorizationRevision: 3,
1879
+ priceExperimentIds: ["exp_price"],
1880
+ adoptPriceAssignments,
1881
+ })).resolves.toEqual({ status: "sent" });
1882
+
1883
+ expect(adoptPriceAssignments).not.toHaveBeenCalled();
1884
+ const cartUpdateBody = JSON.parse(
1885
+ String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
1886
+ );
1887
+ expect(JSON.parse(cartUpdateBody.attributes.apex_assignments)).toEqual([
1888
+ { e: "exp_price", v: "var_treatment", ep: 2 },
1889
+ ]);
1890
+ expect(cartUpdateBody.attributes.apex_assignment_authorization).toBe(token);
1891
+ });
1892
+
1893
+ it("keeps a page-inactive offer pin out of adoption without duplicating attribution", async () => {
1894
+ const token = JSON.stringify({
1895
+ v: 2,
1896
+ p: {
1897
+ offers: {
1898
+ v: 1,
1899
+ a: [{ e: "exp_offer", v: "var_treatment", ep: 3 }],
1900
+ },
1901
+ },
1902
+ });
1903
+ globalThis.fetch = vi.fn()
1904
+ .mockResolvedValueOnce(Response.json({
1905
+ assignments: [{
1906
+ experiment_id: "exp_offer",
1907
+ variation_id: "var_treatment",
1908
+ assignment_epoch: 3,
1909
+ commerce_price_only: true,
1910
+ }],
1911
+ commerce_authorization: token,
1912
+ }))
1913
+ .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
1914
+ const { syncShopifyCartAttributes } = await import("./shopify-cart");
1915
+ const adoptOffersAssignments = vi.fn(() => true);
1916
+ const canAdoptOffersAssignments = vi.fn(() => true);
1917
+
1918
+ await expect(syncShopifyCartAttributes({
1919
+ assignments: [{
1920
+ experimentId: "exp_offer",
1921
+ variationId: "var_treatment",
1922
+ assignmentEpoch: 3,
1923
+ pageActive: false,
1924
+ }],
1925
+ visitorId: "visitor_123",
1926
+ sessionId: "session_123",
1927
+ shopId: "shop_123",
1928
+ endpoint: "https://events.example.com/events",
1929
+ ingestSignature: "ingest-token",
1930
+ cartToken: "cart-token",
1931
+ offersAuthorizationRevision: 5,
1932
+ offersExperimentIds: ["exp_offer"],
1933
+ adoptOffersAssignments,
1934
+ canAdoptOffersAssignments,
1935
+ })).resolves.toEqual({ status: "sent" });
1936
+
1937
+ expect(canAdoptOffersAssignments).not.toHaveBeenCalled();
1938
+ expect(adoptOffersAssignments).not.toHaveBeenCalled();
1939
+ const cartUpdateBody = JSON.parse(
1940
+ String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
1941
+ );
1942
+ expect(JSON.parse(cartUpdateBody.attributes.apex_assignments)).toEqual([
1943
+ { e: "exp_offer", v: "var_treatment", ep: 3 },
1944
+ ]);
1945
+ expect(cartUpdateBody.attributes.apex_assignment_authorization).toBe(token);
1946
+ });
1947
+
1252
1948
  it("adopts null pins only inside the V2 evaluated set and preserves browser-only assignments", async () => {
1253
1949
  const browserOnlyAssignment = {
1254
1950
  experimentId: "exp_browser_only",
@@ -1437,8 +2133,8 @@ describe("syncShopifyCartAttributes", () => {
1437
2133
  commerce_authorization: olderToken,
1438
2134
  }));
1439
2135
  await expect(older).resolves.toEqual({
1440
- status: "failed",
1441
- reason: "authorization_failed",
2136
+ status: "skipped",
2137
+ reason: "superseded",
1442
2138
  });
1443
2139
  expect(adoptPriceAssignments).toHaveBeenCalledOnce();
1444
2140
  expect(runtimeAssignments).toEqual([{
@@ -1528,8 +2224,8 @@ describe("syncShopifyCartAttributes", () => {
1528
2224
  commerce_authorization: nullPinToken,
1529
2225
  }));
1530
2226
  await expect(older).resolves.toEqual({
1531
- status: "failed",
1532
- reason: "authorization_failed",
2227
+ status: "skipped",
2228
+ reason: "superseded",
1533
2229
  });
1534
2230
  expect(adoptPriceAssignments).toHaveBeenCalledOnce();
1535
2231
  expect(runtimeAssignments).toEqual([{
@@ -1915,6 +2611,7 @@ describe("syncShopifyCartAttributes", () => {
1915
2611
  cartHash,
1916
2612
  a: [{ e: assignment.experimentId, v: assignment.variationId, ep: assignment.assignmentEpoch }],
1917
2613
  });
2614
+ const onCartToken = vi.fn();
1918
2615
  globalThis.fetch = vi.fn((url) => {
1919
2616
  if (url === "/cart.js") {
1920
2617
  cartReads += 1;
@@ -1951,6 +2648,7 @@ describe("syncShopifyCartAttributes", () => {
1951
2648
  ingestSignature: "ingest-token",
1952
2649
  priceAuthorizationRevision: 3,
1953
2650
  priceExperimentIds: [assignment.experimentId],
2651
+ onCartToken,
1954
2652
  };
1955
2653
 
1956
2654
  const cartA = syncShopifyCartAttributes(input);
@@ -1960,8 +2658,8 @@ describe("syncShopifyCartAttributes", () => {
1960
2658
 
1961
2659
  resolveCartA(Response.json({ token: "cart-a?key=a" }));
1962
2660
  await expect(cartA).resolves.toEqual({
1963
- status: "failed",
1964
- reason: "authorization_failed",
2661
+ status: "skipped",
2662
+ reason: "superseded",
1965
2663
  });
1966
2664
  await expect(cartB).resolves.toEqual({ status: "sent" });
1967
2665
 
@@ -1970,6 +2668,8 @@ describe("syncShopifyCartAttributes", () => {
1970
2668
  .filter(([url]) => url === "https://events.example.com/v1/assign")
1971
2669
  .map(([, init]) => JSON.parse(String(init?.body)).cart_token);
1972
2670
  expect(authorizationCartTokens).toEqual(["cart-a?key=a", "cart-b?key=b"]);
2671
+ expect(onCartToken).toHaveBeenCalledWith("cart-a");
2672
+ expect(onCartToken).toHaveBeenCalledWith("cart-b");
1973
2673
  const cartUpdates = calls.filter(([url]) => url === "/cart/update.js");
1974
2674
  expect(cartUpdates).toHaveLength(1);
1975
2675
  expect(JSON.parse(String(cartUpdates[0]?.[1]?.body)).attributes)
@@ -2035,8 +2735,8 @@ describe("syncShopifyCartAttributes", () => {
2035
2735
 
2036
2736
  resolveCartA(Response.json({ token: "cart-a?key=a" }));
2037
2737
  await expect(cartA).resolves.toEqual({
2038
- status: "failed",
2039
- reason: "authorization_failed",
2738
+ status: "skipped",
2739
+ reason: "superseded",
2040
2740
  });
2041
2741
  await expect(cartB).resolves.toEqual({
2042
2742
  status: "failed",
@@ -2163,17 +2863,18 @@ describe("syncShopifyCartAttributes", () => {
2163
2863
  const first = syncShopifyCartAttributes(firstInput);
2164
2864
  await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
2165
2865
  const second = syncShopifyCartAttributes(secondInput);
2166
- await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
2866
+ await new Promise((resolve) => setTimeout(resolve, 0));
2867
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
2167
2868
 
2869
+ resolveFirst(new Response("ok", { status: 200 }));
2870
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
2871
+ expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
2168
2872
  resolveSecond(new Response("ok", { status: 200 }));
2169
2873
  await expect(second).resolves.toEqual({ status: "sent" });
2874
+ await expect(first).resolves.toEqual({ status: "skipped", reason: "superseded" });
2170
2875
  const newestStamp = sessionStorage.getItem("drip_cart_attr");
2171
2876
  expect(newestStamp).toContain("exp_new");
2172
2877
 
2173
- resolveFirst(new Response("ok", { status: 200 }));
2174
- await expect(first).resolves.toEqual({ status: "sent" });
2175
- expect(sessionStorage.getItem("drip_cart_attr")).toBe(newestStamp);
2176
-
2177
2878
  vi.mocked(globalThis.fetch).mockClear();
2178
2879
  await expect(syncShopifyCartAttributes(secondInput)).resolves.toEqual({
2179
2880
  status: "sent",
@@ -2183,6 +2884,7 @@ describe("syncShopifyCartAttributes", () => {
2183
2884
  });
2184
2885
 
2185
2886
  it("does not let stale authorization mutate the Shopify cart", async () => {
2887
+ vi.useFakeTimers();
2186
2888
  let resolveOlderAuthorization!: (response: Response) => void;
2187
2889
  let resolveNewerAuthorization!: (response: Response) => void;
2188
2890
  globalThis.fetch = vi.fn((url) => {
@@ -2210,9 +2912,11 @@ describe("syncShopifyCartAttributes", () => {
2210
2912
  });
2211
2913
 
2212
2914
  const older = syncShopifyCartAttributes(input("exp_old", "var_old", 1));
2213
- await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
2915
+ await vi.advanceTimersByTimeAsync(0);
2916
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
2214
2917
  const newer = syncShopifyCartAttributes(input("exp_new", "var_new", 2));
2215
- await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
2918
+ await vi.advanceTimersByTimeAsync(0);
2919
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
2216
2920
 
2217
2921
  resolveNewerAuthorization(new Response(JSON.stringify({
2218
2922
  assignments: [{
@@ -2234,8 +2938,8 @@ describe("syncShopifyCartAttributes", () => {
2234
2938
  commerce_authorization: "older-proof",
2235
2939
  }), { status: 200, headers: { "Content-Type": "application/json" } }));
2236
2940
  await expect(older).resolves.toEqual({
2237
- status: "failed",
2238
- reason: "authorization_failed",
2941
+ status: "skipped",
2942
+ reason: "superseded",
2239
2943
  });
2240
2944
  expect(globalThis.fetch).toHaveBeenCalledTimes(3);
2241
2945
 
@@ -2250,6 +2954,7 @@ describe("syncShopifyCartAttributes", () => {
2250
2954
  });
2251
2955
 
2252
2956
  it("lets price-plane teardown invalidate an in-flight cart authorization before it writes", async () => {
2957
+ vi.useFakeTimers();
2253
2958
  let resolveAuthorization!: (response: Response) => void;
2254
2959
  globalThis.fetch = vi.fn((url) => {
2255
2960
  if (url === "/cart.js") {
@@ -2287,9 +2992,11 @@ describe("syncShopifyCartAttributes", () => {
2287
2992
  commerce_authorization: "stale-proof",
2288
2993
  }));
2289
2994
 
2995
+ await vi.advanceTimersByTimeAsync(0);
2996
+
2290
2997
  await expect(sync).resolves.toEqual({
2291
- status: "failed",
2292
- reason: "authorization_failed",
2998
+ status: "skipped",
2999
+ reason: "superseded",
2293
3000
  });
2294
3001
  expect(globalThis.fetch).toHaveBeenCalledTimes(2);
2295
3002
  expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
@@ -2326,7 +3033,7 @@ describe("syncShopifyCartAttributes", () => {
2326
3033
  })).resolves.toEqual({ status: "sent", reason: "already_stamped" });
2327
3034
 
2328
3035
  resolveOlder(new Response("ok", { status: 200 }));
2329
- await expect(older).resolves.toEqual({ status: "sent" });
3036
+ await expect(older).resolves.toEqual({ status: "skipped", reason: "superseded" });
2330
3037
  expect(sessionStorage.getItem("drip_cart_attr")).toBe(newestStamp);
2331
3038
  });
2332
3039
 
@@ -2344,6 +3051,8 @@ describe("syncShopifyCartAttributes", () => {
2344
3051
  assignment_epoch: 2,
2345
3052
  }],
2346
3053
  commerce_authorization: "signed-envelope",
3054
+ handoff_persisted: false,
3055
+ handoff_stale_risk: false,
2347
3056
  }))
2348
3057
  .mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
2349
3058
  const { syncShopifyCartAttributes } = await import("./shopify-cart");
@@ -2770,6 +3479,7 @@ describe("syncShopifyCartAttributes", () => {
2770
3479
 
2771
3480
  const proofBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[1]?.body));
2772
3481
  expect(proofBody.cart_token).toBe("gid://shopify/Cart/headless?key=headless-secret");
3482
+ expect(proofBody.persist_function_handoff).toBe(true);
2773
3483
  const cartUpdateBody = JSON.parse(
2774
3484
  String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
2775
3485
  );