@drip-apex/sdk 0.1.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/_headers +3 -0
  4. package/dist/bundle-report.json +564 -0
  5. package/dist/drip.debug.js +2 -0
  6. package/dist/drip.debug.js.map +1 -0
  7. package/dist/drip.heatmap.js +2 -0
  8. package/dist/drip.heatmap.js.map +1 -0
  9. package/dist/drip.js +2 -0
  10. package/dist/drip.js.map +1 -0
  11. package/dist/drip.live.js +2 -0
  12. package/dist/drip.live.js.map +1 -0
  13. package/dist/editor.js +480 -0
  14. package/dist/editor.js.map +7 -0
  15. package/dist/index.mjs +645 -0
  16. package/dist/index.mjs.map +7 -0
  17. package/dist/metafiles/drip.debug.meta.json +973 -0
  18. package/dist/metafiles/drip.heatmap.meta.json +918 -0
  19. package/dist/metafiles/drip.live.meta.json +918 -0
  20. package/dist/metafiles/drip.meta.json +973 -0
  21. package/dist/metafiles/editor.meta.json +321 -0
  22. package/dist/metafiles/index.meta.json +1039 -0
  23. package/dist/metafiles/index.mjs.meta.json +78 -0
  24. package/dist/react/index.js +16973 -0
  25. package/dist/react/index.js.map +7 -0
  26. package/dist/sri.json +27 -0
  27. package/dist/types/analytics.d.ts +15 -0
  28. package/dist/types/antiflicker.d.ts +14 -0
  29. package/dist/types/assignment-store.d.ts +30 -0
  30. package/dist/types/audit.d.ts +9 -0
  31. package/dist/types/bucket.d.ts +2 -0
  32. package/dist/types/bucketing-cleanup.d.ts +5 -0
  33. package/dist/types/commerce-gift.d.ts +41 -0
  34. package/dist/types/commerce-price.d.ts +22 -0
  35. package/dist/types/commerce-shipping.d.ts +28 -0
  36. package/dist/types/css.d.ts +5 -0
  37. package/dist/types/cwv.d.ts +10 -0
  38. package/dist/types/data-layer-normalize.d.ts +1 -0
  39. package/dist/types/debug.d.ts +23 -0
  40. package/dist/types/define.d.ts +87 -0
  41. package/dist/types/devtools.d.ts +32 -0
  42. package/dist/types/dom-trigger.d.ts +3 -0
  43. package/dist/types/editor/api.d.ts +37 -0
  44. package/dist/types/editor/artifacts.d.ts +15 -0
  45. package/dist/types/editor/command-runtime.d.ts +48 -0
  46. package/dist/types/editor/controller.d.ts +97 -0
  47. package/dist/types/editor/device-preview.d.ts +23 -0
  48. package/dist/types/editor/index.d.ts +9 -0
  49. package/dist/types/editor/operator.d.ts +39 -0
  50. package/dist/types/editor/picker.d.ts +23 -0
  51. package/dist/types/editor/selector.d.ts +2 -0
  52. package/dist/types/editor/types.d.ts +55 -0
  53. package/dist/types/editor/view-kit.d.ts +33 -0
  54. package/dist/types/editor/view.d.ts +5 -0
  55. package/dist/types/editor-bootstrap.d.ts +37 -0
  56. package/dist/types/exclusion-groups.d.ts +59 -0
  57. package/dist/types/exposure-signals.d.ts +7 -0
  58. package/dist/types/flags.d.ts +65 -0
  59. package/dist/types/goals.d.ts +16 -0
  60. package/dist/types/guards.d.ts +39 -0
  61. package/dist/types/handover.d.ts +10 -0
  62. package/dist/types/hash.d.ts +19 -0
  63. package/dist/types/heatmap.d.ts +8 -0
  64. package/dist/types/hideflicker.d.ts +15 -0
  65. package/dist/types/hydration.d.ts +11 -0
  66. package/dist/types/index.d.ts +144 -0
  67. package/dist/types/js.d.ts +6 -0
  68. package/dist/types/module.d.ts +4 -0
  69. package/dist/types/money.d.ts +14 -0
  70. package/dist/types/mutate.d.ts +34 -0
  71. package/dist/types/observer-mute.d.ts +3 -0
  72. package/dist/types/pii-keys.d.ts +1 -0
  73. package/dist/types/prerequisites.d.ts +12 -0
  74. package/dist/types/qa-assistant-state.d.ts +68 -0
  75. package/dist/types/qa-assistant-view.d.ts +6 -0
  76. package/dist/types/qa-assistant.d.ts +9 -0
  77. package/dist/types/qa-toolbar.d.ts +19 -0
  78. package/dist/types/react/index.d.ts +84 -0
  79. package/dist/types/reassert-sentinel.d.ts +6 -0
  80. package/dist/types/router.d.ts +9 -0
  81. package/dist/types/runtime-tools.d.ts +50 -0
  82. package/dist/types/runtime.d.ts +22 -0
  83. package/dist/types/safe-regex.d.ts +6 -0
  84. package/dist/types/sanitize.d.ts +5 -0
  85. package/dist/types/semantic.d.ts +7 -0
  86. package/dist/types/shopify-cart-events.d.ts +7 -0
  87. package/dist/types/shopify-cart.d.ts +62 -0
  88. package/dist/types/signals.d.ts +31 -0
  89. package/dist/types/surface-signals.d.ts +6 -0
  90. package/dist/types/targeting-diagnostics.d.ts +5 -0
  91. package/dist/types/targeting.d.ts +50 -0
  92. package/dist/types/test-fixtures/commerce.d.ts +12 -0
  93. package/dist/types/track.d.ts +79 -0
  94. package/dist/types/types.d.ts +859 -0
  95. package/dist/types/wait.d.ts +7 -0
  96. package/package.json +74 -0
  97. package/src/analytics.ts +146 -0
  98. package/src/antiflicker.test.ts +127 -0
  99. package/src/antiflicker.ts +147 -0
  100. package/src/assignment-store.test.ts +171 -0
  101. package/src/assignment-store.ts +208 -0
  102. package/src/audit.test.ts +17 -0
  103. package/src/audit.ts +362 -0
  104. package/src/bucket.test.ts +65 -0
  105. package/src/bucket.ts +18 -0
  106. package/src/bucketing-cleanup.test.ts +30 -0
  107. package/src/bucketing-cleanup.ts +72 -0
  108. package/src/bucketing-parity.test.ts +41 -0
  109. package/src/commerce-analytics-lifecycle.test.ts +1416 -0
  110. package/src/commerce-config-lifecycle.test.ts +274 -0
  111. package/src/commerce-epoch-lifecycle.test.ts +62 -0
  112. package/src/commerce-gift.test.ts +1293 -0
  113. package/src/commerce-gift.ts +979 -0
  114. package/src/commerce-offers-display.test.ts +154 -0
  115. package/src/commerce-price.test.ts +1145 -0
  116. package/src/commerce-price.ts +941 -0
  117. package/src/commerce-revocation-lifecycle.test.ts +78 -0
  118. package/src/commerce-shipping.test.ts +630 -0
  119. package/src/commerce-shipping.ts +447 -0
  120. package/src/config-fetch.test.ts +325 -0
  121. package/src/css.ts +54 -0
  122. package/src/cwv.test.ts +95 -0
  123. package/src/cwv.ts +129 -0
  124. package/src/data-layer-normalize.ts +21 -0
  125. package/src/debug.test.ts +83 -0
  126. package/src/debug.ts +138 -0
  127. package/src/define.test.ts +327 -0
  128. package/src/define.ts +208 -0
  129. package/src/devtools.ts +582 -0
  130. package/src/dom-trigger.test.ts +70 -0
  131. package/src/dom-trigger.ts +55 -0
  132. package/src/editor/api.test.ts +154 -0
  133. package/src/editor/api.ts +185 -0
  134. package/src/editor/artifacts.test.ts +57 -0
  135. package/src/editor/artifacts.ts +115 -0
  136. package/src/editor/command-runtime.test.ts +225 -0
  137. package/src/editor/command-runtime.ts +572 -0
  138. package/src/editor/controller.test.ts +371 -0
  139. package/src/editor/controller.ts +596 -0
  140. package/src/editor/device-preview.test.ts +60 -0
  141. package/src/editor/device-preview.ts +120 -0
  142. package/src/editor/index.test.ts +161 -0
  143. package/src/editor/index.ts +169 -0
  144. package/src/editor/operator.test.ts +148 -0
  145. package/src/editor/operator.ts +256 -0
  146. package/src/editor/picker.test.ts +58 -0
  147. package/src/editor/picker.ts +152 -0
  148. package/src/editor/selector.test.ts +44 -0
  149. package/src/editor/selector.ts +110 -0
  150. package/src/editor/types.ts +92 -0
  151. package/src/editor/view-kit.ts +162 -0
  152. package/src/editor/view.test.ts +535 -0
  153. package/src/editor/view.ts +1395 -0
  154. package/src/editor-bootstrap.test.ts +116 -0
  155. package/src/editor-bootstrap.ts +213 -0
  156. package/src/event-schema-contract.test.ts +35 -0
  157. package/src/exclusion-groups.test.ts +383 -0
  158. package/src/exclusion-groups.ts +270 -0
  159. package/src/exposure-signals.test.ts +182 -0
  160. package/src/exposure-signals.ts +110 -0
  161. package/src/flags.test.ts +322 -0
  162. package/src/flags.ts +201 -0
  163. package/src/goals.test.ts +42 -0
  164. package/src/goals.ts +200 -0
  165. package/src/guards.test.ts +249 -0
  166. package/src/guards.ts +250 -0
  167. package/src/handover-cache-order.test.ts +32 -0
  168. package/src/handover.test.ts +349 -0
  169. package/src/handover.ts +309 -0
  170. package/src/hash.ts +214 -0
  171. package/src/heatmap.test.ts +305 -0
  172. package/src/heatmap.ts +373 -0
  173. package/src/hideflicker.ts +21 -0
  174. package/src/hydration.test.ts +252 -0
  175. package/src/hydration.ts +203 -0
  176. package/src/index.ts +7513 -0
  177. package/src/init-empty.test.ts +5572 -0
  178. package/src/js.test.ts +26 -0
  179. package/src/js.ts +61 -0
  180. package/src/module.ts +50 -0
  181. package/src/money.test.ts +35 -0
  182. package/src/money.ts +53 -0
  183. package/src/mutate.test.ts +304 -0
  184. package/src/mutate.ts +469 -0
  185. package/src/observer-mute.test.ts +88 -0
  186. package/src/observer-mute.ts +45 -0
  187. package/src/page-trigger.test.ts +84 -0
  188. package/src/personalization.test.ts +68 -0
  189. package/src/pii-keys.ts +17 -0
  190. package/src/prerequisites.test.ts +39 -0
  191. package/src/prerequisites.ts +94 -0
  192. package/src/qa-assistant-state.test.ts +471 -0
  193. package/src/qa-assistant-state.ts +505 -0
  194. package/src/qa-assistant-view.test.ts +185 -0
  195. package/src/qa-assistant-view.ts +768 -0
  196. package/src/qa-assistant.ts +40 -0
  197. package/src/qa-toolbar.ts +444 -0
  198. package/src/react/index.test.tsx +177 -0
  199. package/src/react/index.tsx +252 -0
  200. package/src/reassert-sentinel.test.ts +314 -0
  201. package/src/reassert-sentinel.ts +191 -0
  202. package/src/router.test.ts +78 -0
  203. package/src/router.ts +92 -0
  204. package/src/runtime-tools.test.ts +112 -0
  205. package/src/runtime-tools.ts +403 -0
  206. package/src/runtime.test.ts +217 -0
  207. package/src/runtime.ts +268 -0
  208. package/src/safe-regex.ts +35 -0
  209. package/src/sanitize.ts +34 -0
  210. package/src/semantic.ts +245 -0
  211. package/src/shopify-cart-events.test.ts +76 -0
  212. package/src/shopify-cart-events.ts +115 -0
  213. package/src/shopify-cart.test.ts +2823 -0
  214. package/src/shopify-cart.ts +1256 -0
  215. package/src/signals.ts +226 -0
  216. package/src/surface-signals.test.ts +83 -0
  217. package/src/surface-signals.ts +165 -0
  218. package/src/targeting-diagnostics.ts +560 -0
  219. package/src/targeting.detailed.test.ts +443 -0
  220. package/src/targeting.test.ts +1054 -0
  221. package/src/targeting.ts +1024 -0
  222. package/src/test-fixtures/commerce.ts +65 -0
  223. package/src/track.test.ts +817 -0
  224. package/src/track.ts +519 -0
  225. package/src/types.ts +991 -0
  226. package/src/wait.test.ts +96 -0
  227. package/src/wait.ts +238 -0
