@drip-apex/sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19283 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19267 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16636 -13533
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +28 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2071 -0
  128. package/src/consent-evaluation-borlabs.test.ts +41 -0
  129. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  130. package/src/consent-evaluation.ts +151 -0
  131. package/src/css.test.ts +30 -0
  132. package/src/css.ts +120 -6
  133. package/src/cwv.test.ts +60 -77
  134. package/src/debug-loader.test.ts +214 -0
  135. package/src/debug-loader.ts +41 -0
  136. package/src/decide-assignments.test.ts +196 -0
  137. package/src/decide-assignments.ts +204 -0
  138. package/src/delivery-timing.test.ts +104 -0
  139. package/src/delivery-timing.ts +89 -0
  140. package/src/early-interaction-capture.test.ts +451 -0
  141. package/src/early-interaction-capture.ts +230 -0
  142. package/src/early-reassert.test.ts +207 -0
  143. package/src/early-reassert.ts +152 -0
  144. package/src/edge-adoption.test.ts +435 -0
  145. package/src/edge-runtime.ts +30 -0
  146. package/src/editor/api.test.ts +109 -0
  147. package/src/editor/api.ts +34 -3
  148. package/src/editor/controller.test.ts +297 -14
  149. package/src/editor/controller.ts +69 -5
  150. package/src/editor/index.test.ts +2 -0
  151. package/src/editor/index.ts +1 -0
  152. package/src/editor/publication-result.ts +58 -0
  153. package/src/editor/types.ts +10 -1
  154. package/src/editor/view-kit.ts +2 -1
  155. package/src/editor/view.test.ts +108 -0
  156. package/src/editor/view.ts +49 -7
  157. package/src/endpoint.test.ts +15 -0
  158. package/src/endpoint.ts +10 -0
  159. package/src/exclusion-groups.ts +8 -18
  160. package/src/flags.test.ts +88 -1
  161. package/src/flags.ts +29 -29
  162. package/src/global-runtime-reconciler.test.ts +271 -0
  163. package/src/goal-eligibility.test.ts +29 -0
  164. package/src/goal-eligibility.ts +17 -0
  165. package/src/goals.test.ts +144 -1
  166. package/src/goals.ts +97 -18
  167. package/src/guards.test.ts +99 -1
  168. package/src/guards.ts +34 -15
  169. package/src/handover-cache-order.test.ts +6 -3
  170. package/src/handover.test.ts +154 -8
  171. package/src/handover.ts +122 -12
  172. package/src/hash.ts +29 -54
  173. package/src/holdout.test.ts +26 -0
  174. package/src/hydration.test.ts +73 -2
  175. package/src/hydration.ts +18 -9
  176. package/src/index.ts +130 -7287
  177. package/src/init-analytics.test.ts +1694 -0
  178. package/src/init-anti-flicker.test.ts +1211 -0
  179. package/src/init-cart-attributes.test.ts +2003 -0
  180. package/src/init-config-resolution.test.ts +999 -0
  181. package/src/init-consent.test.ts +971 -0
  182. package/src/init-empty.test.ts +11 -5406
  183. package/src/init-integrations.test.ts +846 -0
  184. package/src/init-page-activation.test.ts +5064 -0
  185. package/src/init-qa-loading.test.ts +740 -0
  186. package/src/init-redirects.test.ts +2156 -0
  187. package/src/init-test-helpers.ts +287 -0
  188. package/src/inline-bootstrap.test.ts +184 -0
  189. package/src/inline-bootstrap.ts +73 -0
  190. package/src/js.test.ts +51 -1
  191. package/src/js.ts +86 -2
  192. package/src/money.test.ts +55 -1
  193. package/src/money.ts +104 -2
  194. package/src/money.web-parity.contract.test.ts +85 -0
  195. package/src/mutate.test.ts +474 -10
  196. package/src/mutate.ts +739 -230
  197. package/src/page-trigger.test.ts +218 -8
  198. package/src/performance.ts +10 -0
  199. package/src/persistence-decoders.ts +18 -0
  200. package/src/public-api.ts +1828 -0
  201. package/src/qa-assistant-state.test.ts +249 -3
  202. package/src/qa-assistant-state.ts +146 -6
  203. package/src/qa-assistant-view.test.ts +220 -2
  204. package/src/qa-assistant-view.ts +102 -5
  205. package/src/react/index.test.tsx +37 -0
  206. package/src/react/index.tsx +19 -3
  207. package/src/reassert-sentinel.test.ts +155 -1
  208. package/src/reassert-sentinel.ts +168 -8
  209. package/src/redirect.test.ts +541 -0
  210. package/src/redirect.ts +572 -0
  211. package/src/replay-recorder-entry.ts +115 -0
  212. package/src/replay-signal-state.ts +100 -0
  213. package/src/replay-signals.test.ts +249 -0
  214. package/src/replay-signals.ts +151 -0
  215. package/src/replay.test.ts +764 -0
  216. package/src/replay.ts +514 -0
  217. package/src/router.test.ts +61 -2
  218. package/src/router.ts +50 -12
  219. package/src/runtime-state.ts +507 -0
  220. package/src/runtime.test.ts +174 -7
  221. package/src/runtime.ts +88 -14
  222. package/src/sanitize.test.ts +34 -0
  223. package/src/sanitize.ts +23 -12
  224. package/src/shadow.test.ts +187 -0
  225. package/src/shadow.ts +99 -0
  226. package/src/shopify-cart.test.ts +733 -23
  227. package/src/shopify-cart.ts +402 -49
  228. package/src/storefront-vitals.test.ts +217 -0
  229. package/src/storefront-vitals.ts +206 -0
  230. package/src/surface-signals.ts +6 -0
  231. package/src/targeting-conformance.test.ts +9 -0
  232. package/src/targeting-diagnostics.ts +38 -8
  233. package/src/targeting-parity-corpus.json +559 -0
  234. package/src/targeting-parity-corpus.test.ts +18 -0
  235. package/src/targeting.detailed.test.ts +105 -0
  236. package/src/targeting.test.ts +211 -0
  237. package/src/targeting.ts +88 -207
  238. package/src/track.test.ts +147 -0
  239. package/src/track.ts +125 -26
  240. package/src/types.ts +120 -13
  241. package/src/wait.test.ts +23 -0
  242. package/src/wait.ts +129 -13
  243. package/src/weekday-delivery.test.ts +56 -0
  244. package/src/weekday-schedule-parity-corpus.json +92 -0
  245. package/src/weekday-schedule.test.ts +69 -0
  246. package/src/weekday-schedule.ts +20 -0
