@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,78 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ commerceAssignmentResponseFixture,
5
+ commercePriceFixture as commerce,
6
+ installCommerceProductPage,
7
+ priceExperimentsFixture as experiments,
8
+ } from "./test-fixtures/commerce";
9
+
10
+ describe("commerce authorization revocation lifecycle", () => {
11
+ const originalFetch = globalThis.fetch;
12
+ let fetchMock: ReturnType<typeof vi.fn>;
13
+
14
+ beforeEach(() => {
15
+ vi.useFakeTimers();
16
+ vi.resetModules();
17
+ localStorage.clear();
18
+ sessionStorage.clear();
19
+ installCommerceProductPage();
20
+ fetchMock = vi.fn(async (input: RequestInfo | URL) =>
21
+ String(input).endsWith("/cart.js")
22
+ ? Response.json({ token: "cart-runtime?key=runtime-secret" })
23
+ : String(input).includes("/v1/assign")
24
+ ? Response.json(commerceAssignmentResponseFixture)
25
+ : new Response("ok", { status: 202 }));
26
+ globalThis.fetch = fetchMock as typeof fetch;
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.clearAllTimers();
31
+ vi.useRealTimers();
32
+ vi.restoreAllMocks();
33
+ globalThis.fetch = originalFetch;
34
+ document.head.innerHTML = "";
35
+ document.body.innerHTML = "";
36
+ });
37
+
38
+ it("reverts the active price display when forced cart reauthorization fails", async () => {
39
+ const { getDiagnostics, init } = await import("./index");
40
+ init({
41
+ antiFlickerMode: "off",
42
+ commerce,
43
+ endpoint: "https://events.test",
44
+ experiments,
45
+ ingestSignature: "ingest-token",
46
+ shopId: "shop_123",
47
+ trackingStart: "immediate",
48
+ });
49
+ const authorizationCalls = () => fetchMock.mock.calls
50
+ .filter(([url]) => String(url).includes("/v1/assign"));
51
+ await vi.waitFor(() => {
52
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95");
53
+ expect(getDiagnostics().spa.lastShopifyCartSync?.status).toBe("sent");
54
+ });
55
+ const authorizationsBeforeAdd = authorizationCalls().length;
56
+
57
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
58
+ const url = String(input);
59
+ if (url.endsWith("/cart.js")) {
60
+ return Response.json({ token: "rotated-cart?key=rotated-secret" });
61
+ }
62
+ if (url.includes("/v1/assign")) {
63
+ return new Response("unavailable", { status: 503 });
64
+ }
65
+ return new Response("ok", { status: 202 });
66
+ });
67
+
68
+ await window.fetch("/cart/add.js", { method: "POST" });
69
+ await vi.waitFor(() => {
70
+ expect(authorizationCalls()).toHaveLength(authorizationsBeforeAdd + 1);
71
+ expect(getDiagnostics().spa.lastShopifyCartSync).toMatchObject({
72
+ status: "failed",
73
+ reason: "authorization_failed",
74
+ });
75
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
76
+ });
77
+ });
78
+ });
@@ -0,0 +1,630 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ applyCommerceShipping,
5
+ cleanupCommerceShipping,
6
+ isShippingCartEligibilityConfirmed,
7
+ } from "./commerce-shipping";
8
+ import type { Assignment, CommerceShippingSettings } from "./types";
9
+
10
+ const assignments = [
11
+ {
12
+ experimentId: "exp_shipping",
13
+ variationId: "treatment",
14
+ assignmentEpoch: 1,
15
+ mutations: [],
16
+ index: 1,
17
+ },
18
+ ] as Assignment[];
19
+
20
+ function settings(
21
+ treatment: "progress_bar" | "banner" | "none" = "progress_bar",
22
+ copy = "Only {remaining} until free shipping ({threshold})",
23
+ ): CommerceShippingSettings {
24
+ return {
25
+ experiments: [
26
+ {
27
+ experiment_id: "exp_shipping",
28
+ assignment_epoch: 1,
29
+ currency: "EUR",
30
+ variants: {
31
+ treatment: {
32
+ threshold: "50.00",
33
+ treatment,
34
+ progressCopy: copy,
35
+ reachedCopy: "Free shipping reached at {threshold}",
36
+ },
37
+ },
38
+ },
39
+ ],
40
+ };
41
+ }
42
+
43
+ function flush() {
44
+ return new Promise((resolve) => setTimeout(resolve, 0));
45
+ }
46
+
47
+ describe("commerce shipping messaging", () => {
48
+ it("ignores stale cart responses before mutating target eligibility", async () => {
49
+ const targeted = settings();
50
+ targeted.experiments[0].variants.treatment.target = {
51
+ productGids: ["gid://shopify/Product/42"],
52
+ variantGids: [],
53
+ };
54
+ let resolveFirst!: (response: Response) => void;
55
+ let resolveSecond!: (response: Response) => void;
56
+ let cartReads = 0;
57
+ vi.stubGlobal("AbortController", undefined);
58
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
59
+ if (String(input) !== "/cart.js") return new Response("mutation");
60
+ cartReads += 1;
61
+ return new Promise<Response>((resolve) => {
62
+ if (cartReads === 1) resolveFirst = resolve;
63
+ else resolveSecond = resolve;
64
+ });
65
+ });
66
+
67
+ applyCommerceShipping(targeted, assignments);
68
+ await vi.waitFor(() => expect(cartReads).toBe(1));
69
+ await window.fetch("/cart/update.js", { method: "POST" });
70
+ await vi.waitFor(() => expect(cartReads).toBe(2));
71
+ resolveSecond(
72
+ Response.json({
73
+ currency: "EUR",
74
+ items_subtotal_price: 2500,
75
+ items: [{ product_id: 42, variant_id: 7 }],
76
+ }),
77
+ );
78
+ await vi.waitFor(() =>
79
+ expect(isShippingCartEligibilityConfirmed("exp_shipping")).toBe(true),
80
+ );
81
+ resolveFirst(
82
+ Response.json({
83
+ currency: "EUR",
84
+ items_subtotal_price: 2500,
85
+ items: [],
86
+ }),
87
+ );
88
+ await flush();
89
+ expect(isShippingCartEligibilityConfirmed("exp_shipping")).toBe(true);
90
+ });
91
+
92
+ it("notifies once when a later targeted-cart read first confirms eligibility", async () => {
93
+ const targeted = settings();
94
+ targeted.experiments[0].variants.treatment.target = {
95
+ productGids: ["gid://shopify/Product/42"],
96
+ variantGids: [],
97
+ };
98
+ const confirmed = vi.fn();
99
+ let cartReads = 0;
100
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
101
+ if (String(input) !== "/cart.js") return new Response("ok");
102
+ cartReads += 1;
103
+ return Response.json({
104
+ currency: "EUR",
105
+ items_subtotal_price: 2500,
106
+ items: cartReads === 1 ? [] : [{ product_id: 42, variant_id: 7 }],
107
+ });
108
+ });
109
+
110
+ applyCommerceShipping(targeted, assignments, confirmed);
111
+ await vi.waitFor(() => expect(cartReads).toBe(1));
112
+ expect(confirmed).not.toHaveBeenCalled();
113
+ await window.fetch("/cart/update.js", { method: "POST" });
114
+ await vi.waitFor(() => expect(confirmed).toHaveBeenCalledTimes(1));
115
+ });
116
+
117
+ it("confirms targeted eligibility when optional thresholdless copy is empty", async () => {
118
+ const targeted = settings();
119
+ targeted.experiments[0].variants.treatment = {
120
+ ...targeted.experiments[0].variants.treatment,
121
+ threshold: null,
122
+ treatment: "flat_rate",
123
+ bannerCopy: "",
124
+ target: {
125
+ productGids: ["gid://shopify/Product/42"],
126
+ variantGids: [],
127
+ },
128
+ };
129
+ const confirmed = vi.fn();
130
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) =>
131
+ String(input) === "/cart.js"
132
+ ? Response.json({
133
+ currency: "EUR",
134
+ items_subtotal_price: 2500,
135
+ items: [{ product_id: 42, variant_id: 7 }],
136
+ })
137
+ : new Response("mutation", { status: 200 }),
138
+ );
139
+
140
+ applyCommerceShipping(targeted, assignments, confirmed);
141
+ await vi.waitFor(() => expect(confirmed).toHaveBeenCalledTimes(1));
142
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
143
+ });
144
+
145
+ it("confirms cross-currency thresholdless percentage eligibility but fences currency-bound arms", async () => {
146
+ (window as any).Shopify.currency.active = "USD";
147
+ cartCurrency = "USD";
148
+ const crossCurrency = settings("banner");
149
+ crossCurrency.experiments[0].variants.treatment = {
150
+ ...crossCurrency.experiments[0].variants.treatment,
151
+ threshold: null,
152
+ treatment: "percent_off",
153
+ bannerCopy: "Shipping offer active",
154
+ target: {
155
+ productGids: ["gid://shopify/Product/42"],
156
+ variantGids: [],
157
+ },
158
+ };
159
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) =>
160
+ String(input) === "/cart.js"
161
+ ? Response.json({
162
+ currency: "USD",
163
+ items_subtotal_price: 2500,
164
+ items: [{ product_id: 42, variant_id: 7 }],
165
+ })
166
+ : new Response("mutation", { status: 200 }),
167
+ );
168
+
169
+ applyCommerceShipping(crossCurrency, assignments);
170
+ await vi.waitFor(() =>
171
+ expect(isShippingCartEligibilityConfirmed("exp_shipping")).toBe(true),
172
+ );
173
+ expect(
174
+ document.querySelector("[data-apex-shipping]")?.textContent,
175
+ ).toContain("Shipping offer active");
176
+
177
+ for (const currencyBound of [
178
+ { treatment: "percent_off" as const, threshold: "50.00" },
179
+ { treatment: "flat_rate" as const, threshold: null },
180
+ ]) {
181
+ cleanupCommerceShipping();
182
+ fetchMock.mockClear();
183
+ const fenced = settings("banner");
184
+ fenced.experiments[0].variants.treatment = {
185
+ ...fenced.experiments[0].variants.treatment,
186
+ ...currencyBound,
187
+ };
188
+ applyCommerceShipping(fenced, assignments);
189
+ await flush();
190
+ expect(fetchMock).not.toHaveBeenCalled();
191
+ expect(isShippingCartEligibilityConfirmed("exp_shipping")).toBe(false);
192
+ }
193
+ });
194
+ let subtotal: number;
195
+ let cartCurrency: string;
196
+ let fetchMock: ReturnType<typeof vi.fn>;
197
+
198
+ beforeEach(() => {
199
+ document.body.innerHTML = "";
200
+ subtotal = 2500;
201
+ cartCurrency = "EUR";
202
+ Object.assign(window, {
203
+ Shopify: { currency: { active: "EUR" }, routes: { root: "/" } },
204
+ });
205
+ fetchMock = vi.fn(async (input: RequestInfo | URL) => {
206
+ const path = String(input);
207
+ return path === "/cart.js"
208
+ ? Response.json({
209
+ currency: cartCurrency,
210
+ items_subtotal_price: subtotal,
211
+ })
212
+ : new Response("mutation", { status: 200 });
213
+ });
214
+ vi.stubGlobal("fetch", fetchMock);
215
+ });
216
+
217
+ afterEach(() => {
218
+ cleanupCommerceShipping();
219
+ vi.useRealTimers();
220
+ vi.unstubAllGlobals();
221
+ delete (window as any).Shopify;
222
+ });
223
+
224
+ it("renders scoped shipping messaging only when a targeted cart line is present", async () => {
225
+ const scoped = settings();
226
+ scoped.experiments[0]!.variants.treatment!.target = {
227
+ productGids: ["gid://shopify/Product/123"],
228
+ variantGids: [],
229
+ };
230
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) =>
231
+ String(input) === "/cart.js"
232
+ ? Response.json({
233
+ currency: cartCurrency,
234
+ items_subtotal_price: subtotal,
235
+ items: [{ product_id: 999, variant_id: 1000 }],
236
+ })
237
+ : new Response("mutation", { status: 200 }),
238
+ );
239
+ applyCommerceShipping(scoped, assignments);
240
+ await flush();
241
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
242
+
243
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) =>
244
+ String(input) === "/cart.js"
245
+ ? Response.json({
246
+ currency: cartCurrency,
247
+ items_subtotal_price: subtotal,
248
+ items: [{ product_id: 123, variant_id: 456 }],
249
+ })
250
+ : new Response("mutation", { status: 200 }),
251
+ );
252
+ await window.fetch("/cart/change.js", { method: "POST" });
253
+ await flush();
254
+ expect(document.querySelector("[data-apex-shipping]")).not.toBeNull();
255
+
256
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) =>
257
+ String(input) === "/cart.js"
258
+ ? Response.json({
259
+ currency: cartCurrency,
260
+ items_subtotal_price: subtotal,
261
+ items: [{ product_id: 999, variant_id: 1000 }],
262
+ })
263
+ : new Response("mutation", { status: 200 }),
264
+ );
265
+ await window.fetch("/cart/change.js", { method: "POST" });
266
+ await flush();
267
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
268
+ document.body.append(document.createElement("div"));
269
+ await new Promise((resolve) => setTimeout(resolve, 100));
270
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
271
+ });
272
+
273
+ it("renders formatted progress, reaches the final copy, and clamps the fill", async () => {
274
+ applyCommerceShipping(settings(), assignments);
275
+ await flush();
276
+ const expectedRemaining = new Intl.NumberFormat(undefined, {
277
+ style: "currency",
278
+ currency: "EUR",
279
+ }).format(25);
280
+ const expectedThreshold = new Intl.NumberFormat(undefined, {
281
+ style: "currency",
282
+ currency: "EUR",
283
+ }).format(50);
284
+ expect(
285
+ document.querySelector("[data-apex-shipping]")?.textContent,
286
+ ).toContain(
287
+ `Only ${expectedRemaining} until free shipping (${expectedThreshold})`,
288
+ );
289
+ expect(
290
+ (document.querySelector("[data-apex-shipping] div div") as HTMLElement)
291
+ .style.width,
292
+ ).toBe("50%");
293
+ const wrapper = document.querySelector("[data-apex-shipping]")!;
294
+ expect(wrapper.getAttribute("role")).toBe("status");
295
+ expect(wrapper.getAttribute("aria-live")).toBe("polite");
296
+ expect(
297
+ wrapper
298
+ .querySelector('[role="progressbar"]')
299
+ ?.getAttribute("aria-valuenow"),
300
+ ).toBe("50");
301
+
302
+ subtotal = 7500;
303
+ await window.fetch("/cart/change.js", { method: "POST" });
304
+ await flush();
305
+ expect(
306
+ document.querySelector("[data-apex-shipping]")?.textContent,
307
+ ).toContain(`Free shipping reached at ${expectedThreshold}`);
308
+ expect(
309
+ (document.querySelector("[data-apex-shipping] div div") as HTMLElement)
310
+ .style.width,
311
+ ).toBe("100%");
312
+ });
313
+
314
+ it("uses Shopify's locale root for cart reads and mutation refreshes", async () => {
315
+ (window as any).Shopify.routes.root = "/fr/";
316
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) =>
317
+ String(input) === "/fr/cart.js"
318
+ ? Response.json({ currency: "EUR", items_subtotal_price: subtotal })
319
+ : new Response("mutation", { status: 200 }),
320
+ );
321
+ applyCommerceShipping(settings(), assignments);
322
+ await flush();
323
+ expect(fetchMock).toHaveBeenCalledWith(
324
+ "/fr/cart.js",
325
+ expect.objectContaining({ headers: { Accept: "application/json" } }),
326
+ );
327
+
328
+ subtotal = 5000;
329
+ await window.fetch("/fr/cart/change.js", { method: "POST" });
330
+ await flush();
331
+ expect(
332
+ fetchMock.mock.calls.filter(([url]) => url === "/fr/cart.js"),
333
+ ).toHaveLength(2);
334
+ expect(
335
+ document.querySelector("[data-apex-shipping]")?.textContent,
336
+ ).toContain("Free shipping reached");
337
+
338
+ await window.fetch("/cart/change.js", { method: "POST" });
339
+ await flush();
340
+ expect(
341
+ fetchMock.mock.calls.filter(([url]) => url === "/fr/cart.js"),
342
+ ).toHaveLength(2);
343
+ });
344
+
345
+ it("uses each currency's standard minor-unit precision", async () => {
346
+ const jpy = settings();
347
+ jpy.experiments[0].currency = "JPY";
348
+ jpy.experiments[0].variants.treatment.threshold = "5000";
349
+ (window as any).Shopify.currency.active = "JPY";
350
+ cartCurrency = "JPY";
351
+ applyCommerceShipping(jpy, assignments);
352
+ await flush();
353
+ expect(
354
+ (document.querySelector("[data-apex-shipping] div div") as HTMLElement)
355
+ .style.width,
356
+ ).toBe("50%");
357
+ expect(
358
+ document.querySelector("[data-apex-shipping]")?.textContent,
359
+ ).toContain(
360
+ new Intl.NumberFormat(undefined, {
361
+ style: "currency",
362
+ currency: "JPY",
363
+ }).format(2500),
364
+ );
365
+
366
+ cleanupCommerceShipping();
367
+ const kwd = settings();
368
+ kwd.experiments[0].currency = "KWD";
369
+ kwd.experiments[0].variants.treatment.threshold = "50.125";
370
+ (window as any).Shopify.currency.active = "KWD";
371
+ cartCurrency = "KWD";
372
+ subtotal = 25_063;
373
+ applyCommerceShipping(kwd, assignments);
374
+ await flush();
375
+ expect(
376
+ document.querySelector("[data-apex-shipping]")?.textContent,
377
+ ).toContain(
378
+ new Intl.NumberFormat(undefined, {
379
+ style: "currency",
380
+ currency: "KWD",
381
+ }).format(50.125),
382
+ );
383
+ });
384
+
385
+ it("renders banner copy without a progress track", async () => {
386
+ applyCommerceShipping(settings("banner", "Spend {remaining}"), assignments);
387
+ await flush();
388
+ const wrapper = document.querySelector("[data-apex-shipping]")!;
389
+ expect(wrapper.textContent).toContain("Spend");
390
+ expect(wrapper.children).toHaveLength(1);
391
+ });
392
+
393
+ it("keeps messaging fail-soft when AbortController is unavailable", async () => {
394
+ vi.stubGlobal("AbortController", undefined);
395
+ applyCommerceShipping(settings("banner"), assignments);
396
+ await flush();
397
+ expect(
398
+ document.querySelector("[data-apex-shipping]")?.textContent,
399
+ ).toContain("Only");
400
+ });
401
+
402
+ it("is inert for currency mismatch and none treatments", async () => {
403
+ (window as any).Shopify.currency.active = "USD";
404
+ applyCommerceShipping(settings(), assignments);
405
+ await flush();
406
+ expect(fetchMock).not.toHaveBeenCalled();
407
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
408
+
409
+ (window as any).Shopify.currency.active = "EUR";
410
+ applyCommerceShipping(settings("none"), assignments);
411
+ await flush();
412
+ expect(fetchMock).not.toHaveBeenCalled();
413
+
414
+ // A control assignment now reads the cart once to confirm exposure
415
+ // eligibility (currency/target parity with its treatment sibling), but
416
+ // still renders nothing.
417
+ applyCommerceShipping(settings(), [{ ...assignments[0], isControl: true }]);
418
+ await flush();
419
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
420
+ });
421
+
422
+ it("fails closed when the storefront assignment epoch differs from the shipping plane", async () => {
423
+ applyCommerceShipping(settings(), [
424
+ { ...assignments[0], assignmentEpoch: 2 },
425
+ ]);
426
+ await flush();
427
+
428
+ expect(fetchMock).not.toHaveBeenCalled();
429
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
430
+ });
431
+
432
+ it("keeps legacy shipping planes without an epoch compatible during rollout", async () => {
433
+ const legacy = settings();
434
+ legacy.experiments[0].assignment_epoch = undefined;
435
+
436
+ applyCommerceShipping(legacy, assignments);
437
+ await flush();
438
+
439
+ expect(document.querySelector("[data-apex-shipping]")).not.toBeNull();
440
+ });
441
+
442
+ it("fails closed for cart-currency drift and invalid cart subtotals", async () => {
443
+ cartCurrency = "USD";
444
+ applyCommerceShipping(settings(), assignments);
445
+ await flush();
446
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
447
+
448
+ cartCurrency = "EUR";
449
+ for (const invalidSubtotal of [-1, 12.5, Number.MAX_SAFE_INTEGER + 1]) {
450
+ subtotal = invalidSubtotal;
451
+ applyCommerceShipping(settings(), assignments);
452
+ await flush();
453
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
454
+ }
455
+ });
456
+
457
+ it("fails closed for unsafe thresholds or multiple active shipping assignments", async () => {
458
+ const unsafe = settings();
459
+ unsafe.experiments[0].variants.treatment.threshold = "999999999999999.99";
460
+ applyCommerceShipping(unsafe, assignments);
461
+ await flush();
462
+ expect(fetchMock).not.toHaveBeenCalled();
463
+
464
+ const invalidJpy = settings();
465
+ invalidJpy.experiments[0].currency = "JPY";
466
+ invalidJpy.experiments[0].variants.treatment.threshold = "50.01";
467
+ (window as any).Shopify.currency.active = "JPY";
468
+ applyCommerceShipping(invalidJpy, assignments);
469
+ await flush();
470
+ expect(fetchMock).not.toHaveBeenCalled();
471
+
472
+ const ambiguous = settings();
473
+ ambiguous.experiments.push({
474
+ ...ambiguous.experiments[0],
475
+ experiment_id: "exp_shipping_2",
476
+ });
477
+ applyCommerceShipping(ambiguous, [
478
+ assignments[0],
479
+ { ...assignments[0], experimentId: "exp_shipping_2" },
480
+ ]);
481
+ await flush();
482
+ expect(fetchMock).not.toHaveBeenCalled();
483
+ });
484
+
485
+ it("prepends to a drawer candidate and otherwise uses a fixed banner", async () => {
486
+ document.body.innerHTML = "<cart-drawer><p>Cart</p></cart-drawer>";
487
+ applyCommerceShipping(settings("banner"), assignments);
488
+ await flush();
489
+ expect(document.querySelector("cart-drawer")?.firstElementChild).toBe(
490
+ document.querySelector("[data-apex-shipping]"),
491
+ );
492
+
493
+ cleanupCommerceShipping();
494
+ document.body.innerHTML = "";
495
+ applyCommerceShipping(settings("banner"), assignments);
496
+ await flush();
497
+ expect(
498
+ (document.querySelector("[data-apex-shipping]") as HTMLElement).style
499
+ .position,
500
+ ).toBe("fixed");
501
+ });
502
+
503
+ it("re-reads after cart mutations without replacing their response", async () => {
504
+ applyCommerceShipping(settings(), assignments);
505
+ await flush();
506
+ subtotal = 5000;
507
+ const response = await window.fetch("/cart/add.js", { method: "POST" });
508
+ expect(await response.text()).toBe("mutation");
509
+ await flush();
510
+ expect(
511
+ fetchMock.mock.calls.filter(([url]) => url === "/cart.js"),
512
+ ).toHaveLength(2);
513
+ expect(
514
+ document.querySelector("[data-apex-shipping]")?.textContent,
515
+ ).toContain("Free shipping reached");
516
+ });
517
+
518
+ it("ignores an older cart read that resolves after a mutation refresh", async () => {
519
+ let cartReads = 0;
520
+ let resolveOld: ((response: Response) => void) | undefined;
521
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
522
+ if (String(input) !== "/cart.js") {
523
+ return new Response("mutation", { status: 200 });
524
+ }
525
+ cartReads += 1;
526
+ if (cartReads === 1) {
527
+ return new Promise<Response>((resolve) => {
528
+ resolveOld = resolve;
529
+ });
530
+ }
531
+ return Response.json({ currency: "EUR", items_subtotal_price: 5000 });
532
+ });
533
+ applyCommerceShipping(settings(), assignments);
534
+ await window.fetch("/cart/change.js", { method: "POST" });
535
+ await flush();
536
+ expect(
537
+ document.querySelector("[data-apex-shipping]")?.textContent,
538
+ ).toContain("Free shipping reached");
539
+
540
+ resolveOld?.(
541
+ Response.json({ currency: "EUR", items_subtotal_price: 2500 }),
542
+ );
543
+ await flush();
544
+ expect(
545
+ document.querySelector("[data-apex-shipping]")?.textContent,
546
+ ).toContain("Free shipping reached");
547
+ });
548
+
549
+ it("restores the original fetch and removes its element on cleanup", async () => {
550
+ const original = window.fetch;
551
+ applyCommerceShipping(settings(), assignments);
552
+ expect(window.fetch).not.toBe(original);
553
+ await flush();
554
+ cleanupCommerceShipping();
555
+ expect(window.fetch).toBe(original);
556
+ expect(document.querySelector("[data-apex-shipping]")).toBeNull();
557
+ });
558
+
559
+ it("does not clobber a fetch wrapper installed after Apex", () => {
560
+ applyCommerceShipping(settings(), assignments);
561
+ const apexFetch = window.fetch;
562
+ const laterWrapper = vi.fn((...args: Parameters<typeof fetch>) =>
563
+ apexFetch(...args),
564
+ );
565
+ window.fetch = laterWrapper;
566
+ cleanupCommerceShipping();
567
+ expect(window.fetch).toBe(laterWrapper);
568
+ });
569
+
570
+ it("remains additive when AbortController is unavailable", async () => {
571
+ vi.stubGlobal("AbortController", undefined);
572
+ applyCommerceShipping(settings(), assignments);
573
+ await flush();
574
+ expect(document.querySelector("[data-apex-shipping]")).not.toBeNull();
575
+ });
576
+
577
+ it("renders merchant markup as text only", async () => {
578
+ applyCommerceShipping(
579
+ settings("banner", "<img src=x onerror=alert(1)> {remaining}"),
580
+ assignments,
581
+ );
582
+ await flush();
583
+ const wrapper = document.querySelector("[data-apex-shipping]")!;
584
+ expect(wrapper.textContent).toContain("<img src=x onerror=alert(1)>");
585
+ expect(wrapper.querySelector("img")).toBeNull();
586
+ });
587
+
588
+ it("re-reads the cart when the shared drawer detector observes an open", async () => {
589
+ vi.useFakeTimers();
590
+ document.body.innerHTML = '<cart-drawer aria-hidden="true"></cart-drawer>';
591
+ const drawer = document.querySelector("cart-drawer") as HTMLElement;
592
+ drawer.getBoundingClientRect = () => ({
593
+ width: 300,
594
+ height: 500,
595
+ top: 0,
596
+ right: 300,
597
+ bottom: 500,
598
+ left: 0,
599
+ x: 0,
600
+ y: 0,
601
+ toJSON: () => ({}),
602
+ });
603
+ applyCommerceShipping(settings(), assignments);
604
+ await Promise.resolve();
605
+ drawer.setAttribute("aria-hidden", "false");
606
+ await Promise.resolve();
607
+ vi.advanceTimersByTime(80);
608
+ await Promise.resolve();
609
+ await Promise.resolve();
610
+ expect(
611
+ fetchMock.mock.calls.filter(([url]) => url === "/cart.js").length,
612
+ ).toBeGreaterThanOrEqual(2);
613
+ });
614
+
615
+ it("reinstalls the message when a theme replaces the cart drawer", async () => {
616
+ document.body.innerHTML = "<cart-drawer></cart-drawer>";
617
+ applyCommerceShipping(settings("banner"), assignments);
618
+ await flush();
619
+ document.querySelector("cart-drawer")?.remove();
620
+ document.body.insertAdjacentHTML(
621
+ "beforeend",
622
+ "<cart-drawer data-replaced></cart-drawer>",
623
+ );
624
+ await new Promise((resolve) => setTimeout(resolve, 90));
625
+ expect(
626
+ document.querySelector("cart-drawer[data-replaced] [data-apex-shipping]"),
627
+ ).not.toBeNull();
628
+ expect(document.querySelectorAll("[data-apex-shipping]")).toHaveLength(1);
629
+ });
630
+ });