@drip-apex/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/_headers +3 -0
  4. package/dist/bundle-report.json +564 -0
  5. package/dist/drip.debug.js +2 -0
  6. package/dist/drip.debug.js.map +1 -0
  7. package/dist/drip.heatmap.js +2 -0
  8. package/dist/drip.heatmap.js.map +1 -0
  9. package/dist/drip.js +2 -0
  10. package/dist/drip.js.map +1 -0
  11. package/dist/drip.live.js +2 -0
  12. package/dist/drip.live.js.map +1 -0
  13. package/dist/editor.js +480 -0
  14. package/dist/editor.js.map +7 -0
  15. package/dist/index.mjs +645 -0
  16. package/dist/index.mjs.map +7 -0
  17. package/dist/metafiles/drip.debug.meta.json +973 -0
  18. package/dist/metafiles/drip.heatmap.meta.json +918 -0
  19. package/dist/metafiles/drip.live.meta.json +918 -0
  20. package/dist/metafiles/drip.meta.json +973 -0
  21. package/dist/metafiles/editor.meta.json +321 -0
  22. package/dist/metafiles/index.meta.json +1039 -0
  23. package/dist/metafiles/index.mjs.meta.json +78 -0
  24. package/dist/react/index.js +16973 -0
  25. package/dist/react/index.js.map +7 -0
  26. package/dist/sri.json +27 -0
  27. package/dist/types/analytics.d.ts +15 -0
  28. package/dist/types/antiflicker.d.ts +14 -0
  29. package/dist/types/assignment-store.d.ts +30 -0
  30. package/dist/types/audit.d.ts +9 -0
  31. package/dist/types/bucket.d.ts +2 -0
  32. package/dist/types/bucketing-cleanup.d.ts +5 -0
  33. package/dist/types/commerce-gift.d.ts +41 -0
  34. package/dist/types/commerce-price.d.ts +22 -0
  35. package/dist/types/commerce-shipping.d.ts +28 -0
  36. package/dist/types/css.d.ts +5 -0
  37. package/dist/types/cwv.d.ts +10 -0
  38. package/dist/types/data-layer-normalize.d.ts +1 -0
  39. package/dist/types/debug.d.ts +23 -0
  40. package/dist/types/define.d.ts +87 -0
  41. package/dist/types/devtools.d.ts +32 -0
  42. package/dist/types/dom-trigger.d.ts +3 -0
  43. package/dist/types/editor/api.d.ts +37 -0
  44. package/dist/types/editor/artifacts.d.ts +15 -0
  45. package/dist/types/editor/command-runtime.d.ts +48 -0
  46. package/dist/types/editor/controller.d.ts +97 -0
  47. package/dist/types/editor/device-preview.d.ts +23 -0
  48. package/dist/types/editor/index.d.ts +9 -0
  49. package/dist/types/editor/operator.d.ts +39 -0
  50. package/dist/types/editor/picker.d.ts +23 -0
  51. package/dist/types/editor/selector.d.ts +2 -0
  52. package/dist/types/editor/types.d.ts +55 -0
  53. package/dist/types/editor/view-kit.d.ts +33 -0
  54. package/dist/types/editor/view.d.ts +5 -0
  55. package/dist/types/editor-bootstrap.d.ts +37 -0
  56. package/dist/types/exclusion-groups.d.ts +59 -0
  57. package/dist/types/exposure-signals.d.ts +7 -0
  58. package/dist/types/flags.d.ts +65 -0
  59. package/dist/types/goals.d.ts +16 -0
  60. package/dist/types/guards.d.ts +39 -0
  61. package/dist/types/handover.d.ts +10 -0
  62. package/dist/types/hash.d.ts +19 -0
  63. package/dist/types/heatmap.d.ts +8 -0
  64. package/dist/types/hideflicker.d.ts +15 -0
  65. package/dist/types/hydration.d.ts +11 -0
  66. package/dist/types/index.d.ts +144 -0
  67. package/dist/types/js.d.ts +6 -0
  68. package/dist/types/module.d.ts +4 -0
  69. package/dist/types/money.d.ts +14 -0
  70. package/dist/types/mutate.d.ts +34 -0
  71. package/dist/types/observer-mute.d.ts +3 -0
  72. package/dist/types/pii-keys.d.ts +1 -0
  73. package/dist/types/prerequisites.d.ts +12 -0
  74. package/dist/types/qa-assistant-state.d.ts +68 -0
  75. package/dist/types/qa-assistant-view.d.ts +6 -0
  76. package/dist/types/qa-assistant.d.ts +9 -0
  77. package/dist/types/qa-toolbar.d.ts +19 -0
  78. package/dist/types/react/index.d.ts +84 -0
  79. package/dist/types/reassert-sentinel.d.ts +6 -0
  80. package/dist/types/router.d.ts +9 -0
  81. package/dist/types/runtime-tools.d.ts +50 -0
  82. package/dist/types/runtime.d.ts +22 -0
  83. package/dist/types/safe-regex.d.ts +6 -0
  84. package/dist/types/sanitize.d.ts +5 -0
  85. package/dist/types/semantic.d.ts +7 -0
  86. package/dist/types/shopify-cart-events.d.ts +7 -0
  87. package/dist/types/shopify-cart.d.ts +62 -0
  88. package/dist/types/signals.d.ts +31 -0
  89. package/dist/types/surface-signals.d.ts +6 -0
  90. package/dist/types/targeting-diagnostics.d.ts +5 -0
  91. package/dist/types/targeting.d.ts +50 -0
  92. package/dist/types/test-fixtures/commerce.d.ts +12 -0
  93. package/dist/types/track.d.ts +79 -0
  94. package/dist/types/types.d.ts +859 -0
  95. package/dist/types/wait.d.ts +7 -0
  96. package/package.json +74 -0
  97. package/src/analytics.ts +146 -0
  98. package/src/antiflicker.test.ts +127 -0
  99. package/src/antiflicker.ts +147 -0
  100. package/src/assignment-store.test.ts +171 -0
  101. package/src/assignment-store.ts +208 -0
  102. package/src/audit.test.ts +17 -0
  103. package/src/audit.ts +362 -0
  104. package/src/bucket.test.ts +65 -0
  105. package/src/bucket.ts +18 -0
  106. package/src/bucketing-cleanup.test.ts +30 -0
  107. package/src/bucketing-cleanup.ts +72 -0
  108. package/src/bucketing-parity.test.ts +41 -0
  109. package/src/commerce-analytics-lifecycle.test.ts +1416 -0
  110. package/src/commerce-config-lifecycle.test.ts +274 -0
  111. package/src/commerce-epoch-lifecycle.test.ts +62 -0
  112. package/src/commerce-gift.test.ts +1293 -0
  113. package/src/commerce-gift.ts +979 -0
  114. package/src/commerce-offers-display.test.ts +154 -0
  115. package/src/commerce-price.test.ts +1145 -0
  116. package/src/commerce-price.ts +941 -0
  117. package/src/commerce-revocation-lifecycle.test.ts +78 -0
  118. package/src/commerce-shipping.test.ts +630 -0
  119. package/src/commerce-shipping.ts +447 -0
  120. package/src/config-fetch.test.ts +325 -0
  121. package/src/css.ts +54 -0
  122. package/src/cwv.test.ts +95 -0
  123. package/src/cwv.ts +129 -0
  124. package/src/data-layer-normalize.ts +21 -0
  125. package/src/debug.test.ts +83 -0
  126. package/src/debug.ts +138 -0
  127. package/src/define.test.ts +327 -0
  128. package/src/define.ts +208 -0
  129. package/src/devtools.ts +582 -0
  130. package/src/dom-trigger.test.ts +70 -0
  131. package/src/dom-trigger.ts +55 -0
  132. package/src/editor/api.test.ts +154 -0
  133. package/src/editor/api.ts +185 -0
  134. package/src/editor/artifacts.test.ts +57 -0
  135. package/src/editor/artifacts.ts +115 -0
  136. package/src/editor/command-runtime.test.ts +225 -0
  137. package/src/editor/command-runtime.ts +572 -0
  138. package/src/editor/controller.test.ts +371 -0
  139. package/src/editor/controller.ts +596 -0
  140. package/src/editor/device-preview.test.ts +60 -0
  141. package/src/editor/device-preview.ts +120 -0
  142. package/src/editor/index.test.ts +161 -0
  143. package/src/editor/index.ts +169 -0
  144. package/src/editor/operator.test.ts +148 -0
  145. package/src/editor/operator.ts +256 -0
  146. package/src/editor/picker.test.ts +58 -0
  147. package/src/editor/picker.ts +152 -0
  148. package/src/editor/selector.test.ts +44 -0
  149. package/src/editor/selector.ts +110 -0
  150. package/src/editor/types.ts +92 -0
  151. package/src/editor/view-kit.ts +162 -0
  152. package/src/editor/view.test.ts +535 -0
  153. package/src/editor/view.ts +1395 -0
  154. package/src/editor-bootstrap.test.ts +116 -0
  155. package/src/editor-bootstrap.ts +213 -0
  156. package/src/event-schema-contract.test.ts +35 -0
  157. package/src/exclusion-groups.test.ts +383 -0
  158. package/src/exclusion-groups.ts +270 -0
  159. package/src/exposure-signals.test.ts +182 -0
  160. package/src/exposure-signals.ts +110 -0
  161. package/src/flags.test.ts +322 -0
  162. package/src/flags.ts +201 -0
  163. package/src/goals.test.ts +42 -0
  164. package/src/goals.ts +200 -0
  165. package/src/guards.test.ts +249 -0
  166. package/src/guards.ts +250 -0
  167. package/src/handover-cache-order.test.ts +32 -0
  168. package/src/handover.test.ts +349 -0
  169. package/src/handover.ts +309 -0
  170. package/src/hash.ts +214 -0
  171. package/src/heatmap.test.ts +305 -0
  172. package/src/heatmap.ts +373 -0
  173. package/src/hideflicker.ts +21 -0
  174. package/src/hydration.test.ts +252 -0
  175. package/src/hydration.ts +203 -0
  176. package/src/index.ts +7513 -0
  177. package/src/init-empty.test.ts +5572 -0
  178. package/src/js.test.ts +26 -0
  179. package/src/js.ts +61 -0
  180. package/src/module.ts +50 -0
  181. package/src/money.test.ts +35 -0
  182. package/src/money.ts +53 -0
  183. package/src/mutate.test.ts +304 -0
  184. package/src/mutate.ts +469 -0
  185. package/src/observer-mute.test.ts +88 -0
  186. package/src/observer-mute.ts +45 -0
  187. package/src/page-trigger.test.ts +84 -0
  188. package/src/personalization.test.ts +68 -0
  189. package/src/pii-keys.ts +17 -0
  190. package/src/prerequisites.test.ts +39 -0
  191. package/src/prerequisites.ts +94 -0
  192. package/src/qa-assistant-state.test.ts +471 -0
  193. package/src/qa-assistant-state.ts +505 -0
  194. package/src/qa-assistant-view.test.ts +185 -0
  195. package/src/qa-assistant-view.ts +768 -0
  196. package/src/qa-assistant.ts +40 -0
  197. package/src/qa-toolbar.ts +444 -0
  198. package/src/react/index.test.tsx +177 -0
  199. package/src/react/index.tsx +252 -0
  200. package/src/reassert-sentinel.test.ts +314 -0
  201. package/src/reassert-sentinel.ts +191 -0
  202. package/src/router.test.ts +78 -0
  203. package/src/router.ts +92 -0
  204. package/src/runtime-tools.test.ts +112 -0
  205. package/src/runtime-tools.ts +403 -0
  206. package/src/runtime.test.ts +217 -0
  207. package/src/runtime.ts +268 -0
  208. package/src/safe-regex.ts +35 -0
  209. package/src/sanitize.ts +34 -0
  210. package/src/semantic.ts +245 -0
  211. package/src/shopify-cart-events.test.ts +76 -0
  212. package/src/shopify-cart-events.ts +115 -0
  213. package/src/shopify-cart.test.ts +2823 -0
  214. package/src/shopify-cart.ts +1256 -0
  215. package/src/signals.ts +226 -0
  216. package/src/surface-signals.test.ts +83 -0
  217. package/src/surface-signals.ts +165 -0
  218. package/src/targeting-diagnostics.ts +560 -0
  219. package/src/targeting.detailed.test.ts +443 -0
  220. package/src/targeting.test.ts +1054 -0
  221. package/src/targeting.ts +1024 -0
  222. package/src/test-fixtures/commerce.ts +65 -0
  223. package/src/track.test.ts +817 -0
  224. package/src/track.ts +519 -0
  225. package/src/types.ts +991 -0
  226. package/src/wait.test.ts +96 -0
  227. package/src/wait.ts +238 -0