@@ -0,0 +1,1145 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ applyCommercePrice as applyCommercePriceWithStamp,
5
+ cleanupCommercePrice,
6
+ isCommercePriceActive,
7
+ isCommercePriceExposureEligible,
8
+ isCommercePriceWorkPending,
9
+ notifyCommerceCartStampConfirmed,
10
+ } from "./commerce-price";
11
+ import type { Assignment, CommercePriceSettings } from "./types";
12
+
13
+ const settings: CommercePriceSettings = {
14
+ revision: 1,
15
+ checksum: "sum",
16
+ experiments: [
17
+ {
18
+ experiment_id: "exp_price",
19
+ assignment_epoch: 1,
20
+ currency: "EUR",
21
+ variants: {
22
+ treatment: [
23
+ {
24
+ variant_gid: "gid://shopify/ProductVariant/123",
25
+ fixedPricePerUnit: "1149.95",
26
+ },
27
+ ],
28
+ },
29
+ },
30
+ ],
31
+ };
32
+ const assignments = [
33
+ {
34
+ experimentId: "exp_price",
35
+ variationId: "treatment",
36
+ assignmentEpoch: 1,
37
+ mutations: [],
38
+ index: 1,
39
+ },
40
+ ] as Assignment[];
41
+
42
+ function applyCommercePrice(
43
+ priceSettings: CommercePriceSettings | undefined,
44
+ activeAssignments: Assignment[],
45
+ isCohortStampedOnCart: () => boolean | "pending" = () => true,
46
+ ): void {
47
+ applyCommercePriceWithStamp(
48
+ priceSettings,
49
+ activeAssignments,
50
+ isCohortStampedOnCart,
51
+ );
52
+ }
53
+
54
+ function shopify(pageType = "product", currency = "EUR", withMeta = true) {
55
+ Object.assign(window, {
56
+ Shopify: { currency: { active: currency } },
57
+ ShopifyAnalytics: withMeta
58
+ ? {
59
+ meta: {
60
+ page: { pageType, handle: "current-product" },
61
+ product: { variants: [{ id: 123, price: 129900 }] },
62
+ },
63
+ }
64
+ : undefined,
65
+ });
66
+ }
67
+
68
+ describe("commerce price display swap", () => {
69
+ beforeEach(() => {
70
+ document.body.innerHTML = "";
71
+ shopify();
72
+ });
73
+
74
+ afterEach(() => {
75
+ cleanupCommercePrice();
76
+ delete (window as any).Shopify;
77
+ delete (window as any).ShopifyAnalytics;
78
+ });
79
+
80
+ it("swaps all matched PDP nodes while preserving localized formatting", () => {
81
+ document.body.innerHTML =
82
+ '<div class="price">€1.299,00</div><div class="product-price">Now €1.299,00</div>';
83
+ applyCommercePrice(settings, assignments);
84
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
85
+ expect(document.querySelector(".product-price")?.textContent).toBe(
86
+ "Now €1.149,95",
87
+ );
88
+ });
89
+
90
+ it("keeps the published price when the active plane does not authorize the assignment", () => {
91
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
92
+ const authorizeNone = () => false;
93
+
94
+ expect(
95
+ isCommercePriceWorkPending(settings, assignments, authorizeNone),
96
+ ).toBe(false);
97
+ expect(isCommercePriceActive(settings, assignments, authorizeNone)).toBe(
98
+ false,
99
+ );
100
+ applyCommercePriceWithStamp(
101
+ settings,
102
+ assignments,
103
+ () => true,
104
+ undefined,
105
+ authorizeNone,
106
+ );
107
+
108
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
109
+ });
110
+
111
+ it("gates compare-at framing on assignment authorization and keeps the live current price", () => {
112
+ document.body.innerHTML = '<div><span class="price">€1.299,00</span></div>';
113
+ const compareSettings: CommercePriceSettings = {
114
+ ...settings,
115
+ experiments: [
116
+ {
117
+ ...settings.experiments[0],
118
+ variants: {
119
+ treatment: [
120
+ {
121
+ variant_gid: "gid://shopify/ProductVariant/123",
122
+ compareAt: { mode: "set", amount: "1499.00" },
123
+ },
124
+ ],
125
+ },
126
+ },
127
+ ],
128
+ };
129
+ applyCommercePriceWithStamp(
130
+ compareSettings,
131
+ assignments,
132
+ () => true,
133
+ undefined,
134
+ () => false,
135
+ );
136
+ expect(document.querySelector("[data-apex-compare-at]")).toBeNull();
137
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
138
+ applyCommercePriceWithStamp(
139
+ compareSettings,
140
+ assignments,
141
+ () => true,
142
+ undefined,
143
+ () => true,
144
+ );
145
+ expect(document.querySelector("[data-apex-compare-at]")?.textContent).toBe(
146
+ "€1.499,00",
147
+ );
148
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
149
+ });
150
+
151
+ it("frames every eligible price surface and removes every frame on clear", () => {
152
+ document.body.innerHTML = `
153
+ <div data-main><span class="price">€1.299,00</span></div>
154
+ <div data-sticky><span class="product-price">€1.299,00</span></div>
155
+ `;
156
+ const adjustment: {
157
+ variant_gid: string;
158
+ compareAt: { mode: "set"; amount: string } | { mode: "clear" };
159
+ } = {
160
+ variant_gid: "gid://shopify/ProductVariant/123",
161
+ compareAt: { mode: "set" as const, amount: "1499.00" },
162
+ };
163
+ const compareSettings: CommercePriceSettings = {
164
+ ...settings,
165
+ experiments: [
166
+ { ...settings.experiments[0], variants: { treatment: [adjustment] } },
167
+ ],
168
+ };
169
+
170
+ applyCommercePrice(compareSettings, assignments);
171
+
172
+ expect(document.querySelectorAll("[data-apex-compare-at]")).toHaveLength(2);
173
+ expect(
174
+ document.querySelector("[data-main] [data-apex-compare-at]")?.textContent,
175
+ ).toBe("€1.499,00");
176
+ expect(
177
+ document.querySelector("[data-sticky] [data-apex-compare-at]")
178
+ ?.textContent,
179
+ ).toBe("€1.499,00");
180
+
181
+ adjustment.compareAt = { mode: "clear" };
182
+ applyCommercePrice(compareSettings, assignments);
183
+ expect(document.querySelectorAll("[data-apex-compare-at]")).toHaveLength(0);
184
+ });
185
+
186
+ it("keeps Apex compare-at framing visible after a price DOM refresh", async () => {
187
+ document.body.innerHTML = '<div><span class="price">€1.299,00</span></div>';
188
+ const compareSettings: CommercePriceSettings = {
189
+ ...settings,
190
+ experiments: [
191
+ {
192
+ ...settings.experiments[0],
193
+ variants: {
194
+ treatment: [
195
+ {
196
+ variant_gid: "gid://shopify/ProductVariant/123",
197
+ compareAt: { mode: "set", amount: "1499.00" },
198
+ },
199
+ ],
200
+ },
201
+ },
202
+ ],
203
+ };
204
+ applyCommercePrice(compareSettings, assignments);
205
+ document.querySelector(".price")!.textContent = "€1.299,00";
206
+ await new Promise((resolve) => setTimeout(resolve, 0));
207
+ await new Promise((resolve) => setTimeout(resolve, 0));
208
+ expect(
209
+ (document.querySelector("[data-apex-compare-at]") as HTMLElement).style
210
+ .display,
211
+ ).not.toBe("none");
212
+ expect(document.querySelectorAll("[data-apex-compare-at]")).toHaveLength(1);
213
+ });
214
+
215
+ it("renders display-only framing beside an unauthorized charged arm without waiting for a cart stamp", () => {
216
+ document.body.innerHTML = '<div><span class="price">€1.299,00</span></div>';
217
+ const displayAssignment = {
218
+ experimentId: "exp_compare",
219
+ variationId: "treatment",
220
+ mutations: [],
221
+ index: 2,
222
+ } as Assignment;
223
+ const compareSettings: CommercePriceSettings = {
224
+ ...settings,
225
+ experiments: [
226
+ settings.experiments[0],
227
+ {
228
+ experiment_id: "exp_compare",
229
+ currency: "EUR",
230
+ variants: {
231
+ treatment: [
232
+ {
233
+ variant_gid: "gid://shopify/ProductVariant/123",
234
+ compareAt: { mode: "set", amount: "1499.00" },
235
+ },
236
+ ],
237
+ },
238
+ },
239
+ ],
240
+ };
241
+
242
+ applyCommercePriceWithStamp(
243
+ compareSettings,
244
+ [...assignments, displayAssignment],
245
+ () => false,
246
+ undefined,
247
+ (assignment) => assignment.experimentId === "exp_compare",
248
+ );
249
+
250
+ expect(document.querySelector("[data-apex-compare-at]")?.textContent).toBe(
251
+ "€1.499,00",
252
+ );
253
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
254
+ });
255
+
256
+ it("reconciles compare-at framing when the product form changes variant", async () => {
257
+ Object.assign((window as any).ShopifyAnalytics.meta.product, {
258
+ variants: [
259
+ { id: 123, price: 129900 },
260
+ { id: 456, price: 17900 },
261
+ ],
262
+ });
263
+ document.body.innerHTML = `
264
+ <form action="/cart/add"><select name="id"><option value="123" selected>First</option><option value="456">Second</option></select></form>
265
+ <div><span class="price">€1.299,00</span></div>
266
+ `;
267
+ const adjustments = [
268
+ {
269
+ variant_gid: "gid://shopify/ProductVariant/123",
270
+ compareAt: { mode: "set" as const, amount: "1499.00" },
271
+ },
272
+ {
273
+ variant_gid: "gid://shopify/ProductVariant/456",
274
+ compareAt: { mode: "set" as const, amount: "199.00" },
275
+ },
276
+ ];
277
+ const compareSettings: CommercePriceSettings = {
278
+ ...settings,
279
+ experiments: [
280
+ { ...settings.experiments[0], variants: { treatment: adjustments } },
281
+ ],
282
+ };
283
+ applyCommercePrice(compareSettings, assignments);
284
+ const select = document.querySelector<HTMLSelectElement>('[name="id"]')!;
285
+ expect(document.querySelector("[data-apex-compare-at]")?.textContent).toBe(
286
+ "€1.499,00",
287
+ );
288
+
289
+ select.value = "456";
290
+ select.dispatchEvent(new Event("change", { bubbles: true }));
291
+ await new Promise((resolve) => setTimeout(resolve, 0));
292
+ expect(document.querySelector("[data-apex-compare-at]")?.textContent).toBe(
293
+ "€199,00",
294
+ );
295
+
296
+ adjustments[1] = {
297
+ variant_gid: "gid://shopify/ProductVariant/456",
298
+ compareAt: { mode: "clear" },
299
+ };
300
+ select.dispatchEvent(new Event("input", { bubbles: true }));
301
+ await new Promise((resolve) => setTimeout(resolve, 0));
302
+ expect(document.querySelector("[data-apex-compare-at]")).toBeNull();
303
+ });
304
+
305
+ it("uses the checked radio when a variant picker shares the id name", async () => {
306
+ Object.assign((window as any).ShopifyAnalytics.meta.product, {
307
+ variants: [
308
+ { id: 123, price: 129900 },
309
+ { id: 456, price: 9900 },
310
+ ],
311
+ });
312
+ document.body.innerHTML = `
313
+ <form action="/cart/add">
314
+ <input type="radio" name="id" value="123" checked>
315
+ <input type="radio" name="id" value="456">
316
+ </form>
317
+ <div><span class="price">€1.299,00</span></div>
318
+ `;
319
+ const compareSettings: CommercePriceSettings = {
320
+ ...settings,
321
+ experiments: [
322
+ {
323
+ ...settings.experiments[0],
324
+ variants: {
325
+ treatment: [
326
+ {
327
+ variant_gid: "gid://shopify/ProductVariant/123",
328
+ compareAt: { mode: "set", amount: "1499.00" },
329
+ },
330
+ {
331
+ variant_gid: "gid://shopify/ProductVariant/456",
332
+ compareAt: { mode: "set", amount: "149.00" },
333
+ },
334
+ ],
335
+ },
336
+ },
337
+ ],
338
+ };
339
+ applyCommercePrice(compareSettings, assignments);
340
+ expect(document.querySelector("[data-apex-compare-at]")?.textContent).toBe(
341
+ "€1.499,00",
342
+ );
343
+
344
+ const radios = document.querySelectorAll<HTMLInputElement>(
345
+ 'input[type="radio"]',
346
+ );
347
+ radios[0]!.checked = false;
348
+ radios[1]!.checked = true;
349
+ radios[1]!.dispatchEvent(new Event("change", { bubbles: true }));
350
+ await new Promise((resolve) => setTimeout(resolve, 0));
351
+
352
+ expect(document.querySelector("[data-apex-compare-at]")?.textContent).toBe(
353
+ "€149,00",
354
+ );
355
+ });
356
+
357
+ it("keeps display exposure scoped to the assignment that rendered", () => {
358
+ document.body.innerHTML =
359
+ '<form action="/cart/add"><input name="id" value="123"></form><span class="price">€1.299,00</span>';
360
+ const otherAssignment = {
361
+ ...assignments[0],
362
+ experimentId: "zz_exp_other",
363
+ };
364
+ const compareSettings: CommercePriceSettings = {
365
+ ...settings,
366
+ experiments: [
367
+ {
368
+ ...settings.experiments[0],
369
+ variants: {
370
+ treatment: [
371
+ {
372
+ variant_gid: "gid://shopify/ProductVariant/123",
373
+ compareAt: { mode: "set", amount: "1499.00" },
374
+ },
375
+ ],
376
+ },
377
+ },
378
+ {
379
+ ...settings.experiments[0],
380
+ experiment_id: "zz_exp_other",
381
+ variants: {
382
+ treatment: [
383
+ {
384
+ variant_gid: "gid://shopify/ProductVariant/123",
385
+ compareAt: { mode: "set", amount: "1599.00" },
386
+ },
387
+ ],
388
+ },
389
+ },
390
+ ],
391
+ };
392
+
393
+ applyCommercePrice(compareSettings, [assignments[0], otherAssignment]);
394
+
395
+ expect(
396
+ isCommercePriceExposureEligible(compareSettings, assignments[0]),
397
+ ).toBe(true);
398
+ expect(
399
+ isCommercePriceExposureEligible(compareSettings, otherAssignment),
400
+ ).toBe(false);
401
+ });
402
+
403
+ it("keeps native compare-at hidden for a clear arm across passes", async () => {
404
+ document.body.innerHTML = `
405
+ <form action="/cart/add"><input name="id" value="123" /></form>
406
+ <div><span class="price">€1.299,00</span><s data-native-compare>€1.499,00</s></div>
407
+ `;
408
+ const compareSettings: CommercePriceSettings = {
409
+ ...settings,
410
+ experiments: [
411
+ {
412
+ ...settings.experiments[0],
413
+ variants: {
414
+ treatment: [
415
+ {
416
+ variant_gid: "gid://shopify/ProductVariant/123",
417
+ compareAt: { mode: "clear" },
418
+ },
419
+ ],
420
+ },
421
+ },
422
+ ],
423
+ };
424
+ applyCommercePrice(compareSettings, assignments);
425
+ const native = document.querySelector(
426
+ "[data-native-compare]",
427
+ ) as HTMLElement;
428
+ expect(native.style.display).toBe("none");
429
+ expect(document.querySelector("[data-apex-compare-at]")).toBeNull();
430
+ // A later pass (variant-form event) must not restore the native node.
431
+ document
432
+ .querySelector('[name="id"]')!
433
+ .dispatchEvent(new Event("change", { bubbles: true }));
434
+ await new Promise((resolve) => setTimeout(resolve, 0));
435
+ expect(native.style.display).toBe("none");
436
+ });
437
+
438
+ it("scopes compare-at framing and cleanup to the current product", () => {
439
+ document.body.innerHTML = `
440
+ <div><span class="price" data-main>€1.299,00</span><s data-main-compare>€1.399,00</s></div>
441
+ <a href="/products/related-product"><div><span class="price">€1.299,00</span><s data-related-compare>€1.499,00</s></div></a>
442
+ `;
443
+ const compareSettings: CommercePriceSettings = {
444
+ ...settings,
445
+ experiments: [
446
+ {
447
+ ...settings.experiments[0],
448
+ variants: {
449
+ treatment: [
450
+ {
451
+ variant_gid: "gid://shopify/ProductVariant/123",
452
+ compareAt: { mode: "set", amount: "1599.00" },
453
+ },
454
+ ],
455
+ },
456
+ },
457
+ ],
458
+ };
459
+ applyCommercePrice(compareSettings, assignments);
460
+ expect(
461
+ document
462
+ .querySelector("[data-main]")
463
+ ?.previousElementSibling?.getAttribute("data-apex-compare-at"),
464
+ ).toBe("true");
465
+ expect(
466
+ (document.querySelector("[data-main-compare]") as HTMLElement).style
467
+ .display,
468
+ ).toBe("none");
469
+ expect(
470
+ (document.querySelector("[data-related-compare]") as HTMLElement).style
471
+ .display,
472
+ ).not.toBe("none");
473
+ });
474
+
475
+ it("leaves a sibling-price product card untouched", () => {
476
+ document.body.innerHTML = `
477
+ <article class="product-card"><a href="/products/related-product">Related</a><span class="price" data-related>€1.299,00</span><s data-related-compare>€1.499,00</s></article>
478
+ <section><span class="price" data-main>€1.299,00</span></section>
479
+ `;
480
+ const compareSettings: CommercePriceSettings = {
481
+ ...settings,
482
+ experiments: [
483
+ {
484
+ ...settings.experiments[0],
485
+ variants: {
486
+ treatment: [
487
+ {
488
+ variant_gid: "gid://shopify/ProductVariant/123",
489
+ compareAt: { mode: "set", amount: "1599.00" },
490
+ },
491
+ ],
492
+ },
493
+ },
494
+ ],
495
+ };
496
+ applyCommercePrice(compareSettings, assignments);
497
+ expect(
498
+ (document.querySelector("[data-related-compare]") as HTMLElement).style
499
+ .display,
500
+ ).not.toBe("none");
501
+ expect(
502
+ document
503
+ .querySelector("[data-related]")
504
+ ?.previousElementSibling?.hasAttribute("data-apex-compare-at"),
505
+ ).not.toBe(true);
506
+ expect(
507
+ document
508
+ .querySelector("[data-main]")
509
+ ?.previousElementSibling?.getAttribute("data-apex-compare-at"),
510
+ ).toBe("true");
511
+ });
512
+
513
+ it("ignores a recommendation quick-add form before the current product form", () => {
514
+ Object.assign((window as any).ShopifyAnalytics.meta.product, {
515
+ variants: [
516
+ { id: 123, price: 129900 },
517
+ { id: 456, price: 9900 },
518
+ ],
519
+ });
520
+ document.body.innerHTML = `
521
+ <article class="product-card"><a href="/products/related-product">Related</a><form action="/cart/add"><input name="id" value="456"></form></article>
522
+ <section><form action="/cart/add"><select name="id"><option value="123" selected>Current</option></select></form><span class="price">€1.299,00</span></section>
523
+ `;
524
+ const compareSettings: CommercePriceSettings = {
525
+ ...settings,
526
+ experiments: [
527
+ {
528
+ ...settings.experiments[0],
529
+ variants: {
530
+ treatment: [
531
+ {
532
+ variant_gid: "gid://shopify/ProductVariant/123",
533
+ compareAt: { mode: "set", amount: "1499.00" },
534
+ },
535
+ {
536
+ variant_gid: "gid://shopify/ProductVariant/456",
537
+ compareAt: { mode: "set", amount: "149.00" },
538
+ },
539
+ ],
540
+ },
541
+ },
542
+ ],
543
+ };
544
+ applyCommercePrice(compareSettings, assignments);
545
+ expect(document.querySelector("[data-apex-compare-at]")?.textContent).toBe(
546
+ "€1.499,00",
547
+ );
548
+ });
549
+
550
+ it.each(["1299.00", "1200.00"])(
551
+ "refuses non-discount compare-at framing at %s",
552
+ (amount) => {
553
+ document.body.innerHTML =
554
+ '<section><span class="price">€1.299,00</span><s class="price__compare">€1.399,00</s></section>';
555
+ const compareSettings: CommercePriceSettings = {
556
+ ...settings,
557
+ experiments: [
558
+ {
559
+ ...settings.experiments[0],
560
+ variants: {
561
+ treatment: [
562
+ {
563
+ variant_gid: "gid://shopify/ProductVariant/123",
564
+ compareAt: { mode: "set", amount },
565
+ },
566
+ ],
567
+ },
568
+ },
569
+ ],
570
+ };
571
+ applyCommercePrice(compareSettings, assignments);
572
+ expect(document.querySelector("[data-apex-compare-at]")).toBeNull();
573
+ expect(
574
+ (document.querySelector(".price__compare") as HTMLElement).style.display,
575
+ ).toBe("");
576
+ },
577
+ );
578
+
579
+ it("uses the selected variant baseline and does nothing when selection is ambiguous", () => {
580
+ Object.assign((window as any).ShopifyAnalytics.meta.product, {
581
+ variants: [
582
+ { id: 123, price: 129900 },
583
+ { id: 456, price: 9900 },
584
+ ],
585
+ });
586
+ const compareSettings: CommercePriceSettings = {
587
+ ...settings,
588
+ experiments: [
589
+ {
590
+ ...settings.experiments[0],
591
+ variants: {
592
+ treatment: [
593
+ {
594
+ variant_gid: "gid://shopify/ProductVariant/123",
595
+ compareAt: { mode: "set", amount: "1499.00" },
596
+ },
597
+ {
598
+ variant_gid: "gid://shopify/ProductVariant/456",
599
+ compareAt: { mode: "set", amount: "149.00" },
600
+ },
601
+ ],
602
+ },
603
+ },
604
+ ],
605
+ };
606
+ document.body.innerHTML =
607
+ '<a href="/products/related-product"><span class="price">€99,00</span></a><div><span class="price">€1.299,00</span></div>';
608
+ applyCommercePrice(compareSettings, assignments);
609
+ expect(document.querySelector("[data-apex-compare-at]")).toBeNull();
610
+ cleanupCommercePrice();
611
+ history.replaceState({}, "", "?variant=123");
612
+ applyCommercePrice(compareSettings, assignments);
613
+ expect(document.querySelector("[data-apex-compare-at]")?.textContent).toBe(
614
+ "€1.499,00",
615
+ );
616
+ });
617
+
618
+ it.each([129900, "129900"])(
619
+ "matches zero-decimal displays against Shopify's stored price scale (%s)",
620
+ (storedPrice) => {
621
+ shopify("product", "JPY");
622
+ Object.assign((window as any).ShopifyAnalytics.meta.product, {
623
+ variants: [{ id: 123, price: storedPrice }],
624
+ });
625
+ const jpySettings: CommercePriceSettings = {
626
+ ...settings,
627
+ experiments: [
628
+ {
629
+ ...settings.experiments[0],
630
+ currency: "JPY",
631
+ variants: {
632
+ treatment: [
633
+ {
634
+ variant_gid: "gid://shopify/ProductVariant/123",
635
+ fixedPricePerUnit: "1149",
636
+ },
637
+ ],
638
+ },
639
+ },
640
+ ],
641
+ };
642
+ document.body.innerHTML = '<div class="price">¥1299</div>';
643
+
644
+ applyCommercePrice(jpySettings, assignments);
645
+
646
+ expect(document.querySelector(".price")?.textContent).toBe("¥1149");
647
+ },
648
+ );
649
+
650
+ it("keeps an accessible price label synchronized and restores it on cleanup", () => {
651
+ document.body.innerHTML =
652
+ '<div class="price" aria-label="Current price €1.299,00">€1.299,00</div>';
653
+ applyCommercePrice(settings, assignments);
654
+ const price = document.querySelector(".price");
655
+ expect(price?.textContent).toBe("€1.149,95");
656
+ expect(price?.getAttribute("aria-label")).toBe("Current price €1.149,95");
657
+ cleanupCommercePrice();
658
+ expect(price?.textContent).toBe("€1.299,00");
659
+ expect(price?.getAttribute("aria-label")).toBe("Current price €1.299,00");
660
+ });
661
+
662
+ it("excludes compare-at and strikethrough prices", () => {
663
+ document.body.innerHTML =
664
+ '<div class="price"><s>€1.299,00</s><span>€1.299,00</span></div><div class="price-item--compare">€1.299,00</div>';
665
+ applyCommercePrice(settings, assignments);
666
+ expect(document.querySelector("s")?.textContent).toBe("€1.299,00");
667
+ expect(document.querySelector("span")?.textContent).toBe("€1.149,95");
668
+ expect(document.querySelector(".price-item--compare")?.textContent).toBe(
669
+ "€1.299,00",
670
+ );
671
+ });
672
+
673
+ it("leaves another linked product untouched while swapping the unlinked PDP price", () => {
674
+ document.body.innerHTML = `
675
+ <link rel="canonical" href="https://shop.test/products/current-product">
676
+ <div class="price" data-main-price>€1.299,00</div>
677
+ <a href="/products/related-product"><div class="price" data-related-price>€1.299,00</div></a>
678
+ `;
679
+ applyCommercePrice(settings, assignments);
680
+ expect(document.querySelector("[data-main-price]")?.textContent).toBe(
681
+ "€1.149,95",
682
+ );
683
+ expect(document.querySelector("[data-related-price]")?.textContent).toBe(
684
+ "€1.299,00",
685
+ );
686
+ });
687
+
688
+ it("swaps the PDP price but not a cross-sell card under their shared ancestor", () => {
689
+ document.body.innerHTML = `
690
+ <main>
691
+ <div class="price" data-main-price>€1.299,00</div>
692
+ <article class="product-card">
693
+ <a href="/products/related-product">Related product</a>
694
+ <div class="price" data-card-price>€1.299,00</div>
695
+ </article>
696
+ </main>`;
697
+ applyCommercePrice(settings, assignments);
698
+ expect(document.querySelector("[data-main-price]")?.textContent).toBe(
699
+ "€1.149,95",
700
+ );
701
+ expect(document.querySelector("[data-card-price]")?.textContent).toBe(
702
+ "€1.299,00",
703
+ );
704
+ });
705
+
706
+ it("leaves a price wrapped by a cross-sell product link untouched", () => {
707
+ document.body.innerHTML = `
708
+ <div class="price" data-main-price>€1.299,00</div>
709
+ <a href="/products/related-product"><span class="price" data-card-price>€1.299,00</span></a>`;
710
+ applyCommercePrice(settings, assignments);
711
+ expect(document.querySelector("[data-main-price]")?.textContent).toBe(
712
+ "€1.149,95",
713
+ );
714
+ expect(document.querySelector("[data-card-price]")?.textContent).toBe(
715
+ "€1.299,00",
716
+ );
717
+ });
718
+
719
+ it("is inert on currency mismatch, missing assignment/meta, or non-product pages", () => {
720
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
721
+ shopify("product", "USD");
722
+ expect(isCommercePriceActive(settings, assignments)).toBe(false);
723
+ applyCommercePrice(settings, assignments);
724
+ expect(document.body.textContent).toContain("€1.299,00");
725
+ shopify();
726
+ expect(isCommercePriceActive(settings, [])).toBe(false);
727
+ expect(
728
+ isCommercePriceActive(settings, [
729
+ { ...assignments[0], variationId: "control" },
730
+ ]),
731
+ ).toBe(false);
732
+ expect(
733
+ isCommercePriceActive(settings, [{ ...assignments[0], isControl: true }]),
734
+ ).toBe(false);
735
+ shopify("product", "EUR", false);
736
+ expect(isCommercePriceActive(settings, assignments)).toBe(false);
737
+ Object.assign(window, {
738
+ ShopifyAnalytics: { meta: { page: { pageType: "product" } } },
739
+ });
740
+ expect(isCommercePriceActive(settings, assignments)).toBe(false);
741
+ shopify("collection");
742
+ expect(isCommercePriceActive(settings, assignments)).toBe(false);
743
+ });
744
+
745
+ it("fails closed when the storefront assignment epoch differs from the price plane", () => {
746
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
747
+
748
+ expect(
749
+ isCommercePriceActive(settings, [
750
+ { ...assignments[0], assignmentEpoch: 2 },
751
+ ]),
752
+ ).toBe(false);
753
+ applyCommercePrice(settings, [{ ...assignments[0], assignmentEpoch: 2 }]);
754
+
755
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
756
+ expect(
757
+ isCommercePriceWorkPending(settings, [
758
+ { ...assignments[0], assignmentEpoch: 2 },
759
+ ]),
760
+ ).toBe(false);
761
+ });
762
+
763
+ it("keeps legacy price planes without an epoch compatible during rollout", () => {
764
+ const legacySettings: CommercePriceSettings = {
765
+ ...settings,
766
+ experiments: [
767
+ { ...settings.experiments[0], assignment_epoch: undefined },
768
+ ],
769
+ };
770
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
771
+
772
+ applyCommercePrice(legacySettings, assignments);
773
+
774
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
775
+ });
776
+
777
+ it("selects the assigned experiment that actually targets the current product", () => {
778
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
779
+ const multiProductSettings: CommercePriceSettings = {
780
+ ...settings,
781
+ experiments: [
782
+ {
783
+ experiment_id: "exp_other_product",
784
+ currency: "EUR",
785
+ variants: {
786
+ treatment: [
787
+ {
788
+ variant_gid: "gid://shopify/ProductVariant/999",
789
+ fixedPricePerUnit: "9.99",
790
+ },
791
+ ],
792
+ },
793
+ },
794
+ settings.experiments[0],
795
+ ],
796
+ };
797
+ const multiAssignments = [
798
+ { ...assignments[0], experimentId: "exp_other_product" },
799
+ assignments[0],
800
+ ];
801
+ applyCommercePrice(multiProductSettings, multiAssignments);
802
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
803
+ });
804
+
805
+ it("displays the lowest authorized match when price experiments collide", () => {
806
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
807
+ const collisionSettings: CommercePriceSettings = {
808
+ ...settings,
809
+ experiments: [
810
+ {
811
+ experiment_id: "exp_higher_price",
812
+ assignment_epoch: 1,
813
+ currency: "EUR",
814
+ variants: {
815
+ treatment: [
816
+ {
817
+ variant_gid: "gid://shopify/ProductVariant/123",
818
+ fixedPricePerUnit: "1199.95",
819
+ },
820
+ ],
821
+ },
822
+ },
823
+ {
824
+ experiment_id: "exp_lower_price",
825
+ assignment_epoch: 1,
826
+ currency: "EUR",
827
+ variants: {
828
+ treatment: [
829
+ {
830
+ variant_gid: "gid://shopify/ProductVariant/123",
831
+ fixedPricePerUnit: "1099.95",
832
+ },
833
+ ],
834
+ },
835
+ },
836
+ ],
837
+ };
838
+ const collisionAssignments = [
839
+ { ...assignments[0], experimentId: "exp_higher_price" },
840
+ { ...assignments[0], experimentId: "exp_lower_price" },
841
+ ];
842
+
843
+ applyCommercePrice(collisionSettings, collisionAssignments);
844
+
845
+ expect(document.querySelector(".price")?.textContent).toBe("€1.099,95");
846
+ });
847
+
848
+ it("fails closed for equal, higher, over-precise, or unsafe treatment prices", () => {
849
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
850
+ for (const fixedPricePerUnit of [
851
+ "1299.00",
852
+ "1399.00",
853
+ "1149.951",
854
+ "999999999999999999.00",
855
+ ]) {
856
+ const invalidSettings: CommercePriceSettings = {
857
+ ...settings,
858
+ experiments: [
859
+ {
860
+ ...settings.experiments[0],
861
+ variants: {
862
+ treatment: [
863
+ {
864
+ variant_gid: "gid://shopify/ProductVariant/123",
865
+ fixedPricePerUnit,
866
+ },
867
+ ],
868
+ },
869
+ },
870
+ ],
871
+ };
872
+ applyCommercePrice(invalidSettings, assignments);
873
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
874
+ }
875
+ });
876
+
877
+ it("fails closed when Shopify publishes a non-integer or unsafe minor-unit price", () => {
878
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
879
+ for (const price of [129900.5, Number.MAX_SAFE_INTEGER + 1]) {
880
+ Object.assign((window as any).ShopifyAnalytics.meta.product, {
881
+ variants: [{ id: 123, price }],
882
+ });
883
+ applyCommercePrice(settings, assignments);
884
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
885
+ }
886
+ });
887
+
888
+ it("re-swaps a theme-rendered price after a variant update", async () => {
889
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
890
+ applyCommercePrice(settings, assignments);
891
+ document.querySelector(".price")!.textContent = "€1.299,00";
892
+ await new Promise((resolve) => setTimeout(resolve, 0));
893
+ await new Promise((resolve) => setTimeout(resolve, 0));
894
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
895
+ });
896
+
897
+ it("drops conflicting targets for a shared source price but keeps identical targets", () => {
898
+ shopify();
899
+ Object.assign((window as any).ShopifyAnalytics.meta.product, {
900
+ variants: [
901
+ { id: 123, price: 129900 },
902
+ { id: 456, price: 129900 },
903
+ ],
904
+ });
905
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
906
+ const ambiguousSettings: CommercePriceSettings = {
907
+ ...settings,
908
+ experiments: [
909
+ {
910
+ ...settings.experiments[0],
911
+ variants: {
912
+ treatment: [
913
+ {
914
+ variant_gid: "gid://shopify/ProductVariant/123",
915
+ fixedPricePerUnit: "1149.95",
916
+ },
917
+ {
918
+ variant_gid: "gid://shopify/ProductVariant/456",
919
+ fixedPricePerUnit: "1099.95",
920
+ },
921
+ ],
922
+ },
923
+ },
924
+ ],
925
+ };
926
+ applyCommercePrice(ambiguousSettings, assignments);
927
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
928
+
929
+ const equalSettings: CommercePriceSettings = {
930
+ ...ambiguousSettings,
931
+ experiments: [
932
+ {
933
+ ...ambiguousSettings.experiments[0],
934
+ variants: {
935
+ treatment: [
936
+ {
937
+ variant_gid: "gid://shopify/ProductVariant/123",
938
+ fixedPricePerUnit: "1149.95",
939
+ },
940
+ {
941
+ variant_gid: "gid://shopify/ProductVariant/456",
942
+ fixedPricePerUnit: "1149.95",
943
+ },
944
+ ],
945
+ },
946
+ },
947
+ ],
948
+ };
949
+ applyCommercePrice(equalSettings, assignments);
950
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
951
+ });
952
+
953
+ it("swaps a matching price block mounted after the initial pass", async () => {
954
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
955
+ applyCommercePrice(settings, assignments);
956
+ document.body.insertAdjacentHTML(
957
+ "beforeend",
958
+ '<aside class="sticky-atc"><div class="product-price">€1.299,00</div></aside>',
959
+ );
960
+ await new Promise((resolve) => setTimeout(resolve, 0));
961
+ await new Promise((resolve) => setTimeout(resolve, 0));
962
+ expect(
963
+ document.querySelector(".sticky-atc .product-price")?.textContent,
964
+ ).toBe("€1.149,95");
965
+ });
966
+
967
+ it("cleans up and stops observing when an observer-triggered pass throws", async () => {
968
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
969
+ applyCommercePrice(settings, assignments);
970
+ const price = document.querySelector(".price")!;
971
+ const createTreeWalker = document.createTreeWalker.bind(document);
972
+ document.createTreeWalker = (() => {
973
+ throw new Error("malformed price convention");
974
+ }) as typeof document.createTreeWalker;
975
+ price.textContent = "€1.299,00";
976
+ await new Promise((resolve) => setTimeout(resolve, 0));
977
+ document.createTreeWalker = createTreeWalker;
978
+
979
+ price.textContent = "€1.299,00";
980
+ await new Promise((resolve) => setTimeout(resolve, 0));
981
+ expect(price.textContent).toBe("€1.299,00");
982
+ });
983
+
984
+ it("restores original text on cleanup", () => {
985
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
986
+ applyCommercePrice(settings, assignments);
987
+ cleanupCommercePrice();
988
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
989
+ });
990
+
991
+ it("keeps the published price when a pending cohort stamp fails", async () => {
992
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
993
+ let stamp: boolean | "pending" = "pending";
994
+ applyCommercePrice(settings, assignments, () => stamp);
995
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
996
+
997
+ stamp = false;
998
+ await new Promise((resolve) => setTimeout(resolve, 30));
999
+
1000
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
1001
+ });
1002
+
1003
+ it("does not swap when the cohort stamp is impossible from the start", () => {
1004
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
1005
+ applyCommercePrice(settings, assignments, () => false);
1006
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
1007
+ });
1008
+
1009
+ it("keeps the swap when a pending cohort stamp becomes confirmed", async () => {
1010
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
1011
+ let stamp: boolean | "pending" = "pending";
1012
+ applyCommercePrice(settings, assignments, () => stamp);
1013
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
1014
+ stamp = true;
1015
+ await new Promise((resolve) => setTimeout(resolve, 30));
1016
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
1017
+ });
1018
+
1019
+ it("restores the latest theme-rendered source price after a variant update", async () => {
1020
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
1021
+ applyCommercePrice(settings, assignments);
1022
+ const price = document.querySelector(".price")!;
1023
+ Object.assign((window as any).ShopifyAnalytics.meta.product.variants[0], {
1024
+ price: 139900,
1025
+ });
1026
+ price.textContent = "€1.399,00";
1027
+ await new Promise((resolve) => setTimeout(resolve, 0));
1028
+ await new Promise((resolve) => setTimeout(resolve, 0));
1029
+ cleanupCommercePrice();
1030
+ expect(price.textContent).toBe("€1.399,00");
1031
+ });
1032
+
1033
+ it("retries activation until Shopify publishes product metadata, then swaps", async () => {
1034
+ // Measured on a live storefront: the SDK boots at ~43ms but Shopify has not yet
1035
+ // populated ShopifyAnalytics.meta.product. Activation must survive that window.
1036
+ vi.useFakeTimers();
1037
+ try {
1038
+ shopify("product", "EUR", false);
1039
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
1040
+
1041
+ applyCommercePrice(settings, assignments);
1042
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
1043
+
1044
+ shopify();
1045
+ await vi.advanceTimersByTimeAsync(200);
1046
+
1047
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
1048
+ } finally {
1049
+ vi.useRealTimers();
1050
+ }
1051
+ });
1052
+
1053
+ it("does not retry when the page can never become eligible", async () => {
1054
+ vi.useFakeTimers();
1055
+ try {
1056
+ shopify("collection");
1057
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
1058
+ const timer = vi.spyOn(globalThis, "setTimeout");
1059
+
1060
+ applyCommercePrice(settings, assignments);
1061
+ await vi.advanceTimersByTimeAsync(6000);
1062
+
1063
+ expect(timer).not.toHaveBeenCalled();
1064
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
1065
+ timer.mockRestore();
1066
+ } finally {
1067
+ vi.useRealTimers();
1068
+ }
1069
+ });
1070
+
1071
+ it("stops a pending activation retry on cleanup", async () => {
1072
+ vi.useFakeTimers();
1073
+ try {
1074
+ shopify("product", "EUR", false);
1075
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
1076
+
1077
+ applyCommercePrice(settings, assignments);
1078
+ cleanupCommercePrice();
1079
+ shopify();
1080
+ await vi.advanceTimersByTimeAsync(1000);
1081
+
1082
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
1083
+ } finally {
1084
+ vi.useRealTimers();
1085
+ }
1086
+ });
1087
+
1088
+ it("fails closed when the cohort stamp never confirms", async () => {
1089
+ vi.useFakeTimers();
1090
+ try {
1091
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
1092
+ // stamp stays "pending" forever (e.g. the cart sync request never resolves)
1093
+ applyCommercePrice(settings, assignments, () => "pending");
1094
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
1095
+
1096
+ await vi.advanceTimersByTimeAsync(9000);
1097
+
1098
+ // a price we cannot prove Shopify will charge must not stay on screen
1099
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
1100
+ } finally {
1101
+ cleanupCommercePrice();
1102
+ vi.useRealTimers();
1103
+ }
1104
+ });
1105
+
1106
+ it("applies the treatment after a cart stamp confirms beyond the reveal timeout", async () => {
1107
+ vi.useFakeTimers();
1108
+ try {
1109
+ document.body.innerHTML = '<div class="price">€1.299,00</div>';
1110
+ let stamp: boolean | "pending" = "pending";
1111
+ applyCommercePrice(settings, assignments, () => stamp);
1112
+
1113
+ await vi.advanceTimersByTimeAsync(9000);
1114
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
1115
+
1116
+ stamp = true;
1117
+ notifyCommerceCartStampConfirmed();
1118
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
1119
+ } finally {
1120
+ cleanupCommercePrice();
1121
+ vi.useRealTimers();
1122
+ }
1123
+ });
1124
+
1125
+ it("reports pending price work while Shopify metadata is still missing", () => {
1126
+ // The reveal barrier uses this: a metadata-late PDP must keep content hidden
1127
+ // until the swap lands, instead of flashing the original price.
1128
+ shopify("product", "EUR", false);
1129
+ expect(isCommercePriceWorkPending(settings, assignments)).toBe(true);
1130
+
1131
+ shopify();
1132
+ expect(isCommercePriceWorkPending(settings, assignments)).toBe(true);
1133
+ });
1134
+
1135
+ it("reports no pending price work for a control visitor or a non-product page", () => {
1136
+ shopify("collection");
1137
+ expect(isCommercePriceWorkPending(settings, assignments)).toBe(false);
1138
+
1139
+ shopify();
1140
+ const control = [
1141
+ { ...assignments[0], isControl: true },
1142
+ ] as typeof assignments;
1143
+ expect(isCommercePriceWorkPending(settings, control)).toBe(false);
1144
+ });
1145
+ });