@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,217 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ type Listener = () => void;
4
+
5
+ function browserHarness() {
6
+ const documentListeners = new Map<string, Listener>();
7
+ const windowListeners = new Map<string, Listener>();
8
+ const sendBeacon = vi.fn(() => true);
9
+ vi.stubGlobal("document", {
10
+ visibilityState: "visible",
11
+ addEventListener: vi.fn((name: string, listener: Listener) => documentListeners.set(name, listener)),
12
+ removeEventListener: vi.fn(),
13
+ });
14
+ vi.stubGlobal("window", {
15
+ addEventListener: vi.fn((name: string, listener: Listener) => windowListeners.set(name, listener)),
16
+ removeEventListener: vi.fn(),
17
+ });
18
+ vi.stubGlobal("navigator", { sendBeacon });
19
+ vi.stubGlobal("location", { origin: "https://shop.test", pathname: "/products/a" });
20
+ vi.stubGlobal("fetch", vi.fn());
21
+ return { documentListeners, windowListeners, sendBeacon };
22
+ }
23
+
24
+ describe("storefront vitals", () => {
25
+ afterEach(async () => {
26
+ const { resetStorefrontVitalsForTest } = await import("./storefront-vitals");
27
+ resetStorefrontVitalsForTest();
28
+ vi.unstubAllGlobals();
29
+ vi.resetModules();
30
+ });
31
+
32
+ it("keeps one page-level sampling decision across reconfiguration", async () => {
33
+ const { sendBeacon, windowListeners } = browserHarness();
34
+ vi.stubGlobal("PerformanceObserver", undefined);
35
+ const { startStorefrontVitals } = await import("./storefront-vitals");
36
+ const random = vi.fn(() => 0.05);
37
+ const base = { endpoint: "https://events.test", shopId: "shop", experimentActive: false };
38
+ startStorefrontVitals({ ...base, sampleRate: 0.1 }, random);
39
+ startStorefrontVitals({ ...base, sampleRate: 0 }, random);
40
+ windowListeners.get("pagehide")?.();
41
+ expect(random).toHaveBeenCalledTimes(1);
42
+ expect(sendBeacon).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it("aggregates observer metrics and flushes only once", async () => {
46
+ const { documentListeners, windowListeners, sendBeacon } = browserHarness();
47
+ const callbacks = new Map<string, PerformanceObserverCallback>();
48
+ vi.stubGlobal("performance", { getEntriesByType: () => [{ responseStart: 123 }] });
49
+ vi.stubGlobal("PerformanceObserver", class {
50
+ constructor(callback: PerformanceObserverCallback) { this.callback = callback; }
51
+ callback: PerformanceObserverCallback;
52
+ observe(options: PerformanceObserverInit) { callbacks.set(String(options.type), this.callback); }
53
+ disconnect() {}
54
+ takeRecords() { return []; }
55
+ });
56
+ const emit = (type: string, entries: unknown[]) => callbacks.get(type)?.(
57
+ { getEntries: () => entries } as PerformanceObserverEntryList,
58
+ {} as PerformanceObserver,
59
+ );
60
+ const { startStorefrontVitals } = await import("./storefront-vitals");
61
+ startStorefrontVitals({ endpoint: "https://events.test/", shopId: "shop", sampleRate: 1, experimentActive: true });
62
+ expect([...callbacks.keys()]).toEqual(["largest-contentful-paint", "layout-shift", "event"]);
63
+ emit("largest-contentful-paint", [{ startTime: 900, renderTime: 875 }]);
64
+ emit("layout-shift", [
65
+ { startTime: 100, value: 0.08, hadRecentInput: false },
66
+ { startTime: 500, value: 0.04, hadRecentInput: false },
67
+ { startTime: 600, value: 1, hadRecentInput: true },
68
+ ]);
69
+ emit("event", [
70
+ { interactionId: 7, duration: 80 },
71
+ { interactionId: 7, duration: 120 },
72
+ { interactionId: 8, duration: 90 },
73
+ ]);
74
+ Object.defineProperty(document, "visibilityState", { value: "hidden" });
75
+ documentListeners.get("visibilitychange")?.();
76
+ windowListeners.get("pagehide")?.();
77
+ expect(sendBeacon).toHaveBeenCalledTimes(1);
78
+ const body = JSON.parse(await (sendBeacon.mock.calls[0]![1] as Blob).text());
79
+ expect(body).toMatchObject({ lcp_ms: 875, inp_ms: 120, cls: 0.12, ttfb_navigation_start_to_response_start_ms: 123 });
80
+ // Google-threshold ratings ride on the sample so dashboards and alerts
81
+ // read a bucket without re-deriving thresholds.
82
+ expect(body).toMatchObject({
83
+ lcp_rating: "good",
84
+ inp_rating: "good",
85
+ cls_rating: "needs-improvement",
86
+ ttfb_rating: "good",
87
+ });
88
+ });
89
+
90
+ it("rates each vital against the Google CWV thresholds", async () => {
91
+ const { rateVital } = await import("./storefront-vitals");
92
+ expect(rateVital("lcp_ms", 2_500)).toBe("good");
93
+ expect(rateVital("lcp_ms", 2_501)).toBe("needs-improvement");
94
+ expect(rateVital("lcp_ms", 4_001)).toBe("poor");
95
+ expect(rateVital("inp_ms", 200)).toBe("good");
96
+ expect(rateVital("inp_ms", 501)).toBe("poor");
97
+ expect(rateVital("cls", 0.09)).toBe("good");
98
+ expect(rateVital("cls", 0.26)).toBe("poor");
99
+ expect(rateVital("ttfb_ms", undefined)).toBeUndefined();
100
+ });
101
+
102
+ it("uses loadTime when an LCP entry has a zero renderTime", async () => {
103
+ const { windowListeners, sendBeacon } = browserHarness();
104
+ let lcpCallback: PerformanceObserverCallback | undefined;
105
+ vi.stubGlobal("performance", { getEntriesByType: () => [] });
106
+ vi.stubGlobal("PerformanceObserver", class {
107
+ constructor(callback: PerformanceObserverCallback) { lcpCallback ??= callback; }
108
+ observe() {}
109
+ disconnect() {}
110
+ takeRecords() { return []; }
111
+ });
112
+ const { startStorefrontVitals } = await import("./storefront-vitals");
113
+ startStorefrontVitals({ endpoint: "https://events.test", shopId: "shop", sampleRate: 1, experimentActive: false });
114
+ lcpCallback?.({ getEntries: () => [{ startTime: 1200, renderTime: 0, loadTime: 1234 }] } as PerformanceObserverEntryList, {} as PerformanceObserver);
115
+ windowListeners.get("pagehide")?.();
116
+ const body = JSON.parse(await (sendBeacon.mock.calls[0]![1] as Blob).text());
117
+ expect(body.lcp_ms).toBe(1234);
118
+ });
119
+
120
+ it("reads deferred anti-flicker reveal duration when the sample flushes", async () => {
121
+ const { windowListeners, sendBeacon } = browserHarness();
122
+ vi.stubGlobal("PerformanceObserver", undefined);
123
+ let revealDuration: number | undefined;
124
+ const { startStorefrontVitals } = await import("./storefront-vitals");
125
+ startStorefrontVitals({
126
+ endpoint: "https://events.test",
127
+ shopId: "shop",
128
+ sampleRate: 1,
129
+ experimentActive: true,
130
+ getAntiFlickerInsertedToRevealMs: () => revealDuration,
131
+ });
132
+ revealDuration = 1450;
133
+ windowListeners.get("pagehide")?.();
134
+ const body = JSON.parse(await (sendBeacon.mock.calls[0]![1] as Blob).text());
135
+ expect(body.anti_flicker_inserted_to_reveal_ms).toBe(1450);
136
+ });
137
+
138
+ it("falls back without observers or sendBeacon", async () => {
139
+ const { windowListeners } = browserHarness();
140
+ vi.stubGlobal("PerformanceObserver", undefined);
141
+ vi.stubGlobal("navigator", {});
142
+ const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 202 })));
143
+ vi.stubGlobal("fetch", fetch);
144
+ const { startStorefrontVitals } = await import("./storefront-vitals");
145
+ expect(() => startStorefrontVitals({ endpoint: "https://events.test", shopId: "shop", sampleRate: 1, experimentActive: false })).not.toThrow();
146
+ windowListeners.get("pagehide")?.();
147
+ expect(fetch).toHaveBeenCalledTimes(1);
148
+ expect(new URL(String(fetch.mock.calls[0]![0])).pathname).toBe("/storefront-vitals");
149
+ const body = JSON.parse(String(fetch.mock.calls[0]![1]?.body));
150
+ expect(body).not.toHaveProperty("cls");
151
+ });
152
+
153
+ it("uses the same-origin proxy for storefront vitals and retries direct when sendBeacon rejects it", async () => {
154
+ const { windowListeners, sendBeacon } = browserHarness();
155
+ vi.stubGlobal("PerformanceObserver", undefined);
156
+ sendBeacon.mockReturnValueOnce(false).mockReturnValueOnce(true);
157
+ const { startStorefrontVitals } = await import("./storefront-vitals");
158
+ startStorefrontVitals({
159
+ endpoint: "https://events.test",
160
+ proxyEventDelivery: { enabled: true },
161
+ shopId: "shop",
162
+ sampleRate: 1,
163
+ experimentActive: false,
164
+ });
165
+ windowListeners.get("pagehide")?.();
166
+
167
+ expect(sendBeacon).toHaveBeenCalledTimes(2);
168
+ expect(sendBeacon.mock.calls.map(([url]) => String(url))).toEqual([
169
+ "https://shop.test/apps/apex/storefront-vitals",
170
+ "https://events.test/storefront-vitals",
171
+ ]);
172
+ const directBody = JSON.parse(await (sendBeacon.mock.calls[1]![1] as Blob).text());
173
+ expect(directBody.delivery_downgrade).toBe("proxy_failed");
174
+ });
175
+
176
+ it("retries a failed proxy fetch directly once", async () => {
177
+ const { windowListeners } = browserHarness();
178
+ vi.stubGlobal("PerformanceObserver", undefined);
179
+ vi.stubGlobal("navigator", {});
180
+ const fetch = vi.fn()
181
+ .mockResolvedValueOnce(new Response(null, { status: 502 }))
182
+ .mockResolvedValueOnce(new Response(null, { status: 202 }));
183
+ vi.stubGlobal("fetch", fetch);
184
+ const { startStorefrontVitals } = await import("./storefront-vitals");
185
+ startStorefrontVitals({
186
+ endpoint: "https://events.test",
187
+ proxyEventDelivery: { enabled: true },
188
+ shopId: "shop",
189
+ sampleRate: 1,
190
+ experimentActive: false,
191
+ });
192
+ windowListeners.get("pagehide")?.();
193
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
194
+
195
+ expect(fetch.mock.calls.map(([url]) => String(url))).toEqual([
196
+ "https://shop.test/apps/apex/storefront-vitals",
197
+ "https://events.test/storefront-vitals",
198
+ ]);
199
+ expect(String(fetch.mock.calls[1]![1]?.body)).toContain('"delivery_downgrade":"proxy_failed"');
200
+ });
201
+
202
+ it("reports a legitimate zero CLS when layout shifts are supported", async () => {
203
+ const { windowListeners, sendBeacon } = browserHarness();
204
+ vi.stubGlobal("performance", { getEntriesByType: () => [] });
205
+ vi.stubGlobal("PerformanceObserver", class {
206
+ constructor(_callback: PerformanceObserverCallback) {}
207
+ observe() {}
208
+ disconnect() {}
209
+ takeRecords() { return []; }
210
+ });
211
+ const { startStorefrontVitals } = await import("./storefront-vitals");
212
+ startStorefrontVitals({ endpoint: "https://events.test", shopId: "shop", sampleRate: 1, experimentActive: false });
213
+ windowListeners.get("pagehide")?.();
214
+ const body = JSON.parse(await (sendBeacon.mock.calls[0]![1] as Blob).text());
215
+ expect(body.cls).toBe(0);
216
+ });
217
+ });
@@ -0,0 +1,206 @@
1
+ export type StorefrontVitalsContext = {
2
+ endpoint: string;
3
+ proxyEventDelivery?: { enabled: boolean; path?: string };
4
+ shopId: string;
5
+ ingestShopId?: string;
6
+ ingestSignature?: string;
7
+ visitorId?: string;
8
+ sampleRate?: number;
9
+ experimentActive: boolean;
10
+ sdkBootToInitMs?: number;
11
+ getAntiFlickerInsertedToRevealMs?: () => number | undefined;
12
+ sdkInitStartToAssignmentReadyMs?: number;
13
+ };
14
+
15
+ type LayoutShift = PerformanceEntry & { value?: number; hadRecentInput?: boolean };
16
+ type EventTiming = PerformanceEntry & { duration?: number; interactionId?: number };
17
+ type LargestPaint = PerformanceEntry & { renderTime?: number; loadTime?: number };
18
+
19
+ const observers: Array<{ observer: PerformanceObserver; consume: (entries: PerformanceEntry[]) => void }> = [];
20
+ let cleanup: (() => void) | null = null;
21
+ let pageSampleDecision: boolean | undefined;
22
+
23
+ function finite(value: unknown): number | undefined {
24
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
25
+ }
26
+
27
+ export type VitalRating = "good" | "needs-improvement" | "poor";
28
+
29
+ // Google CWV thresholds (web.dev): the same buckets PageSpeed reports, so
30
+ // dashboards and alerts can read a rating without re-deriving thresholds.
31
+ const RATING_THRESHOLDS: Record<string, [number, number]> = {
32
+ lcp_ms: [2_500, 4_000],
33
+ inp_ms: [200, 500],
34
+ cls: [0.1, 0.25],
35
+ ttfb_ms: [800, 1_800],
36
+ };
37
+
38
+ export function rateVital(metric: keyof typeof RATING_THRESHOLDS, value: number | undefined): VitalRating | undefined {
39
+ const thresholds = RATING_THRESHOLDS[metric];
40
+ if (!thresholds || value === undefined) return undefined;
41
+ return value <= thresholds[0] ? "good" : value <= thresholds[1] ? "needs-improvement" : "poor";
42
+ }
43
+
44
+ function navigationTtfb(): number | undefined {
45
+ try {
46
+ const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
47
+ return finite(navigation?.responseStart);
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /** Starts one dependency-free, observer-only storefront vitals sample. */
54
+ export function startStorefrontVitals(context: StorefrontVitalsContext, random = Math.random): void {
55
+ stopStorefrontVitals();
56
+ const rate = finite(context.sampleRate) ?? 0.1;
57
+ if (pageSampleDecision === undefined) pageSampleDecision = rate > 0 && (rate >= 1 || random() < rate);
58
+ if (!pageSampleDecision || !context.endpoint || !context.shopId || typeof document === "undefined") return;
59
+
60
+ let flushed = false;
61
+ let lcpMs: number | undefined;
62
+ let cls = 0;
63
+ let clsSupported = false;
64
+ let clsWindow = 0;
65
+ let clsWindowStart = 0;
66
+ let clsLastShift = 0;
67
+ const interactions = new Map<number, number>();
68
+
69
+ const observe = (type: string, callback: (entries: PerformanceEntry[]) => void, options?: PerformanceObserverInit): boolean => {
70
+ if (typeof PerformanceObserver === "undefined") return false;
71
+ try {
72
+ const observer = new PerformanceObserver((list) => callback(list.getEntries()));
73
+ observer.observe(options ?? { type, buffered: true });
74
+ observers.push({ observer, consume: callback });
75
+ return true;
76
+ } catch {
77
+ // Unsupported entry types are expected on older browsers.
78
+ return false;
79
+ }
80
+ };
81
+
82
+ observe("largest-contentful-paint", (entries) => {
83
+ const last = entries[entries.length - 1] as LargestPaint | undefined;
84
+ if (last) {
85
+ // Cross-origin LCP resources can expose renderTime as zero. A real LCP
86
+ // cannot occur at navigation start, so use the entry's effective time.
87
+ lcpMs = (finite(last.renderTime) || undefined) ?? finite(last.loadTime) ?? finite(last.startTime);
88
+ }
89
+ });
90
+ clsSupported = observe("layout-shift", (entries) => {
91
+ for (const raw of entries) {
92
+ const entry = raw as LayoutShift;
93
+ if (entry.hadRecentInput || finite(entry.value) === undefined) continue;
94
+ if (entry.startTime - clsLastShift > 1_000 || entry.startTime - clsWindowStart > 5_000) {
95
+ clsWindow = 0;
96
+ clsWindowStart = entry.startTime;
97
+ }
98
+ clsWindow += entry.value!;
99
+ clsLastShift = entry.startTime;
100
+ cls = Math.max(cls, clsWindow);
101
+ }
102
+ });
103
+ observe("event", (entries) => {
104
+ for (const raw of entries) {
105
+ const entry = raw as EventTiming;
106
+ const id = finite(entry.interactionId);
107
+ const duration = finite(entry.duration);
108
+ if (!id || duration === undefined) continue;
109
+ // One interaction can produce multiple EventTiming entries. INP uses the
110
+ // longest entry for that interaction, never a double-counted sum.
111
+ interactions.set(id, Math.max(interactions.get(id) ?? 0, duration));
112
+ }
113
+ }, { type: "event", buffered: true, durationThreshold: 40 } as PerformanceObserverInit);
114
+
115
+ const flush = () => {
116
+ if (flushed) return;
117
+ flushed = true;
118
+ const ttfbMs = navigationTtfb();
119
+ for (const { observer, consume } of observers.splice(0)) {
120
+ try { consume(observer.takeRecords()); } catch {}
121
+ observer.disconnect();
122
+ }
123
+ const interactionDurations = [...interactions.values()].sort((a, b) => b - a);
124
+ // The CWV INP estimator ignores one extreme interaction per 50 observed
125
+ // interactions, after first collapsing multiple EventTiming entries by ID.
126
+ const inpMs = interactionDurations[Math.min(Math.floor(interactionDurations.length / 50), interactionDurations.length - 1)];
127
+ const sample = {
128
+ _shopId: context.ingestShopId ?? context.shopId,
129
+ _signature: context.ingestSignature,
130
+ shop_id: context.shopId,
131
+ visitor_id: context.visitorId,
132
+ url: location.origin + location.pathname,
133
+ path: location.pathname,
134
+ lcp_ms: lcpMs,
135
+ inp_ms: inpMs,
136
+ cls: clsSupported ? Math.round(cls * 100_000) / 100_000 : undefined,
137
+ ttfb_navigation_start_to_response_start_ms: ttfbMs,
138
+ lcp_rating: rateVital("lcp_ms", lcpMs),
139
+ inp_rating: rateVital("inp_ms", inpMs),
140
+ cls_rating: clsSupported ? rateVital("cls", cls) : undefined,
141
+ ttfb_rating: rateVital("ttfb_ms", ttfbMs),
142
+ experiment_active: context.experimentActive,
143
+ sdk_boot_to_init_start_ms: context.sdkBootToInitMs,
144
+ // Reveal can happen well after initialization (hydration/deferred holds),
145
+ // so resolve this live rather than freezing an incomplete init snapshot.
146
+ anti_flicker_inserted_to_reveal_ms: context.getAntiFlickerInsertedToRevealMs?.(),
147
+ sdk_init_start_to_assignment_ready_ms: context.sdkInitStartToAssignmentReadyMs,
148
+ timestamp: new Date().toISOString(),
149
+ };
150
+ const payload = JSON.stringify(sample);
151
+ const endpoint = context.endpoint.replace(/\/+$/, "") + "/storefront-vitals";
152
+ const proxyEndpoint = context.proxyEventDelivery?.enabled === true && typeof location?.origin === "string"
153
+ ? `${location.origin}/apps/apex/storefront-vitals`
154
+ : null;
155
+ const downgradedPayload = () => JSON.stringify({ ...sample, delivery_downgrade: "proxy_failed" });
156
+
157
+ if (proxyEndpoint) {
158
+ try {
159
+ if (navigator.sendBeacon) {
160
+ if (navigator.sendBeacon(proxyEndpoint, new Blob([payload], { type: "text/plain" }))) return;
161
+ const directPayload = downgradedPayload();
162
+ if (navigator.sendBeacon(endpoint, new Blob([directPayload], { type: "text/plain" }))) return;
163
+ try { void fetch(endpoint, { method: "POST", body: directPayload, headers: { "Content-Type": "text/plain" }, keepalive: true }); } catch {}
164
+ return;
165
+ }
166
+ } catch {
167
+ const directPayload = downgradedPayload();
168
+ try {
169
+ if (navigator.sendBeacon?.(endpoint, new Blob([directPayload], { type: "text/plain" }))) return;
170
+ } catch {}
171
+ try { void fetch(endpoint, { method: "POST", body: directPayload, headers: { "Content-Type": "text/plain" }, keepalive: true }); } catch {}
172
+ return;
173
+ }
174
+ void fetch(proxyEndpoint, {
175
+ method: "POST", body: payload, headers: { "Content-Type": "text/plain" }, keepalive: true,
176
+ }).then((response) => {
177
+ if (!response.ok) throw new Error(`Proxy storefront vitals returned ${response.status}`);
178
+ }).catch(() => fetch(endpoint, {
179
+ method: "POST", body: downgradedPayload(), headers: { "Content-Type": "text/plain" }, keepalive: true,
180
+ })).catch(() => undefined);
181
+ return;
182
+ }
183
+ try {
184
+ if (navigator.sendBeacon?.(endpoint, new Blob([payload], { type: "text/plain" }))) return;
185
+ } catch {}
186
+ try { void fetch(endpoint, { method: "POST", body: payload, headers: { "Content-Type": "text/plain" }, keepalive: true }); } catch {}
187
+ };
188
+ const onVisibility = () => { if (document.visibilityState === "hidden") flush(); };
189
+ document.addEventListener("visibilitychange", onVisibility);
190
+ window.addEventListener("pagehide", flush, { once: true });
191
+ cleanup = () => {
192
+ document.removeEventListener("visibilitychange", onVisibility);
193
+ window.removeEventListener("pagehide", flush);
194
+ };
195
+ }
196
+
197
+ export function stopStorefrontVitals(): void {
198
+ cleanup?.();
199
+ cleanup = null;
200
+ for (const { observer } of observers.splice(0)) observer.disconnect();
201
+ }
202
+
203
+ export function resetStorefrontVitalsForTest(): void {
204
+ stopStorefrontVitals();
205
+ pageSampleDecision = undefined;
206
+ }
@@ -111,6 +111,7 @@ export function installCartDrawerOpenListener(
111
111
 
112
112
  let current = detectCartDrawerOpen();
113
113
 
114
+ let stopped = false;
114
115
  let timeoutId: number | null = null;
115
116
  const check = () => {
116
117
  timeoutId = null;
@@ -120,6 +121,9 @@ export function installCartDrawerOpenListener(
120
121
  onChange(next);
121
122
  };
122
123
  const schedule = () => {
124
+ // A mutation record may already be queued when stop() disconnects the
125
+ // observer. Do not let that stale callback re-arm the check timer.
126
+ if (stopped) return;
123
127
  if (timeoutId !== null) return;
124
128
  timeoutId = window.setTimeout(check, 80);
125
129
  };
@@ -144,10 +148,12 @@ export function installCartDrawerOpenListener(
144
148
  document.addEventListener("keyup", schedule, true);
145
149
 
146
150
  return () => {
151
+ stopped = true;
147
152
  observer.disconnect();
148
153
  document.removeEventListener("click", schedule, true);
149
154
  document.removeEventListener("keyup", schedule, true);
150
155
  if (timeoutId !== null) window.clearTimeout(timeoutId);
156
+ timeoutId = null;
151
157
  };
152
158
  }
153
159
 
@@ -0,0 +1,9 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { targetingConformanceCorpus } from "@drip/event-schema/targeting-conformance";
3
+ import { evaluateConformanceCase } from "@drip/event-schema/targeting-core";
4
+
5
+ describe("shared targeting conformance", () => {
6
+ for (const entry of targetingConformanceCorpus) {
7
+ it(entry.name, () => expect(evaluateConformanceCase(entry)).toBe(entry.verdict));
8
+ }
9
+ });
@@ -11,6 +11,7 @@ import type {
11
11
  } from "./types";
12
12
  import {
13
13
  evalUrlRule,
14
+ evalUrlRuleForIncludeOrigin,
14
15
  evaluateJsTriggerResult,
15
16
  evaluatePageAdvancedConditionResult,
16
17
  matchAttributeRule,
@@ -29,6 +30,7 @@ function urlRuleOperator(rule: UrlRule): string {
29
30
  function diagnoseUrlRules(
30
31
  url: string,
31
32
  rules: UrlRule[],
33
+ hostAgnosticIncludeOrigin?: string | string[],
32
34
  ): { matched: boolean; conditions: ConditionDiagnostic[] } {
33
35
  const conditions: ConditionDiagnostic[] = [];
34
36
  if (!rules.length) return { matched: true, conditions };
@@ -53,7 +55,13 @@ function diagnoseUrlRules(
53
55
  continue;
54
56
  }
55
57
 
56
- const rawMatched = evalUrlRule(url, rule);
58
+ const rawMatched = hostAgnosticIncludeOrigin
59
+ ? evalUrlRuleForIncludeOrigin(
60
+ url,
61
+ rule,
62
+ hostAgnosticIncludeOrigin,
63
+ )
64
+ : evalUrlRule(url, rule);
57
65
  const passed = rule.include === false ? !rawMatched : rawMatched;
58
66
  conditions.push({
59
67
  id: `url:${index}`,
@@ -276,6 +284,14 @@ function diagnoseSegmentCondition(
276
284
  ): { matched: boolean; diagnostic: ConditionDiagnostic } {
277
285
  const resolved = resolveSegmentConditionForDiagnostics(condition, ctx);
278
286
  const matched = matchSegmentConditionValues(condition, resolved.actual, resolved.expected);
287
+ const unresolvedActual =
288
+ resolved.actual === undefined ||
289
+ resolved.actual === null ||
290
+ (typeof resolved.actual === "string" && resolved.actual.trim() === "");
291
+ const diagnosticActual =
292
+ condition.signal === "device.type" && resolved.actual === undefined
293
+ ? ""
294
+ : resolved.actual;
279
295
  return {
280
296
  matched,
281
297
  diagnostic: segmentConditionDiagnostic(
@@ -283,8 +299,10 @@ function diagnoseSegmentCondition(
283
299
  condition,
284
300
  index,
285
301
  matched ? "passed" : "failed",
286
- matched ? undefined : resolved.jsReason ?? "segment_mismatch",
287
- resolved.actual,
302
+ matched
303
+ ? undefined
304
+ : resolved.jsReason ?? (!condition.js && unresolvedActual ? "signal_unresolved" : "segment_mismatch"),
305
+ diagnosticActual,
288
306
  resolved.expected,
289
307
  ),
290
308
  };
@@ -479,7 +497,11 @@ function skippedSegmentConditions(rules: SegmentTargetingRule[], reason: string)
479
497
 
480
498
  export function evaluateTargetingGatesWithConditionDiagnostics(
481
499
  targeting: Targeting | undefined,
482
- ctx: { url: string; attributes: Record<string, unknown> },
500
+ ctx: {
501
+ url: string;
502
+ attributes: Record<string, unknown>;
503
+ hostAgnosticIncludeOrigin?: string | string[];
504
+ },
483
505
  ): TargetingDiagnostics {
484
506
  const conditions: ConditionDiagnostic[] = [];
485
507
  if (!targeting) {
@@ -497,7 +519,11 @@ export function evaluateTargetingGatesWithConditionDiagnostics(
497
519
  const urlRules = normalizeRuntimeUrlRules(targeting);
498
520
  const pageRules = targeting.pageRules ?? [];
499
521
  const pageHasRules = urlRules.length > 0 || pageRules.length > 0;
500
- const url = diagnoseUrlRules(ctx.url, urlRules);
522
+ const url = diagnoseUrlRules(
523
+ ctx.url,
524
+ urlRules,
525
+ ctx.hostAgnosticIncludeOrigin,
526
+ );
501
527
  conditions.push(...url.conditions);
502
528
 
503
529
  let pageRulesMatched = true;
@@ -521,6 +547,10 @@ export function evaluateTargetingGatesWithConditionDiagnostics(
521
547
 
522
548
  const attributeRules = targeting.attributes ?? [];
523
549
  const segmentRules = targeting.segmentRules ?? [];
550
+ const segmentRefs = targeting.segments ?? [];
551
+ const segmentsExpanded = segmentRefs.every((ref) =>
552
+ segmentRules.some((rule) => rule.id === ref.id)
553
+ );
524
554
  if (!pageMatched) {
525
555
  conditions.push(...skippedAttributeConditions(attributeRules, "page_mismatch"));
526
556
  conditions.push(...skippedSegmentConditions(segmentRules, "page_mismatch"));
@@ -540,14 +570,14 @@ export function evaluateTargetingGatesWithConditionDiagnostics(
540
570
  const segments = diagnoseSegmentRules(segmentRules, ctx);
541
571
  conditions.push(...segments.conditions);
542
572
 
543
- const audienceHasRules = attributeRules.length > 0 || segmentRules.length > 0;
544
- const audienceMatched = attributes.matched && segments.matched;
573
+ const audienceHasRules = attributeRules.length > 0 || segmentRules.length > 0 || segmentRefs.length > 0;
574
+ const audienceMatched = segmentsExpanded && attributes.matched && segments.matched;
545
575
  gates.push({
546
576
  id: "audience",
547
577
  label: "Audience check",
548
578
  status: audienceHasRules ? (audienceMatched ? "passed" : "failed") : "skipped",
549
579
  reason: audienceHasRules
550
- ? (audienceMatched ? "audience_matched" : !attributes.matched ? "attribute_mismatch" : "segment_mismatch")
580
+ ? (audienceMatched ? "audience_matched" : !segmentsExpanded ? "segments_unexpanded" : !attributes.matched ? "attribute_mismatch" : "segment_mismatch")
551
581
  : "no_audience_rules",
552
582
  detail: audienceHasRules ? { attributeRules: attributeRules.length, segmentRules: segmentRules.length } : undefined,
553
583
  });