@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
@@ -118,6 +118,62 @@ describe("persisted assignment store", () => {
118
118
  expect(localStorage.getItem("drip_assignments_v1")).toBeNull();
119
119
  });
120
120
 
121
+ it("restores a persisted redirect control assignment after navigation off the source URL", async () => {
122
+ localStorage.setItem(
123
+ "drip_assignments_v1",
124
+ JSON.stringify({ exp_1: ["exp_1_control", 1, 123] }),
125
+ );
126
+ const { evaluate } = await import("./index");
127
+ const redirect = experiment("exp_1", [1, 0]);
128
+ redirect.experimentType = "redirect";
129
+ redirect.targeting = {
130
+ url: [{ type: "simple", pattern: "/products/source" }],
131
+ };
132
+ redirect.variations[1].redirectUrl = "https://store.test/landing";
133
+
134
+ expect(evaluate([redirect], {
135
+ url: "https://store.test/checkout",
136
+ userId: "visitor-1",
137
+ })).toEqual([
138
+ expect.objectContaining({
139
+ experimentId: "exp_1",
140
+ variationId: "exp_1_control",
141
+ }),
142
+ ]);
143
+ });
144
+
145
+ it("drops a persisted redirect control assignment when its segment gate fails", async () => {
146
+ localStorage.setItem(
147
+ "drip_assignments_v1",
148
+ JSON.stringify({ exp_1: ["exp_1_control", 1, 123] }),
149
+ );
150
+ const { evaluate } = await import("./index");
151
+ const redirect = experiment("exp_1", [1, 0]);
152
+ redirect.experimentType = "redirect";
153
+ redirect.targeting = {
154
+ url: [{ type: "simple", pattern: "/products/source" }],
155
+ segmentRules: [{
156
+ id: "vip-segment",
157
+ rules: {
158
+ version: 1,
159
+ operator: "and",
160
+ conditions: [{
161
+ signal: "customer.tier",
162
+ operator: "equals",
163
+ value: "vip",
164
+ }],
165
+ },
166
+ }],
167
+ };
168
+ redirect.variations[1].redirectUrl = "https://store.test/landing";
169
+
170
+ expect(evaluate([redirect], {
171
+ attributes: { "customer.tier": "standard" },
172
+ url: "https://store.test/checkout",
173
+ userId: "visitor-1",
174
+ })).toEqual([]);
175
+ });
176
+
121
177
  it("does not restore when non-page audience targeting is no longer eligible", async () => {
122
178
  localStorage.setItem(
123
179
  "drip_assignments_v1",
@@ -1,4 +1,7 @@
1
1
  import type { Experiment, Variation } from "./types";
2
+ import { decodeCompactStore } from "./compact-store";
3
+ import { decodePersistedAssignments, type PersistedAssignmentRecord } from "./persistence-decoders";
4
+ export { decodePersistedAssignments, type PersistedAssignmentRecord } from "./persistence-decoders";
2
5
 
3
6
  const STORAGE_KEY = "drip_assignments_v1"; // gitleaks:allow — public storage key name, not a credential
4
7
  const COOKIE_NAME = "drip_assignments_v1";
@@ -6,12 +9,6 @@ const MAX_ENTRIES = 80;
6
9
  const MAX_COOKIE_ENTRIES = 20;
7
10
  const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
8
11
 
9
- export type PersistedAssignmentRecord = {
10
- variationId: string;
11
- epoch: number;
12
- assignedAt: number;
13
- };
14
-
15
12
  type CompactRecord = [string, number, number];
16
13
  type CompactStore = Record<string, CompactRecord>;
17
14
 
@@ -72,28 +69,8 @@ function clearCookie(): void {
72
69
  }
73
70
  }
74
71
 
75
- function parseStore(raw: string | null): CompactStore {
76
- if (!raw) return {};
77
- try {
78
- const parsed = JSON.parse(raw) as unknown;
79
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
80
- const out: CompactStore = {};
81
- for (const [experimentId, value] of Object.entries(parsed)) {
82
- if (
83
- typeof experimentId !== "string" ||
84
- !Array.isArray(value) ||
85
- typeof value[0] !== "string" ||
86
- typeof value[1] !== "number" ||
87
- typeof value[2] !== "number"
88
- ) {
89
- continue;
90
- }
91
- out[experimentId] = [value[0], value[1], value[2]];
92
- }
93
- return out;
94
- } catch {
95
- return {};
96
- }
72
+ export function decodeCompactAssignmentStore(raw: string | null): CompactStore {
73
+ return decodeCompactStore(raw, 3) as CompactStore;
97
74
  }
98
75
 
99
76
  function serializeStore(store: CompactStore, maxEntries = MAX_ENTRIES): string {
@@ -109,15 +86,7 @@ function serializeStore(store: CompactStore, maxEntries = MAX_ENTRIES): string {
109
86
  }
110
87
 
111
88
  export function readPersistedAssignments(): Record<string, PersistedAssignmentRecord> {
112
- const compact = {
113
- ...parseStore(readCookie()),
114
- ...parseStore(readLocalStorage()),
115
- };
116
- const out: Record<string, PersistedAssignmentRecord> = {};
117
- for (const [experimentId, [variationId, epoch, assignedAt]] of Object.entries(compact)) {
118
- out[experimentId] = { variationId, epoch, assignedAt };
119
- }
120
- return out;
89
+ return decodePersistedAssignments(readLocalStorage(), readCookie());
121
90
  }
122
91
 
123
92
  export function writePersistedAssignments(
@@ -203,6 +172,6 @@ export const __assignmentStoreInternals = {
203
172
  STORAGE_KEY,
204
173
  COOKIE_NAME,
205
174
  assignmentEpoch,
206
- parseStore,
175
+ parseStore: decodeCompactAssignmentStore,
207
176
  serializeStore,
208
177
  };
package/src/bucket.ts CHANGED
@@ -1,18 +1,3 @@
1
- export function inTrafficAllocation(hashValue: number, allocation = 1): boolean {
2
- if (allocation <= 0) return false;
3
- if (allocation >= 1) return true;
4
- return hashValue < allocation;
5
- }
6
-
7
- export function chooseVariation(hashValue: number, weights: number[]): number {
8
- if (!weights.length) return -1;
9
- const total = weights.reduce((sum, w) => sum + (w > 0 ? w : 0), 0);
10
- if (total <= 0) return 0;
11
- const n = hashValue * total;
12
- let acc = 0;
13
- for (let i = 0; i < weights.length; i++) {
14
- acc += weights[i] > 0 ? weights[i] : 0;
15
- if (n < acc) return i;
16
- }
17
- return weights.length - 1;
18
- }
1
+ // The bucketing kernel lives in @drip/shared-runtime/bucketing (#2476). This
2
+ // module keeps re-exporting it so SDK call sites stay unchanged.
3
+ export { chooseVariation, inTrafficAllocation } from "@drip/shared-runtime/bucketing";
@@ -19,6 +19,7 @@ describe("commerce analytics lifecycle", () => {
19
19
  delete (globalThis as any).Apex;
20
20
  localStorage.clear();
21
21
  sessionStorage.clear();
22
+ document.cookie = "drip_assignments_v1=; Max-Age=0; Path=/; SameSite=Lax";
22
23
  installCommerceProductPage();
23
24
  fetchMock = vi.fn(async (input: RequestInfo | URL) =>
24
25
  String(input).endsWith("/cart.js")
@@ -29,7 +30,9 @@ describe("commerce analytics lifecycle", () => {
29
30
  globalThis.fetch = fetchMock as typeof fetch;
30
31
  });
31
32
 
32
- afterEach(() => {
33
+ afterEach(async () => {
34
+ const { cleanupCommerceShipping } = await import("./commerce-shipping");
35
+ cleanupCommerceShipping();
33
36
  vi.clearAllTimers();
34
37
  vi.useRealTimers();
35
38
  vi.restoreAllMocks();
@@ -157,7 +160,7 @@ describe("commerce analytics lifecycle", () => {
157
160
  });
158
161
  });
159
162
 
160
- it("keeps an off-target resurrected pin in the price plane only", async () => {
163
+ it("keeps an off-target persisted price pin attributable without rendering it", async () => {
161
164
  delete (window as Window & { ShopifyAnalytics?: unknown }).ShopifyAnalytics;
162
165
  document.body.insertAdjacentHTML(
163
166
  "beforeend",
@@ -202,6 +205,10 @@ describe("commerce analytics lifecycle", () => {
202
205
  })
203
206
  : new Response("ok", { status: 202 }));
204
207
  globalThis.fetch = fetchMock as typeof fetch;
208
+ localStorage.setItem(
209
+ "drip_assignments_v1",
210
+ JSON.stringify({ exp_price: ["treatment", 1, 123] }),
211
+ );
205
212
  const onExperimentViewed = vi.fn();
206
213
  const { flush, getAssignments, init, trackRevenue } = await import("./index");
207
214
 
@@ -222,24 +229,44 @@ describe("commerce analytics lifecycle", () => {
222
229
  });
223
230
  expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
224
231
  expect(document.querySelector(".companion")?.textContent).toBe("Control companion");
225
- expect(getAssignments()).toEqual([]);
232
+ expect(getAssignments()).toEqual([
233
+ expect.objectContaining({
234
+ experimentId: "exp_price",
235
+ variationId: "treatment",
236
+ pageActive: false,
237
+ }),
238
+ ]);
226
239
  expect(onExperimentViewed).not.toHaveBeenCalled();
227
240
  expect(localStorage.getItem("drip_exposures")).toBeNull();
228
241
 
229
242
  trackRevenue(19.99, { orderId: "off-target-order" });
230
243
  await flush();
231
244
  const trackedEvents = fetchMock.mock.calls
232
- .filter(([url]) => String(url) === "https://events.test/events")
245
+ .filter(([url]) => String(url) === "https://events.test/v1/events")
233
246
  .flatMap(([, options]) => JSON.parse(String(options?.body)).events);
234
247
  expect(trackedEvents.some((event: { type: string }) =>
235
- event.type === "experiment_viewed" || event.type === "goal"
248
+ event.type === "experiment_viewed"
236
249
  )).toBe(false);
250
+ expect(trackedEvents).toContainEqual(expect.objectContaining({
251
+ type: "goal",
252
+ experimentId: "exp_price",
253
+ variationId: "treatment",
254
+ data: expect.objectContaining({
255
+ goalId: "__revenue__",
256
+ revenue: 19.99,
257
+ orderId: "off-target-order",
258
+ }),
259
+ }));
260
+ expect(JSON.parse(localStorage.getItem("drip_assignments_v1") ?? "{}"))
261
+ .toHaveProperty("exp_price", ["treatment", 1, 123]);
237
262
  const cartUpdate = fetchMock.mock.calls.find(([url]) => url === "/cart/update.js");
238
- expect(JSON.parse(String(cartUpdate?.[1]?.body)).attributes).toMatchObject({
239
- apex_assignments: JSON.stringify([{ e: "exp_price", v: "treatment", ep: 1 }]),
263
+ const cartAttributes = JSON.parse(String(cartUpdate?.[1]?.body)).attributes;
264
+ expect(cartAttributes).toMatchObject({
240
265
  apex_assignment_authorization: pinnedToken,
241
266
  });
242
-
267
+ expect(JSON.parse(cartAttributes.apex_assignments)).toEqual([
268
+ { e: "exp_price", v: "treatment", ep: 1 },
269
+ ]);
243
270
  });
244
271
 
245
272
  it("keeps off-target offer pins charge-only while applying targeted offers", async () => {
@@ -318,8 +345,12 @@ describe("commerce analytics lifecycle", () => {
318
345
  })
319
346
  : new Response("ok", { status: 202 }));
320
347
  globalThis.fetch = fetchMock as typeof fetch;
348
+ localStorage.setItem(
349
+ "drip_assignments_v1",
350
+ JSON.stringify({ exp_offer_off_target: ["off_target_treatment", 1, 123] }),
351
+ );
321
352
  const onExperimentViewed = vi.fn();
322
- const { getAssignments, init } = await import("./index");
353
+ const { flush, getAssignments, init, trackRevenue } = await import("./index");
323
354
 
324
355
  init({
325
356
  antiFlickerMode: "off",
@@ -350,23 +381,52 @@ describe("commerce analytics lifecycle", () => {
350
381
  .toBe("Off-target control");
351
382
  expect(document.querySelector(".targeted-offer")?.textContent)
352
383
  .toBe("Targeted treatment");
353
- expect(getAssignments()).toEqual([
354
- expect.objectContaining({ experimentId: "exp_offer_targeted" }),
355
- ]);
384
+ expect(getAssignments()).toEqual(expect.arrayContaining([
385
+ expect.objectContaining({
386
+ experimentId: "exp_offer_off_target",
387
+ variationId: "off_target_treatment",
388
+ pageActive: false,
389
+ }),
390
+ expect.objectContaining({
391
+ experimentId: "exp_offer_targeted",
392
+ variationId: "targeted_treatment",
393
+ pageActive: true,
394
+ }),
395
+ ]));
356
396
  expect(onExperimentViewed).toHaveBeenCalledTimes(1);
357
397
  expect(onExperimentViewed).toHaveBeenCalledWith(
358
398
  expect.objectContaining({ experimentId: "exp_offer_targeted" }),
359
399
  );
360
400
  expect(JSON.parse(localStorage.getItem("drip_assignments_v1") ?? "{}"))
361
- .not.toHaveProperty("exp_offer_off_target");
401
+ .toHaveProperty("exp_offer_off_target", ["off_target_treatment", 1, expect.any(Number)]);
362
402
  expect(JSON.parse(localStorage.getItem("drip_assignments_v1") ?? "{}"))
363
403
  .toHaveProperty("exp_offer_targeted");
404
+ trackRevenue(24.99, { orderId: "off-target-offer-order" });
405
+ await flush();
406
+ const trackedEvents = fetchMock.mock.calls
407
+ .filter(([url]) => String(url) === "https://events.test/v1/events")
408
+ .flatMap(([, options]) => JSON.parse(String(options?.body)).events);
409
+ expect(trackedEvents).toContainEqual(expect.objectContaining({
410
+ type: "goal",
411
+ experimentId: "exp_offer_off_target",
412
+ variationId: "off_target_treatment",
413
+ data: expect.objectContaining({
414
+ goalId: "__revenue__",
415
+ revenue: 24.99,
416
+ orderId: "off-target-offer-order",
417
+ }),
418
+ }));
364
419
  const cartUpdate = fetchMock.mock.calls.find(([url]) => url === "/cart/update.js");
365
- expect(JSON.parse(JSON.parse(String(cartUpdate?.[1]?.body)).attributes.apex_assignments))
366
- .toEqual(expect.arrayContaining([
420
+ const stampedAssignments = JSON.parse(
421
+ JSON.parse(String(cartUpdate?.[1]?.body)).attributes.apex_assignments,
422
+ );
423
+ expect(stampedAssignments).toEqual(expect.arrayContaining([
367
424
  { e: "exp_offer_off_target", v: "off_target_treatment", ep: 1 },
368
425
  { e: "exp_offer_targeted", v: "targeted_treatment", ep: 1 },
369
426
  ]));
427
+ expect(stampedAssignments.filter(
428
+ (assignment: { e: string }) => assignment.e === "exp_offer_off_target",
429
+ )).toHaveLength(1);
370
430
  await expect(window.fetch("/cart/add.js", { method: "POST" }))
371
431
  .resolves.toMatchObject({ status: 202 });
372
432
  });
@@ -54,7 +54,11 @@ describe("commerce config lifecycle", () => {
54
54
  }) as typeof fetch;
55
55
  });
56
56
 
57
- afterEach(() => {
57
+ afterEach(async () => {
58
+ const { cleanupCommercePrice } = await import("./commerce-price");
59
+ const { cleanupCommerceShipping } = await import("./commerce-shipping");
60
+ cleanupCommercePrice();
61
+ cleanupCommerceShipping();
58
62
  vi.restoreAllMocks();
59
63
  delete (globalThis as typeof globalThis & { __drip?: unknown }).__drip;
60
64
  globalThis.fetch = originalFetch;
@@ -211,6 +215,82 @@ describe("commerce config lifecycle", () => {
211
215
  });
212
216
  });
213
217
 
218
+ it("cannot reassert a deactivated Page price while a non-commerce assignment remains", async () => {
219
+ history.replaceState({}, "", "/products/current-product");
220
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
221
+ const url = String(input);
222
+ if (url.endsWith("/cart.js")) {
223
+ return Response.json({ token: "cart-page-price?key=runtime-secret" });
224
+ }
225
+ if (url.includes("/v1/assign")) {
226
+ return Response.json(commerceAssignmentResponseFixture);
227
+ }
228
+ return new Response("ok", { status: 200 });
229
+ }) as typeof fetch;
230
+ const targetedExperiments = [
231
+ {
232
+ ...experiments[0]!,
233
+ targeting: {
234
+ pageRules: [{
235
+ id: "page_price_product",
236
+ includeRules: [{ type: "simple" as const, pattern: "/products/*" }],
237
+ trigger: { deactivationMode: "reset" as const },
238
+ }],
239
+ },
240
+ variations: [{
241
+ ...experiments[0]!.variations[0]!,
242
+ js: "window.__pricePageApplied=true;",
243
+ }],
244
+ },
245
+ {
246
+ id: "exp_non_commerce_sibling",
247
+ status: "running" as const,
248
+ trafficAllocation: 1,
249
+ variations: [{
250
+ id: "var_non_commerce_sibling",
251
+ weight: 1,
252
+ mutations: [],
253
+ js: "window.__nonCommerceSiblingApplied=true;",
254
+ }],
255
+ },
256
+ ];
257
+ const disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
258
+ const { getAssignments, init, reInit } = await import("./index");
259
+
260
+ init({
261
+ antiFlickerMode: "off",
262
+ commerce,
263
+ endpoint: "https://events.test",
264
+ experiments: targetedExperiments,
265
+ ingestSignature: "ingest-token",
266
+ shopId: "shop_123",
267
+ trackingStart: "immediate",
268
+ userId: "visitor_page_price",
269
+ });
270
+ await waitForPrice("€1.149,95");
271
+
272
+ history.replaceState({}, "", "/collections/all");
273
+ reInit();
274
+ expect(getAssignments()).toEqual(expect.arrayContaining([
275
+ expect.objectContaining({
276
+ experimentId: "exp_price",
277
+ pageActive: false,
278
+ }),
279
+ expect.objectContaining({
280
+ experimentId: "exp_non_commerce_sibling",
281
+ pageActive: true,
282
+ }),
283
+ ]));
284
+ await waitForPrice("€1.299,00");
285
+ expect(disconnect).toHaveBeenCalled();
286
+
287
+ const price = document.querySelector(".price")!;
288
+ price.textContent = "€1.399,00";
289
+ await Promise.resolve();
290
+ await Promise.resolve();
291
+ expect(price.textContent).toBe("€1.399,00");
292
+ });
293
+
214
294
  it("clears a stale shipping authorization after the Worker confirms the assignment disappeared", async () => {
215
295
  sessionStorage.setItem("drip_cart_attr", JSON.stringify({
216
296
  assignments: [["exp_shipping", "treatment", 6]],
@@ -0,0 +1,133 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { applyMutations, reassertNonStructuralMutations } from "./mutate";
4
+ import { applyMutationWithWait } from "./wait";
5
+
6
+ describe("content treatment price preservation", () => {
7
+ beforeEach(() => { document.body.innerHTML = ""; });
8
+
9
+ function apply(text: string, replacement: string, options: Record<string, unknown> = {}) {
10
+ document.body.innerHTML = `<div class="target">${text}</div>`;
11
+ const onViolation = vi.fn();
12
+ applyMutations([{ selector: ".target", action: "text", text: replacement }], {
13
+ preservePrices: true,
14
+ onPricePreservationViolation: onViolation,
15
+ ...options,
16
+ });
17
+ return { element: document.querySelector(".target")!, onViolation };
18
+ }
19
+
20
+ it("applies content when every displayed price survives", () => {
21
+ const result = apply("Now $1,299.95", "Only $1,299.95 today");
22
+ expect(result.element.textContent).toBe("Only $1,299.95 today");
23
+ expect(result.onViolation).not.toHaveBeenCalled();
24
+ });
25
+
26
+ it("reverts and reports content that drops a price", () => {
27
+ const result = apply("Now €1.299,95", "On sale");
28
+ expect(result.element.textContent).toBe("Now €1.299,95");
29
+ expect(result.onViolation).toHaveBeenCalledWith(expect.objectContaining({
30
+ selector: ".target",
31
+ originalPriceTokens: [expect.objectContaining({ raw: "€1.299,95" })],
32
+ }));
33
+ });
34
+
35
+ it("reverts a changed price value", () => {
36
+ expect(apply("£99.00", "£79.00").element.textContent).toBe("£99.00");
37
+ });
38
+
39
+ it.each([
40
+ ["Now 1.299,95 \u20ac", "1.299,95 \u20ac"],
41
+ ["Now CHF 99.00", "CHF 99.00"],
42
+ ["Now 99.00 EUR", "99.00 EUR"],
43
+ ["Now 99 kr", "99 kr"],
44
+ ])("reverts and reports dropped suffix/code price %s", (text, raw) => {
45
+ const result = apply(text, "On sale");
46
+ expect(result.element.textContent).toBe(text);
47
+ expect(result.onViolation).toHaveBeenCalledWith(expect.objectContaining({
48
+ originalPriceTokens: [expect.objectContaining({ raw })],
49
+ }));
50
+ });
51
+
52
+ it("does not treat bare numbers as money", () => {
53
+ const result = apply("Model SKU 1299", "Model SKU 1200");
54
+ expect(result.element.textContent).toBe("Model SKU 1200");
55
+ expect(result.onViolation).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it("keeps a rejected mutation blocked during reassertion", () => {
59
+ const mutation = { selector: ".target", action: "text" as const, text: "On sale" };
60
+ document.body.innerHTML = '<div class="target">Now $19.00</div>';
61
+ applyMutations([mutation], { preservePrices: true });
62
+
63
+ reassertNonStructuralMutations([mutation]);
64
+
65
+ expect(document.querySelector(".target")?.textContent).toBe("Now $19.00");
66
+ });
67
+
68
+ it("reverts and reports setText content that drops a price", () => {
69
+ document.body.innerHTML = '<div class="target">Now $29.00</div>';
70
+ const onViolation = vi.fn();
71
+ applyMutations([{ selector: ".target", action: "setText", text: "On sale" }], {
72
+ preservePrices: true,
73
+ onPricePreservationViolation: onViolation,
74
+ });
75
+
76
+ expect(document.querySelector(".target")?.textContent).toBe("Now $29.00");
77
+ expect(onViolation).toHaveBeenCalledWith(expect.objectContaining({
78
+ selector: ".target",
79
+ originalPriceTokens: [expect.objectContaining({ raw: "$29.00" })],
80
+ }));
81
+ });
82
+
83
+ it("guards setText content applied through the wait path", () => {
84
+ document.body.innerHTML = '<div class="target">Now $39.00</div>';
85
+ const onViolation = vi.fn();
86
+ applyMutationWithWait(
87
+ { selector: ".target", action: "setText", text: "On sale" },
88
+ 0,
89
+ undefined,
90
+ undefined,
91
+ [],
92
+ undefined,
93
+ 0,
94
+ { enabled: true, onViolation },
95
+ );
96
+
97
+ expect(document.querySelector(".target")?.textContent).toBe("Now $39.00");
98
+ expect(onViolation).toHaveBeenCalledWith(expect.objectContaining({
99
+ selector: ".target",
100
+ originalPriceTokens: [expect.objectContaining({ raw: "$39.00" })],
101
+ }));
102
+ });
103
+
104
+ it("resolves observer reveal accounting immediately when the guard rejects", () => {
105
+ document.body.innerHTML = '<div class="target">Now $39.00</div>';
106
+ const onApplied = vi.fn();
107
+ const onMiss = vi.fn();
108
+ applyMutationWithWait(
109
+ { selector: ".target", action: "setText", text: "On sale" },
110
+ 0,
111
+ onApplied,
112
+ onMiss,
113
+ [],
114
+ undefined,
115
+ 0,
116
+ { enabled: true, onViolation: vi.fn() },
117
+ );
118
+
119
+ expect(onMiss).toHaveBeenCalledTimes(1);
120
+ expect(onApplied).not.toHaveBeenCalled();
121
+ expect(document.querySelector(".target")?.textContent).toBe("Now $39.00");
122
+ });
123
+
124
+ it("preserves multiple currencies and values", () => {
125
+ const result = apply("¥1000 / ₹1,000 / $10", "Compare ¥1000, ₹1,000 and $10");
126
+ expect(result.element.textContent).toContain("Compare");
127
+ });
128
+
129
+ it("keeps the no-price fast path and intentional price tests exempt", () => {
130
+ expect(apply("Hello", "Goodbye").element.textContent).toBe("Goodbye");
131
+ expect(apply("$10", "$12", { priceTestExempt: true }).element.textContent).toBe("$12");
132
+ });
133
+ });
@@ -87,6 +87,103 @@ describe("commerce price display swap", () => {
87
87
  );
88
88
  });
89
89
 
90
+ it.each([
91
+ {
92
+ currency: "JPY",
93
+ published: 129900,
94
+ treatment: "1149",
95
+ before: "¥1,299",
96
+ after: "¥1,149",
97
+ },
98
+ {
99
+ currency: "BHD",
100
+ published: 128.2,
101
+ treatment: "1.123",
102
+ before: "KD 1.282",
103
+ after: "KD 1.123",
104
+ },
105
+ ])("swaps $currency prices at their ISO-4217 scale", ({
106
+ currency,
107
+ published,
108
+ treatment,
109
+ before,
110
+ after,
111
+ }) => {
112
+ shopify("product", currency);
113
+ Object.assign((window as any).ShopifyAnalytics.meta.product, {
114
+ variants: [{ id: 123, price: published }],
115
+ });
116
+ document.body.innerHTML = `<div class="price">${before}</div>`;
117
+ const scaledSettings: CommercePriceSettings = {
118
+ ...settings,
119
+ experiments: [{
120
+ ...settings.experiments[0],
121
+ currency,
122
+ variants: {
123
+ treatment: [{
124
+ variant_gid: "gid://shopify/ProductVariant/123",
125
+ fixedPricePerUnit: treatment,
126
+ }],
127
+ },
128
+ }],
129
+ };
130
+
131
+ applyCommercePrice(scaledSettings, assignments);
132
+
133
+ expect(document.querySelector(".price")?.textContent).toBe(after);
134
+ });
135
+
136
+ it("fails closed when a Shopify stored price cannot represent whole minor units", () => {
137
+ shopify("product", "BHD");
138
+ Object.assign((window as any).ShopifyAnalytics.meta.product, {
139
+ variants: [{ id: 123, price: 128.25 }],
140
+ });
141
+ document.body.innerHTML = '<div class="price">KD 1.282</div>';
142
+ const bhdSettings: CommercePriceSettings = {
143
+ ...settings,
144
+ experiments: [{
145
+ ...settings.experiments[0],
146
+ currency: "BHD",
147
+ variants: {
148
+ treatment: [{
149
+ variant_gid: "gid://shopify/ProductVariant/123",
150
+ fixedPricePerUnit: "1.123",
151
+ }],
152
+ },
153
+ }],
154
+ };
155
+
156
+ applyCommercePrice(bhdSettings, assignments);
157
+
158
+ expect(document.querySelector(".price")?.textContent).toBe("KD 1.282");
159
+ });
160
+
161
+ it("fails closed when the active currency has unknown precision", () => {
162
+ shopify("product", "ZZZ");
163
+ Object.assign((window as any).ShopifyAnalytics.meta.product, {
164
+ variants: [{ id: 123, price: 1000 }],
165
+ });
166
+ document.body.innerHTML = '<div class="price">ZZ 10.00</div>';
167
+ const unknownSettings: CommercePriceSettings = {
168
+ ...settings,
169
+ experiments: [{
170
+ ...settings.experiments[0],
171
+ currency: "ZZZ",
172
+ variants: {
173
+ treatment: [{
174
+ variant_gid: "gid://shopify/ProductVariant/123",
175
+ fixedPricePerUnit: "9.00",
176
+ }],
177
+ },
178
+ }],
179
+ };
180
+
181
+ applyCommercePrice(unknownSettings, assignments);
182
+
183
+ expect(document.querySelector(".price")?.textContent).toBe("ZZ 10.00");
184
+ expect(isCommercePriceActive(unknownSettings, assignments)).toBe(false);
185
+ });
186
+
90
187
  it("keeps the published price when the active plane does not authorize the assignment", () => {
91
188
  document.body.innerHTML = '<div class="price">€1.299,00</div>';
92
189
  const authorizeNone = () => false;