@@ -0,0 +1,271 @@
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,
8
+ } from "./test-fixtures/commerce";
9
+ import type { Experiment } from "./types";
10
+
11
+ function trackedEvents(fetchMock: ReturnType<typeof vi.fn>): { type: string; data?: Record<string, unknown> }[] {
12
+ return fetchMock.mock.calls
13
+ .filter((call) => String(call[0]).endsWith("/events"))
14
+ .flatMap((call) => JSON.parse(String((call[1] as RequestInit | undefined)?.body)).events);
15
+ }
16
+
17
+ describe("global runtime reconciler", () => {
18
+ const originalFetch = globalThis.fetch;
19
+ let fetchMock: ReturnType<typeof vi.fn>;
20
+
21
+ beforeEach(() => {
22
+ vi.resetModules();
23
+ delete (globalThis as any).__drip;
24
+ delete (globalThis as any).drip;
25
+ delete (globalThis as any).Apex;
26
+ localStorage.clear();
27
+ sessionStorage.clear();
28
+ document.head.innerHTML = "";
29
+ document.body.innerHTML = "";
30
+ history.replaceState({}, "", "/products/current-product");
31
+ fetchMock = vi.fn(async (input: RequestInfo | URL) => {
32
+ const url = String(input);
33
+ if (url.endsWith("/cart.js")) {
34
+ return Response.json({ token: "cart-runtime?key=runtime-secret" });
35
+ }
36
+ if (url.includes("/v1/assign")) {
37
+ return Response.json(commerceAssignmentResponseFixture);
38
+ }
39
+ return new Response("ok", { status: 202 });
40
+ });
41
+ globalThis.fetch = fetchMock as typeof fetch;
42
+ });
43
+
44
+ afterEach(async () => {
45
+ const { cleanupCommercePrice } = await import("./commerce-price");
46
+ const { cleanupCommerceShipping } = await import("./commerce-shipping");
47
+ cleanupCommercePrice();
48
+ cleanupCommerceShipping();
49
+ vi.clearAllTimers();
50
+ vi.useRealTimers();
51
+ vi.restoreAllMocks();
52
+ globalThis.fetch = originalFetch;
53
+ delete (globalThis as any).__drip;
54
+ delete (globalThis as any).drip;
55
+ delete (globalThis as any).Apex;
56
+ delete (window as Window & { Shopify?: unknown }).Shopify;
57
+ delete (window as Window & { ShopifyAnalytics?: unknown }).ShopifyAnalytics;
58
+ document.head.innerHTML = "";
59
+ document.body.innerHTML = "";
60
+ });
61
+
62
+ // F6 / S11 / F12
63
+ it("keeps click goals wired after a force-reset recheck that changes no assignment", async () => {
64
+ document.body.innerHTML =
65
+ '<h1 class="headline">Original</h1><button class="buy">Buy</button>';
66
+ const { flush, init, reInit } = await import("./index");
67
+
68
+ init({
69
+ antiFlickerMode: "off",
70
+ endpoint: "https://events.test",
71
+ experiments: [{
72
+ id: "exp_force_goal",
73
+ status: "running",
74
+ trafficAllocation: 1,
75
+ targeting: {
76
+ pageRules: [{
77
+ id: "page_force_goal",
78
+ includeRules: [{ type: "simple", pattern: "/products/*" }],
79
+ trigger: { deactivationMode: "force-reset" },
80
+ }],
81
+ },
82
+ variations: [{
83
+ id: "var_force_goal",
84
+ weight: 1,
85
+ mutations: [{ selector: ".headline", action: "text", text: "Treatment" }],
86
+ }],
87
+ goals: [{
88
+ id: "goal_force_click",
89
+ type: "click",
90
+ selector: ".buy",
91
+ pageId: "page_force_goal",
92
+ }],
93
+ }],
94
+ shopId: "shop_123",
95
+ trackingStart: "immediate",
96
+ userId: "visitor_force_goal",
97
+ });
98
+ await Promise.resolve();
99
+ document.querySelector<HTMLElement>(".buy")!.click();
100
+
101
+ reInit();
102
+ await Promise.resolve();
103
+ document.querySelector<HTMLElement>(".buy")!.click();
104
+ await flush();
105
+
106
+ const goals = trackedEvents(fetchMock).filter((event) => event.type === "goal");
107
+ expect(goals.map((event) => event.data?.goalId)).toEqual([
108
+ "goal_force_click",
109
+ "goal_force_click",
110
+ ]);
111
+ });
112
+
113
+ // F5
114
+ it("keeps the charge-correctness cart listener armed through a display-plane page teardown", async () => {
115
+ installCommerceProductPage();
116
+ const { getDiagnostics, init, reInit } = await import("./index");
117
+
118
+ init({
119
+ antiFlickerMode: "off",
120
+ commerce,
121
+ endpoint: "https://events.test",
122
+ experiments: [{
123
+ id: "exp_display_only",
124
+ status: "running",
125
+ trafficAllocation: 1,
126
+ targeting: {
127
+ pageRules: [
128
+ {
129
+ id: "page_display_product",
130
+ includeRules: [{ type: "simple", pattern: "/products/*" }],
131
+ trigger: { deactivationMode: "reset" },
132
+ },
133
+ {
134
+ id: "page_display_everywhere",
135
+ includeRules: [{ type: "simple", pattern: "/*" }],
136
+ },
137
+ ],
138
+ },
139
+ variations: [{
140
+ id: "var_display_only",
141
+ weight: 1,
142
+ mutations: [{ selector: ".price", action: "attribute", attribute: "data-arm", value: "b" }],
143
+ }],
144
+ }],
145
+ ingestSignature: "ingest-token",
146
+ shopId: "shop_123",
147
+ trackingStart: "immediate",
148
+ userId: "visitor_display_only",
149
+ });
150
+ history.replaceState({}, "", "/collections/all");
151
+ reInit();
152
+ expect(getDiagnostics().pageActivation).toEqual(expect.arrayContaining([
153
+ expect.objectContaining({ pageId: "page_display_product", active: false }),
154
+ expect.objectContaining({ pageId: "page_display_everywhere", active: true }),
155
+ ]));
156
+
157
+ // Let the init-time stamp settle so the only remaining source of a cart
158
+ // sync is the mutation listener itself.
159
+ await vi.waitFor(() =>
160
+ expect(getDiagnostics().spa.lastShopifyCartSync?.status).not.toBe("pending"));
161
+ const before = getDiagnostics().spa.lastShopifyCartSync;
162
+ await new Promise((resolve) => setTimeout(resolve, 20));
163
+ expect(getDiagnostics().spa.lastShopifyCartSync).toBe(before);
164
+
165
+ // The charge plane is page-independent: the price authorization plane is
166
+ // still live, so a cart mutation must still refresh the cohort stamp.
167
+ await fetch(new URL("/cart/add.js", location.origin).toString(), { method: "POST" });
168
+ await vi.waitFor(() =>
169
+ expect(getDiagnostics().spa.lastShopifyCartSync).not.toBe(before));
170
+ });
171
+
172
+ // F4
173
+ it("does not resurrect a deactivated page's price treatment from a cart-authorization callback", async () => {
174
+ installCommerceProductPage();
175
+ const experiments: Experiment[] = [
176
+ {
177
+ ...priceExperimentsFixture[0]!,
178
+ targeting: {
179
+ pageRules: [{
180
+ id: "page_price_product",
181
+ includeRules: [{ type: "simple", pattern: "/products/*" }],
182
+ trigger: { deactivationMode: "reset" },
183
+ }],
184
+ },
185
+ },
186
+ {
187
+ id: "exp_sibling",
188
+ status: "running",
189
+ trafficAllocation: 1,
190
+ variations: [{ id: "var_sibling", weight: 1, mutations: [] }],
191
+ },
192
+ ];
193
+ const { confirmCartAttributesWritten, getSignedCartAttributes, init, reInit } =
194
+ await import("./index");
195
+
196
+ init({
197
+ antiFlickerMode: "off",
198
+ commerce,
199
+ endpoint: "https://events.test",
200
+ experiments,
201
+ ingestSignature: "ingest-token",
202
+ shopId: "shop_123",
203
+ trackingStart: "immediate",
204
+ userId: "visitor_price_page",
205
+ });
206
+ await vi.waitFor(() =>
207
+ expect(document.querySelector(".price")?.textContent).toBe("€1.149,95"));
208
+
209
+ history.replaceState({}, "", "/collections/all");
210
+ reInit();
211
+ await vi.waitFor(() =>
212
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00"));
213
+
214
+ const cartToken = "gid://shopify/Cart/abc?key=cart-secret";
215
+ const entries = await getSignedCartAttributes(cartToken);
216
+ confirmCartAttributesWritten(cartToken);
217
+ await Promise.resolve();
218
+ await Promise.resolve();
219
+ expect(entries.length).toBeGreaterThan(0);
220
+ expect(document.querySelector(".price")?.textContent).toBe("€1.299,00");
221
+ });
222
+
223
+ // S7
224
+ it("keeps another page's harness polling alive through a force-reset reapply", async () => {
225
+ vi.useFakeTimers();
226
+ const manifestUrl = "https://harness.test/manifest.json";
227
+ const manifestCalls = () =>
228
+ fetchMock.mock.calls.filter((call) => String(call[0]) === manifestUrl).length;
229
+ const { init, reInit } = await import("./index");
230
+
231
+ init({
232
+ antiFlickerMode: "off",
233
+ endpoint: "https://events.test",
234
+ experiments: [
235
+ {
236
+ id: "exp_harness",
237
+ status: "running",
238
+ trafficAllocation: 1,
239
+ variations: [{
240
+ id: "var_harness",
241
+ weight: 1,
242
+ mutations: [],
243
+ harnessSession: { manifestUrl, pollIntervalMs: 250, version: 1 },
244
+ }],
245
+ },
246
+ {
247
+ id: "exp_force_page",
248
+ status: "running",
249
+ trafficAllocation: 1,
250
+ targeting: {
251
+ pageRules: [{
252
+ id: "page_force_harness",
253
+ includeRules: [{ type: "simple", pattern: "/products/*" }],
254
+ trigger: { deactivationMode: "force-reset" },
255
+ }],
256
+ },
257
+ variations: [{ id: "var_force_page", weight: 1, mutations: [] }],
258
+ },
259
+ ],
260
+ shopId: "shop_123",
261
+ trackingStart: "immediate",
262
+ userId: "visitor_harness_page",
263
+ });
264
+ expect(manifestCalls()).toBe(1);
265
+
266
+ reInit();
267
+ const afterReapply = manifestCalls();
268
+ vi.advanceTimersByTime(600);
269
+ expect(manifestCalls()).toBeGreaterThan(afterReapply);
270
+ });
271
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { goalIsEligible } from "./goal-eligibility";
3
+
4
+ const HREF = "https://shop.example/checkout/thank-you?step=done";
5
+ const NO_PAGES: ReadonlySet<string> = new Set();
6
+
7
+ describe("goalIsEligible", () => {
8
+ it("decides page-scoped goals entirely by page activation", () => {
9
+ expect(goalIsEligible({ pageId: "page_1", urlPattern: "never-matches" }, new Set(["page_1"]), HREF)).toBe(true);
10
+ expect(goalIsEligible({ pageId: "page_1", urlPattern: ".*" }, NO_PAGES, HREF)).toBe(false);
11
+ });
12
+
13
+ it("matches every URL when the pattern is empty", () => {
14
+ expect(goalIsEligible({ urlPattern: "" }, NO_PAGES, HREF)).toBe(true);
15
+ expect(goalIsEligible({ urlPattern: null }, NO_PAGES, HREF)).toBe(true);
16
+ expect(goalIsEligible({}, NO_PAGES, HREF)).toBe(true);
17
+ });
18
+
19
+ it("tests the full href, not a path-relative substring", () => {
20
+ expect(goalIsEligible({ urlPattern: "checkout/thank-you" }, NO_PAGES, HREF)).toBe(true);
21
+ // A path-anchored pattern can never match an absolute href — the goal arm
22
+ // deliberately differs from the experiment URL-targeting matcher.
23
+ expect(goalIsEligible({ urlPattern: "^/checkout" }, NO_PAGES, HREF)).toBe(false);
24
+ });
25
+
26
+ it("fails closed on an invalid regex pattern", () => {
27
+ expect(goalIsEligible({ urlPattern: "([unclosed" }, NO_PAGES, HREF)).toBe(false);
28
+ });
29
+ });
@@ -0,0 +1,17 @@
1
+ import { safeRegexTest } from "./safe-regex";
2
+
3
+ // The single decision for whether a goal is active on a page (APX-102 arms):
4
+ // a goal carrying a pageId is decided entirely by Page activation and its
5
+ // url_pattern is never consulted; otherwise an empty pattern matches every
6
+ // URL and a non-empty pattern is a full-href regex test (no slash escaping,
7
+ // no path fallback — deliberately NOT the experiment URL-targeting matcher).
8
+ // The SDK runtime and the CLI both consume this function; the edge engine
9
+ // inlines the same two arms (packages/worker/src/edge-engine `goalActive`).
10
+ export function goalIsEligible(
11
+ goal: { pageId?: string | null; urlPattern?: string | null },
12
+ pageActiveIds: ReadonlySet<string>,
13
+ href: string,
14
+ ): boolean {
15
+ if (goal.pageId) return pageActiveIds.has(goal.pageId);
16
+ return !goal.urlPattern || safeRegexTest(goal.urlPattern, href);
17
+ }
package/src/goals.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { setupGoals } from "./goals";
3
+ import { setupGoals, triggerGoal, triggerGoalsForEvent } from "./goals";
4
4
  import { setJsPolicy } from "./js";
5
5
 
6
6
  const assignment = { experimentId: "exp", variationId: "var" } as const;
@@ -20,6 +20,7 @@ describe("goal capture policy", () => {
20
20
  setupGoals([], [], vi.fn());
21
21
  setJsPolicy("hybrid");
22
22
  document.body.innerHTML = "";
23
+ localStorage.clear();
23
24
  });
24
25
 
25
26
  it("drops PII-shaped captures only under strict policy", () => {
@@ -40,3 +41,145 @@ describe("goal capture policy", () => {
40
41
  });
41
42
  });
42
43
  });
44
+
45
+ describe("goal page scope", () => {
46
+ afterEach(() => {
47
+ setupGoals([], [], vi.fn());
48
+ document.body.innerHTML = "";
49
+ localStorage.clear();
50
+ window.history.replaceState({}, "", "/");
51
+ });
52
+
53
+ function clickGoal(overrides: Record<string, unknown> = {}) {
54
+ return {
55
+ id: "goal",
56
+ type: "click" as const,
57
+ selector: "button",
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ function wire(goal: ReturnType<typeof clickGoal>, activePages = new Set<string>()) {
63
+ const callback = vi.fn();
64
+ setupGoals(
65
+ [assignment],
66
+ [{ id: "exp", goals: [goal] }],
67
+ callback,
68
+ activePages,
69
+ );
70
+ return callback;
71
+ }
72
+
73
+ it("fires a scoped click only while its Page is active", () => {
74
+ document.body.innerHTML = "<button>Buy</button>";
75
+ const active = wire(clickGoal({ pageId: "page-products" }), new Set(["page-products"]));
76
+ document.querySelector("button")!.click();
77
+ expect(active).toHaveBeenCalledOnce();
78
+
79
+ const inactive = wire(clickGoal({ pageId: "page-products" }), new Set());
80
+ document.querySelector("button")!.click();
81
+ expect(inactive).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("uses urlPattern as the backstop for unscoped click goals", () => {
85
+ document.body.innerHTML = "<button>Buy</button>";
86
+ window.history.replaceState({}, "", "/products/shirt");
87
+ const matching = wire(clickGoal({ urlPattern: "/products/" }));
88
+ document.querySelector("button")!.click();
89
+ expect(matching).toHaveBeenCalledOnce();
90
+
91
+ window.history.replaceState({}, "", "/collections/sale");
92
+ const nonMatching = wire(clickGoal({ urlPattern: "/products/" }));
93
+ document.querySelector("button")!.click();
94
+ expect(nonMatching).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it("preserves global behavior for legacy click goals without scope", () => {
98
+ document.body.innerHTML = "<button>Buy</button>";
99
+ const callback = wire(clickGoal());
100
+ document.querySelector("button")!.click();
101
+ expect(callback).toHaveBeenCalledOnce();
102
+ });
103
+
104
+ it("uses Page state for scoped pageview goals and URL matching for unscoped ones", () => {
105
+ window.history.replaceState({}, "", "/products/shirt");
106
+ const scoped = vi.fn();
107
+ setupGoals(
108
+ [assignment],
109
+ [{
110
+ id: "exp",
111
+ goals: [{ id: "view", type: "pageview", pageId: "page-products" }],
112
+ }],
113
+ scoped,
114
+ new Set(["page-products"]),
115
+ );
116
+ expect(scoped).toHaveBeenCalledOnce();
117
+
118
+ const inactive = vi.fn();
119
+ setupGoals(
120
+ [assignment],
121
+ [{
122
+ id: "exp",
123
+ goals: [{ id: "view", type: "pageview", pageId: "page-products" }],
124
+ }],
125
+ inactive,
126
+ new Set(),
127
+ );
128
+ expect(inactive).not.toHaveBeenCalled();
129
+
130
+ const urlScoped = vi.fn();
131
+ setupGoals(
132
+ [assignment],
133
+ [{
134
+ id: "exp",
135
+ goals: [{ id: "view-url", type: "pageview", urlPattern: "/products/" }],
136
+ }],
137
+ urlScoped,
138
+ new Set(),
139
+ );
140
+ expect(urlScoped).toHaveBeenCalledOnce();
141
+ });
142
+
143
+ it("normalizes both sides when matching project-level custom goals", () => {
144
+ const callback = vi.fn();
145
+ setupGoals([assignment], [], callback, new Set());
146
+ triggerGoalsForEvent(
147
+ "click_to__atc",
148
+ [assignment],
149
+ [{
150
+ id: "exp",
151
+ goals: [{ id: "custom", type: "custom", eventName: "click_to_ atc" }],
152
+ }],
153
+ { plan: "pro" },
154
+ );
155
+ expect(callback).toHaveBeenCalledWith("custom", assignment, { plan: "pro" });
156
+ });
157
+
158
+ it("fires a project-level revenue goal regardless of active Pages", () => {
159
+ const callback = vi.fn();
160
+ const experiments = [{
161
+ id: "exp",
162
+ goals: [{ id: "revenue", type: "revenue" as const }],
163
+ }];
164
+ setupGoals([assignment], experiments, callback, new Set());
165
+ triggerGoal("revenue", [assignment], experiments, { revenue: 42 });
166
+ expect(callback).toHaveBeenCalledWith("revenue", assignment, { revenue: 42 });
167
+ });
168
+
169
+ it("keeps countOnce dedup across deactivate and reactivate", () => {
170
+ document.body.innerHTML = "<button>Buy</button>";
171
+ const goal = clickGoal({ pageId: "page-products", countOnce: true });
172
+ const callback = wire(goal, new Set(["page-products"]));
173
+ document.querySelector("button")!.click();
174
+
175
+ setupGoals([assignment], [{ id: "exp", goals: [goal] }], callback, new Set());
176
+ setupGoals(
177
+ [assignment],
178
+ [{ id: "exp", goals: [goal] }],
179
+ callback,
180
+ new Set(["page-products"]),
181
+ );
182
+ document.querySelector("button")!.click();
183
+ expect(callback).toHaveBeenCalledOnce();
184
+ });
185
+ });
package/src/goals.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { Goal, Assignment, CaptureRule } from "./types";
2
2
  import { safeRegexTest } from "./safe-regex";
3
+ import { goalIsEligible } from "./goal-eligibility";
3
4
  import { containsPiiToken } from "./pii-keys";
4
5
  import { isStrictJsPolicy } from "./js";
6
+ import { normalizeEventName } from "@drip/event-schema/event-name";
5
7
 
6
8
  type GoalCallback = (
7
9
  goalId: string,
@@ -14,6 +16,10 @@ let onGoal: GoalCallback | null = null;
14
16
  let clickListener: ((e: Event) => void) | null = null;
15
17
  let activeClickGoals: { goal: Goal; assignment: Assignment }[] = [];
16
18
 
19
+ function goalIsActiveOnPage(goal: Goal, pageActiveIds: ReadonlySet<string>): boolean {
20
+ return goalIsEligible(goal, pageActiveIds, location.href);
21
+ }
22
+
17
23
  function getFiredGoals(): Record<string, string[]> {
18
24
  try {
19
25
  return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
@@ -89,13 +95,39 @@ function findByText(start: Element, text: string): Element | null {
89
95
  const needle = text.trim().toLowerCase();
90
96
  while (el && el !== document.documentElement) {
91
97
  if ((el.textContent || "").trim().toLowerCase().includes(needle)) return el;
92
- el = el.parentElement;
98
+ el = parentAcrossShadowBoundary(el);
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function parentAcrossShadowBoundary(element: Element): Element | null {
104
+ if (element.parentElement) return element.parentElement;
105
+ const root = element.getRootNode();
106
+ return typeof ShadowRoot === "function" && root instanceof ShadowRoot ? root.host : null;
107
+ }
108
+
109
+ function closestAcrossShadowBoundary(start: Element, selector: string): Element | null {
110
+ let element: Element | null = start;
111
+ while (element) {
112
+ const matched = element.closest(selector);
113
+ if (matched) return matched;
114
+ const root = element.getRootNode();
115
+ element = typeof ShadowRoot === "function" && root instanceof ShadowRoot ? root.host : null;
93
116
  }
94
117
  return null;
95
118
  }
96
119
 
97
120
  function handleClick(e: Event): void {
98
- const target = e.target as Element;
121
+ const composedTarget = e.composedPath?.()[0];
122
+ let resolved: Element | null = null;
123
+ if (typeof Element === "function" && composedTarget instanceof Element) {
124
+ resolved = composedTarget;
125
+ } else if (typeof Node === "function" && composedTarget instanceof Node) {
126
+ // A composed path can start at a Text node; its parent is the real
127
+ // shadow-internal element, while e.target is only the retargeted host.
128
+ resolved = composedTarget.parentElement;
129
+ }
130
+ const target = (resolved ?? e.target) as Element;
99
131
  if (!target?.closest) return;
100
132
  for (const entry of activeClickGoals) {
101
133
  const { selector, textMatch } = entry.goal;
@@ -104,7 +136,7 @@ function handleClick(e: Event): void {
104
136
  let matched: Element | null = null;
105
137
 
106
138
  if (selector) {
107
- matched = target.closest(selector);
139
+ matched = closestAcrossShadowBoundary(target, selector);
108
140
  if (!matched) continue;
109
141
  // If textMatch is also set, verify the text
110
142
  if (textMatch && !textMatches(matched, textMatch)) continue;
@@ -119,10 +151,55 @@ function handleClick(e: Event): void {
119
151
  }
120
152
  }
121
153
 
154
+ function collectActiveGoals(
155
+ assignments: Assignment[],
156
+ experiments: { id: string; goals?: Goal[] }[],
157
+ pageActiveIds: ReadonlySet<string>,
158
+ ): { goal: Goal; assignment: Assignment }[] {
159
+ const expMap = new Map(experiments.map((e) => [e.id, e]));
160
+ const active: { goal: Goal; assignment: Assignment }[] = [];
161
+ for (const assignment of assignments) {
162
+ const exp = expMap.get(assignment.experimentId);
163
+ if (!exp?.goals) continue;
164
+ for (const goal of exp.goals) {
165
+ if (!goalIsActiveOnPage(goal, pageActiveIds)) continue;
166
+ active.push({ goal, assignment });
167
+ }
168
+ }
169
+ return active;
170
+ }
171
+
172
+ /**
173
+ * Identity of the goal wiring the given inputs would produce. The runtime
174
+ * diffs this set to decide whether a re-wire is needed, so wiring stays
175
+ * idempotent across repeated reconciliation passes.
176
+ */
177
+ export function goalWiringKeys(
178
+ assignments: Assignment[],
179
+ experiments: { id: string; goals?: Goal[] }[],
180
+ pageActiveIds: ReadonlySet<string> = new Set(),
181
+ ): Set<string> {
182
+ return new Set(
183
+ collectActiveGoals(assignments, experiments, pageActiveIds).map(
184
+ ({ goal, assignment }) =>
185
+ [
186
+ assignment.experimentId,
187
+ assignment.variationId,
188
+ goal.id,
189
+ goal.pageId ?? "",
190
+ goal.type,
191
+ goal.selector ?? "",
192
+ goal.textMatch ?? "",
193
+ ].join("\u0000"),
194
+ ),
195
+ );
196
+ }
197
+
122
198
  export function setupGoals(
123
199
  assignments: Assignment[],
124
200
  experiments: { id: string; goals?: Goal[] }[],
125
201
  callback: GoalCallback,
202
+ pageActiveIds: ReadonlySet<string> = new Set(),
126
203
  ): void {
127
204
  onGoal = callback;
128
205
  activeClickGoals = [];
@@ -132,22 +209,19 @@ export function setupGoals(
132
209
  clickListener = null;
133
210
  }
134
211
 
135
- const expMap = new Map(experiments.map((e) => [e.id, e]));
136
212
  let hasClickGoals = false;
137
213
 
138
- for (const assignment of assignments) {
139
- const exp = expMap.get(assignment.experimentId);
140
- if (!exp?.goals) continue;
141
- for (const goal of exp.goals) {
142
- if (goal.type === "click" && (goal.selector || goal.textMatch)) {
143
- activeClickGoals.push({ goal, assignment });
144
- hasClickGoals = true;
145
- }
146
- if (goal.type === "pageview") {
147
- if (!goal.urlPattern || safeRegexTest(goal.urlPattern, location.href)) {
148
- fireGoal(goal, assignment);
149
- }
150
- }
214
+ for (const { goal, assignment } of collectActiveGoals(
215
+ assignments,
216
+ experiments,
217
+ pageActiveIds,
218
+ )) {
219
+ if (goal.type === "click" && (goal.selector || goal.textMatch)) {
220
+ activeClickGoals.push({ goal, assignment });
221
+ hasClickGoals = true;
222
+ }
223
+ if (goal.type === "pageview") {
224
+ fireGoal(goal, assignment);
151
225
  }
152
226
  }
153
227
 
@@ -179,12 +253,17 @@ export function triggerGoalsForEvent(
179
253
  data?: Record<string, unknown>,
180
254
  ): void {
181
255
  if (!eventName) return;
256
+ const normalizedEventName = normalizeEventName(eventName);
182
257
  const expMap = new Map(experiments.map((e) => [e.id, e]));
183
258
  for (const assignment of assignments) {
184
259
  const exp = expMap.get(assignment.experimentId);
185
260
  if (!exp?.goals) continue;
186
261
  for (const goal of exp.goals) {
187
- if (goal.type === "custom" && goal.eventName === eventName) {
262
+ if (
263
+ goal.type === "custom" &&
264
+ goal.eventName &&
265
+ normalizeEventName(goal.eventName) === normalizedEventName
266
+ ) {
188
267
  fireGoal(goal, assignment, data);
189
268
  }
190
269
  }