@@ -0,0 +1,325 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ function makeStorage() {
4
+ const map = new Map<string, string>();
5
+ return {
6
+ getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
7
+ key: (index: number) => Array.from(map.keys())[index] ?? null,
8
+ removeItem: (key: string) => { map.delete(key); },
9
+ setItem: (key: string, value: string) => { map.set(key, value); },
10
+ get length() { return map.size; },
11
+ };
12
+ }
13
+
14
+ function makeClassList() {
15
+ const values = new Set<string>();
16
+ return {
17
+ add: (...tokens: string[]) => { tokens.forEach((token) => values.add(token)); },
18
+ remove: (...tokens: string[]) => { tokens.forEach((token) => values.delete(token)); },
19
+ contains: (token: string) => values.has(token),
20
+ };
21
+ }
22
+
23
+ function configPayload() {
24
+ return {
25
+ experiments: [
26
+ {
27
+ id: "exp_late",
28
+ status: "running",
29
+ trafficAllocation: 1,
30
+ variations: [
31
+ { id: "var_late", weight: 1, mutations: [], isControl: false },
32
+ ],
33
+ },
34
+ {
35
+ id: "exp_late_second",
36
+ status: "running",
37
+ trafficAllocation: 1,
38
+ variations: [
39
+ {
40
+ id: "var_late_second",
41
+ weight: 1,
42
+ mutations: [],
43
+ isControl: false,
44
+ },
45
+ ],
46
+ },
47
+ ],
48
+ exclusionGroups: [
49
+ {
50
+ id: "group_late",
51
+ name: "Late config group",
52
+ epoch: 1,
53
+ members: [
54
+ { experimentId: "exp_late", share: 0.5 },
55
+ { experimentId: "exp_late_second", share: 0.5 },
56
+ ],
57
+ },
58
+ ],
59
+ profile: { platform: "auto", version: 1 },
60
+ };
61
+ }
62
+
63
+ async function flushPromises(times = 4) {
64
+ for (let i = 0; i < times; i += 1) {
65
+ await Promise.resolve();
66
+ }
67
+ }
68
+
69
+ // Wait for an async condition by driving the fake clock — fixed microtask
70
+ // flush counts are scheduler-dependent (Node 22 CI needed more turns than
71
+ // local Node 26) and real setTimeout never fires under fake timers.
72
+ async function waitFor(condition: () => boolean, maxTicks = 200) {
73
+ for (let i = 0; i < maxTicks && !condition(); i += 1) {
74
+ await vi.advanceTimersByTimeAsync(10);
75
+ }
76
+ }
77
+
78
+ describe("async config fetch contract", () => {
79
+ const originalWindow = (globalThis as any).window;
80
+ const originalDocument = (globalThis as any).document;
81
+ const originalHistory = (globalThis as any).history;
82
+ const originalLocation = (globalThis as any).location;
83
+ const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
84
+ const originalLocalStorage = (globalThis as any).localStorage;
85
+ const originalSessionStorage = (globalThis as any).sessionStorage;
86
+ const originalCustomEvent = (globalThis as any).CustomEvent;
87
+ const originalFetch = globalThis.fetch;
88
+ const originalAbortController = (globalThis as any).AbortController;
89
+
90
+ beforeEach(() => {
91
+ vi.resetModules();
92
+ vi.useFakeTimers();
93
+ vi.setSystemTime(0);
94
+ delete (globalThis as any).__drip;
95
+
96
+ const appended: Array<{ id?: string; remove?: () => void }> = [];
97
+ const windowStub: Record<string, any> = {
98
+ addEventListener: vi.fn(),
99
+ clearInterval: (id: number) => clearInterval(id),
100
+ clearTimeout: (id: number) => clearTimeout(id),
101
+ innerHeight: 800,
102
+ removeEventListener: vi.fn(),
103
+ setInterval: (fn: TimerHandler, ms?: number) => setInterval(fn, ms),
104
+ setTimeout: (fn: TimerHandler, ms?: number) => setTimeout(fn, ms),
105
+ };
106
+ windowStub.self = windowStub;
107
+ windowStub.top = windowStub;
108
+
109
+ (globalThis as any).window = windowStub;
110
+ (globalThis as any).location = {
111
+ hash: "",
112
+ hostname: "plain.test",
113
+ href: "https://plain.test/",
114
+ origin: "https://plain.test",
115
+ pathname: "/",
116
+ search: "",
117
+ };
118
+ (globalThis as any).history = {
119
+ pushState: vi.fn(),
120
+ replaceState: vi.fn(),
121
+ };
122
+ (globalThis as any).document = {
123
+ addEventListener: vi.fn(),
124
+ body: { scrollHeight: 1200 },
125
+ createElement: vi.fn((tag: string) => ({
126
+ id: "",
127
+ remove: vi.fn(),
128
+ set textContent(_value: string) {},
129
+ tagName: tag.toUpperCase(),
130
+ })),
131
+ dispatchEvent: vi.fn(),
132
+ documentElement: {
133
+ classList: makeClassList(),
134
+ scrollHeight: 1200,
135
+ },
136
+ getElementById: vi.fn((id: string) => appended.find((el) => el.id === id) ?? null),
137
+ head: {
138
+ appendChild: vi.fn((el: { id?: string; remove?: () => void }) => {
139
+ appended.push(el);
140
+ return el;
141
+ }),
142
+ },
143
+ querySelector: vi.fn(() => null),
144
+ querySelectorAll: vi.fn(() => []),
145
+ referrer: "",
146
+ removeEventListener: vi.fn(),
147
+ title: "Plain probe",
148
+ };
149
+ Object.defineProperty(globalThis, "navigator", {
150
+ configurable: true,
151
+ value: { userAgent: "Mozilla/5.0", doNotTrack: "0" },
152
+ });
153
+ (globalThis as any).localStorage = makeStorage();
154
+ (globalThis as any).sessionStorage = makeStorage();
155
+ (globalThis as any).CustomEvent = class CustomEvent {
156
+ type: string;
157
+ detail: unknown;
158
+ constructor(type: string, init?: { detail?: unknown }) {
159
+ this.type = type;
160
+ this.detail = init?.detail;
161
+ }
162
+ };
163
+ globalThis.fetch = vi.fn(async () => new Response("ok", { status: 202 })) as any;
164
+ });
165
+
166
+ afterEach(() => {
167
+ vi.useRealTimers();
168
+ vi.restoreAllMocks();
169
+ delete (globalThis as any).__drip;
170
+ (globalThis as any).window = originalWindow;
171
+ (globalThis as any).document = originalDocument;
172
+ (globalThis as any).history = originalHistory;
173
+ (globalThis as any).location = originalLocation;
174
+ if (originalNavigator) {
175
+ Object.defineProperty(globalThis, "navigator", originalNavigator);
176
+ } else {
177
+ delete (globalThis as any).navigator;
178
+ }
179
+ (globalThis as any).localStorage = originalLocalStorage;
180
+ (globalThis as any).sessionStorage = originalSessionStorage;
181
+ (globalThis as any).CustomEvent = originalCustomEvent;
182
+ (globalThis as any).AbortController = originalAbortController;
183
+ globalThis.fetch = originalFetch;
184
+ });
185
+
186
+ it("bounds fetchWithTimeout when AbortController is unavailable", async () => {
187
+ delete (globalThis as any).AbortController;
188
+ globalThis.fetch = vi.fn(() => new Promise<Response>(() => {})) as any;
189
+ const { __test } = await import("./index");
190
+
191
+ const promise = __test.fetchWithTimeout("https://events.test/config", 25);
192
+ const expectation = expect(promise).rejects.toMatchObject({ name: "AbortError" });
193
+ await vi.advanceTimersByTimeAsync(25);
194
+
195
+ await expectation;
196
+ expect(globalThis.fetch).toHaveBeenCalledWith("https://events.test/config");
197
+ });
198
+
199
+ it("uses the anti-flicker timeout as the first config fetch budget when armed", async () => {
200
+ const aborts: number[] = [];
201
+ globalThis.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) =>
202
+ new Promise<Response>((_resolve, reject) => {
203
+ init?.signal?.addEventListener("abort", () => {
204
+ aborts.push(Date.now());
205
+ reject(new DOMException("The operation was aborted.", "AbortError"));
206
+ });
207
+ })) as any;
208
+ const { init } = await import("./index");
209
+
210
+ init({
211
+ antiFlickerMode: "full",
212
+ antiFlickerTimeoutMs: 700,
213
+ endpoint: "https://events.test",
214
+ shopId: "shop_123",
215
+ });
216
+
217
+ await vi.advanceTimersByTimeAsync(699);
218
+ expect(aborts).toEqual([]);
219
+ await vi.advanceTimersByTimeAsync(1);
220
+ expect(aborts).toEqual([700]);
221
+ });
222
+
223
+ it("skips applying late config by default but writes the config cache", async () => {
224
+ let configCalls = 0;
225
+ globalThis.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
226
+ if (!String(input).includes("/config")) {
227
+ return Promise.resolve(new Response("ok", { status: 202 }));
228
+ }
229
+ configCalls += 1;
230
+ if (configCalls === 1) {
231
+ return new Promise<Response>((_resolve, reject) => {
232
+ init?.signal?.addEventListener("abort", () => {
233
+ reject(new DOMException("The operation was aborted.", "AbortError"));
234
+ });
235
+ });
236
+ }
237
+ return new Promise<Response>((resolve) => {
238
+ setTimeout(() => {
239
+ resolve(new Response(JSON.stringify(configPayload()), { status: 200 }));
240
+ }, 50);
241
+ });
242
+ }) as any;
243
+ const { getDiagnostics, init } = await import("./index");
244
+
245
+ init({
246
+ antiFlickerMode: "full",
247
+ antiFlickerTimeoutMs: 300,
248
+ endpoint: "https://events.test",
249
+ shopId: "shop_123",
250
+ });
251
+
252
+ await vi.advanceTimersByTimeAsync(850);
253
+ await flushPromises(10);
254
+
255
+ const cached = JSON.parse(String(localStorage.getItem("drip_cfg:shop_123:production")));
256
+ expect(cached.experiments).toHaveLength(2);
257
+ expect(cached.exclusionGroups).toHaveLength(1);
258
+ expect(getDiagnostics().assignments).toHaveLength(0);
259
+ expect(getDiagnostics().initialized).toBe(false);
260
+ expect(getDiagnostics().load.stage).toBe("late_config_skip");
261
+ expect(document.dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
262
+ type: "drip_runtime_status",
263
+ detail: expect.objectContaining({ stage: "late_config_skip", configFetchMs: 850 }),
264
+ }));
265
+ });
266
+
267
+ it("applies late config when lateConfigPolicy is apply", async () => {
268
+ let configCalls = 0;
269
+ globalThis.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
270
+ if (!String(input).includes("/config")) {
271
+ return Promise.resolve(new Response("ok", { status: 202 }));
272
+ }
273
+ configCalls += 1;
274
+ if (configCalls === 1) {
275
+ return new Promise<Response>((_resolve, reject) => {
276
+ init?.signal?.addEventListener("abort", () => {
277
+ reject(new DOMException("The operation was aborted.", "AbortError"));
278
+ });
279
+ });
280
+ }
281
+ return new Promise<Response>((resolve) => {
282
+ setTimeout(() => {
283
+ resolve(new Response(JSON.stringify(configPayload()), { status: 200 }));
284
+ }, 50);
285
+ });
286
+ }) as any;
287
+ const { getDiagnostics, init } = await import("./index");
288
+
289
+ init({
290
+ antiFlickerMode: "full",
291
+ antiFlickerTimeoutMs: 300,
292
+ endpoint: "https://events.test",
293
+ lateConfigPolicy: "apply",
294
+ shopId: "shop_123",
295
+ });
296
+
297
+ await vi.advanceTimersByTimeAsync(850);
298
+ await flushPromises(10);
299
+
300
+ expect(getDiagnostics().assignments).toHaveLength(1);
301
+ expect(getDiagnostics().configSource).toBe("network");
302
+ });
303
+
304
+ it("keeps on-time config application unchanged", async () => {
305
+ globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
306
+ if (String(input).includes("/config")) {
307
+ return Promise.resolve(new Response(JSON.stringify(configPayload()), { status: 200 }));
308
+ }
309
+ return Promise.resolve(new Response("ok", { status: 202 }));
310
+ }) as any;
311
+ const { getDiagnostics, init } = await import("./index");
312
+
313
+ init({
314
+ antiFlickerMode: "full",
315
+ antiFlickerTimeoutMs: 1200,
316
+ endpoint: "https://events.test",
317
+ shopId: "shop_123",
318
+ });
319
+ await waitFor(() => getDiagnostics().assignments.length > 0);
320
+
321
+ expect(getDiagnostics().assignments).toHaveLength(1);
322
+ expect(getDiagnostics().configSource).toBe("network");
323
+ expect(getDiagnostics().load.stage).toBe("assignments_ready");
324
+ });
325
+ });
package/src/css.ts ADDED
@@ -0,0 +1,54 @@
1
+ const CSS_CLASS = "drip-css";
2
+ const CSS_LINK_CLASS = "drip-css-link";
3
+
4
+ export function injectCss(css: string, experimentId: string, variationId: string): void {
5
+ const id = "drip-css-" + experimentId + "-" + variationId;
6
+ if (document.getElementById(id)) return;
7
+ const style = document.createElement("style");
8
+ style.id = id;
9
+ style.className = CSS_CLASS;
10
+ style.textContent = css;
11
+ (document.head || document.documentElement).appendChild(style);
12
+ }
13
+
14
+ export function injectExperimentCss(css: string, experimentId: string): void {
15
+ const id = "drip-css-exp-" + experimentId;
16
+ let style = document.getElementById(id) as HTMLStyleElement | null;
17
+ if (!style) {
18
+ style = document.createElement("style");
19
+ style.id = id;
20
+ style.className = CSS_CLASS;
21
+ (document.head || document.documentElement).appendChild(style);
22
+ }
23
+ style.textContent = css;
24
+ }
25
+
26
+ export function removeCss(experimentId: string, variationId: string): void {
27
+ const el = document.getElementById("drip-css-" + experimentId + "-" + variationId);
28
+ if (el) el.remove();
29
+ }
30
+
31
+ export function injectExternalCss(url: string, experimentId: string, variationId: string): Promise<void> {
32
+ return new Promise((resolve, reject) => {
33
+ const id = "drip-css-link-" + experimentId + "-" + variationId;
34
+ const previous = document.getElementById(id);
35
+ if (previous && previous.getAttribute("href") === url) {
36
+ resolve();
37
+ return;
38
+ }
39
+ if (previous) previous.remove();
40
+ const link = document.createElement("link");
41
+ link.id = id;
42
+ link.className = CSS_LINK_CLASS;
43
+ link.rel = "stylesheet";
44
+ link.href = url;
45
+ link.onload = () => resolve();
46
+ link.onerror = () => reject(new Error("Failed to load CSS artifact: " + url));
47
+ (document.head || document.documentElement).appendChild(link);
48
+ });
49
+ }
50
+
51
+ export function removeAllCss(): void {
52
+ document.querySelectorAll("." + CSS_CLASS).forEach((el) => el.remove());
53
+ document.querySelectorAll("." + CSS_LINK_CLASS).forEach((el) => el.remove());
54
+ }
@@ -0,0 +1,95 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ describe("CWV self-measurement", () => {
4
+ afterEach(async () => {
5
+ const { resetCwvMeasurement } = await import("./cwv");
6
+ resetCwvMeasurement();
7
+ vi.useRealTimers();
8
+ vi.unstubAllGlobals();
9
+ vi.restoreAllMocks();
10
+ vi.resetModules();
11
+ });
12
+
13
+ it("feature-detects unsupported PerformanceObserver without throwing", async () => {
14
+ vi.stubGlobal("PerformanceObserver", undefined);
15
+ const { getCwvMetrics, startCwvMeasurement } = await import("./cwv");
16
+
17
+ expect(() => startCwvMeasurement()).not.toThrow();
18
+ expect(getCwvMetrics()).toEqual({ cls_total: 0, cls_after_reveal: 0 });
19
+ });
20
+
21
+ it("resets without constructing an observer when measurement is disabled", async () => {
22
+ const observer = vi.fn();
23
+ vi.stubGlobal("PerformanceObserver", observer);
24
+ const { getCwvMetrics, startCwvMeasurement, waitForCwvMetrics } = await import("./cwv");
25
+
26
+ startCwvMeasurement(true);
27
+
28
+ expect(observer).not.toHaveBeenCalled();
29
+ await expect(waitForCwvMetrics()).resolves.toBeNull();
30
+ expect(getCwvMetrics()).toEqual({ cls_total: 0, cls_after_reveal: 0 });
31
+ });
32
+
33
+ it("accumulates layout shifts and splits shifts after reveal", async () => {
34
+ vi.useFakeTimers();
35
+ let callback: PerformanceObserverCallback | null = null;
36
+ const disconnect = vi.fn();
37
+ const observe = vi.fn();
38
+ vi.stubGlobal("PerformanceObserver", class PerformanceObserver {
39
+ constructor(cb: PerformanceObserverCallback) { callback = cb; }
40
+ observe = observe;
41
+ disconnect = disconnect;
42
+ takeRecords = vi.fn(() => []);
43
+ });
44
+ vi.spyOn(performance, "now").mockReturnValue(100);
45
+ const { getCwvMetrics, markCwvReveal, startCwvMeasurement, waitForCwvMetrics } = await import("./cwv");
46
+ startCwvMeasurement();
47
+ markCwvReveal();
48
+ const finalized = waitForCwvMetrics();
49
+
50
+ callback?.({
51
+ getEntries: () => [
52
+ { startTime: 50, value: 0.1, hadRecentInput: false },
53
+ { startTime: 150, value: 0.2, hadRecentInput: false },
54
+ { startTime: 160, value: 0.4, hadRecentInput: true },
55
+ ],
56
+ } as unknown as PerformanceObserverEntryList, {} as PerformanceObserver);
57
+
58
+ expect(observe).toHaveBeenCalledWith({ type: "layout-shift", buffered: true });
59
+ expect(getCwvMetrics()).toEqual({ cls_total: 0.3, cls_after_reveal: 0.2 });
60
+ await vi.advanceTimersByTimeAsync(5_000);
61
+ expect(disconnect).toHaveBeenCalled();
62
+ await expect(finalized).resolves.toEqual({ cls_total: 0.3, cls_after_reveal: 0.2 });
63
+ });
64
+
65
+ it("finalizes the prior route and resets metrics when a new route starts", async () => {
66
+ vi.useFakeTimers();
67
+ const callbacks: PerformanceObserverCallback[] = [];
68
+ const observes: ReturnType<typeof vi.fn>[] = [];
69
+ vi.stubGlobal("PerformanceObserver", class PerformanceObserver {
70
+ observe = vi.fn();
71
+ disconnect = vi.fn();
72
+ takeRecords = vi.fn(() => []);
73
+ constructor(callback: PerformanceObserverCallback) {
74
+ callbacks.push(callback);
75
+ observes.push(this.observe);
76
+ }
77
+ });
78
+ vi.spyOn(performance, "now").mockReturnValue(100);
79
+ const { getCwvMetrics, markCwvReveal, startCwvMeasurement, waitForCwvMetrics } = await import("./cwv");
80
+
81
+ startCwvMeasurement();
82
+ markCwvReveal();
83
+ const firstRoute = waitForCwvMetrics();
84
+ callbacks[0]?.({
85
+ getEntries: () => [{ startTime: 150, value: 0.25, hadRecentInput: false }],
86
+ } as unknown as PerformanceObserverEntryList, {} as PerformanceObserver);
87
+
88
+ startCwvMeasurement();
89
+
90
+ await expect(firstRoute).resolves.toEqual({ cls_total: 0.25, cls_after_reveal: 0.25 });
91
+ expect(getCwvMetrics()).toEqual({ cls_total: 0, cls_after_reveal: 0 });
92
+ expect(observes[0]).toHaveBeenCalledWith({ type: "layout-shift", buffered: true });
93
+ expect(observes[1]).toHaveBeenCalledWith({ type: "layout-shift", buffered: false });
94
+ });
95
+ });
package/src/cwv.ts ADDED
@@ -0,0 +1,129 @@
1
+ type LayoutShiftEntry = PerformanceEntry & {
2
+ value?: number;
3
+ hadRecentInput?: boolean;
4
+ };
5
+
6
+ type CwvMetrics = {
7
+ cls_total: number;
8
+ cls_after_reveal: number;
9
+ };
10
+
11
+ type Measurement = {
12
+ observer: PerformanceObserver;
13
+ revealAt: number | null;
14
+ disconnectTimer: ReturnType<typeof setTimeout> | null;
15
+ total: number;
16
+ afterReveal: number;
17
+ promise: Promise<CwvMetrics | null>;
18
+ resolve: (metrics: CwvMetrics | null) => void;
19
+ finalized: boolean;
20
+ };
21
+
22
+ let current: Measurement | null = null;
23
+ let lastMetrics: CwvMetrics = { cls_total: 0, cls_after_reveal: 0 };
24
+ let lastCompletion: Promise<CwvMetrics | null> = Promise.resolve(null);
25
+ let measurementStarted = false;
26
+
27
+ function snapshot(measurement: Measurement): CwvMetrics {
28
+ return {
29
+ cls_total: Math.round(measurement.total * 100_000) / 100_000,
30
+ cls_after_reveal: Math.round(measurement.afterReveal * 100_000) / 100_000,
31
+ };
32
+ }
33
+
34
+ function consume(measurement: Measurement, entries: PerformanceEntry[]): void {
35
+ for (const rawEntry of entries) {
36
+ const entry = rawEntry as LayoutShiftEntry;
37
+ if (entry.hadRecentInput || typeof entry.value !== "number") continue;
38
+ measurement.total += entry.value;
39
+ if (measurement.revealAt !== null && entry.startTime > measurement.revealAt) {
40
+ measurement.afterReveal += entry.value;
41
+ }
42
+ }
43
+ }
44
+
45
+ function finish(measurement = current): void {
46
+ if (!measurement || measurement.finalized) return;
47
+ measurement.finalized = true;
48
+ if (measurement.disconnectTimer !== null) {
49
+ clearTimeout(measurement.disconnectTimer);
50
+ measurement.disconnectTimer = null;
51
+ }
52
+ try {
53
+ consume(measurement, measurement.observer.takeRecords());
54
+ } catch {}
55
+ measurement.observer.disconnect();
56
+ const metrics = snapshot(measurement);
57
+ lastMetrics = metrics;
58
+ measurement.resolve(metrics);
59
+ if (current === measurement) current = null;
60
+ }
61
+
62
+ export function startCwvMeasurement(disabled?: boolean): void {
63
+ finish(current);
64
+ lastMetrics = { cls_total: 0, cls_after_reveal: 0 };
65
+
66
+ if (disabled || typeof PerformanceObserver === "undefined") {
67
+ lastCompletion = Promise.resolve(null);
68
+ return;
69
+ }
70
+ try {
71
+ let resolve!: (metrics: CwvMetrics | null) => void;
72
+ const promise = new Promise<CwvMetrics | null>((done) => { resolve = done; });
73
+ let measurement!: Measurement;
74
+ const observer = new PerformanceObserver((list) => {
75
+ if (!measurement.finalized) consume(measurement, list.getEntries());
76
+ });
77
+ measurement = {
78
+ observer,
79
+ revealAt: null,
80
+ disconnectTimer: null,
81
+ total: 0,
82
+ afterReveal: 0,
83
+ promise,
84
+ resolve,
85
+ finalized: false,
86
+ };
87
+ current = measurement;
88
+ lastCompletion = promise;
89
+ // Buffered entries belong to the initial document only. Replaying them
90
+ // into later SPA route observers would mix prior-route CLS into the new
91
+ // route's telemetry window.
92
+ observer.observe({ type: "layout-shift", buffered: !measurementStarted });
93
+ measurementStarted = true;
94
+ // Empty-project and non-visual routes may not call markCwvReveal(). Bound
95
+ // every route measurement anyway so finalized telemetry is guaranteed.
96
+ measurement.disconnectTimer = setTimeout(() => finish(measurement), 5_000);
97
+ } catch {
98
+ current?.observer.disconnect();
99
+ current?.resolve(null);
100
+ current = null;
101
+ lastCompletion = Promise.resolve(null);
102
+ }
103
+ }
104
+
105
+ export function markCwvReveal(): void {
106
+ if (!current || current.revealAt !== null) return;
107
+ current.revealAt = typeof performance !== "undefined" && typeof performance.now === "function"
108
+ ? performance.now()
109
+ : 0;
110
+ if (current.disconnectTimer !== null) clearTimeout(current.disconnectTimer);
111
+ const measurement = current;
112
+ measurement.disconnectTimer = setTimeout(() => finish(measurement), 5_000);
113
+ }
114
+
115
+ export function getCwvMetrics(): CwvMetrics {
116
+ return current ? snapshot(current) : lastMetrics;
117
+ }
118
+
119
+ export function waitForCwvMetrics(): Promise<CwvMetrics | null> {
120
+ return current?.promise ?? lastCompletion;
121
+ }
122
+
123
+ export function resetCwvMeasurement(): void {
124
+ finish(current);
125
+ current = null;
126
+ measurementStarted = false;
127
+ lastMetrics = { cls_total: 0, cls_after_reveal: 0 };
128
+ lastCompletion = Promise.resolve(null);
129
+ }
@@ -0,0 +1,21 @@
1
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
2
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
3
+ const prototype = Object.getPrototypeOf(value);
4
+ return prototype === Object.prototype || prototype === null;
5
+ }
6
+
7
+ export function normalizeDataLayerEntry(
8
+ item: unknown,
9
+ ): Record<string, unknown> | null {
10
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
11
+
12
+ const record = item as Record<PropertyKey, unknown>;
13
+ if (typeof record.event === "string") {
14
+ return item as Record<string, unknown>;
15
+ }
16
+
17
+ if (record[0] !== "event" || typeof record[1] !== "string") return null;
18
+
19
+ const params = isPlainObject(record[2]) ? record[2] : {};
20
+ return { ...params, event: record[1] };
21
+ }