@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,817 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+ import { Tracker } from "./track";
3
+ import { configureIdentityStorage } from "./hash";
4
+
5
+ function makeSessionStorage() {
6
+ const map = new Map<string, string>();
7
+ return {
8
+ getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
9
+ setItem: (k: string, v: string) => { map.set(k, v); },
10
+ removeItem: (k: string) => { map.delete(k); },
11
+ };
12
+ }
13
+
14
+ describe("Tracker flush resilience", () => {
15
+ const realFetch = globalThis.fetch;
16
+ const realWindow = globalThis.window;
17
+ const realDocument = globalThis.document;
18
+ const realSessionStorage = (globalThis as any).sessionStorage;
19
+ const realLocalStorage = (globalThis as any).localStorage;
20
+ const realNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
21
+
22
+ function setNavigator(value: unknown): void {
23
+ Object.defineProperty(globalThis, "navigator", {
24
+ configurable: true,
25
+ value,
26
+ });
27
+ }
28
+
29
+ beforeEach(() => {
30
+ vi.useFakeTimers();
31
+ (globalThis as any).sessionStorage = makeSessionStorage();
32
+ (globalThis as any).localStorage = makeSessionStorage();
33
+ (globalThis as any).window = {
34
+ setInterval: (fn: any, _ms: number) => {
35
+ // keep tests deterministic: don't run interval automatically
36
+ return 1;
37
+ },
38
+ clearInterval: () => {},
39
+ addEventListener: () => {},
40
+ removeEventListener: () => {},
41
+ };
42
+ });
43
+
44
+ afterEach(() => {
45
+ vi.useRealTimers();
46
+ globalThis.fetch = realFetch;
47
+ (globalThis as any).window = realWindow;
48
+ (globalThis as any).document = realDocument;
49
+ if (realNavigatorDescriptor) {
50
+ Object.defineProperty(globalThis, "navigator", realNavigatorDescriptor);
51
+ } else {
52
+ delete (globalThis as any).navigator;
53
+ }
54
+ (globalThis as any).sessionStorage = realSessionStorage;
55
+ (globalThis as any).localStorage = realLocalStorage;
56
+ vi.restoreAllMocks();
57
+ });
58
+
59
+ it("never persists or stamps the session count without a lifetime store", () => {
60
+ configureIdentityStorage({ storageMode: "sessionStorage" });
61
+ try {
62
+ const tracker = new Tracker("shop", "https://worker.example");
63
+ tracker.track({ type: "pageview", data: {} });
64
+ expect(localStorage.getItem("drip_visitor_sessions_v1")).toBeNull();
65
+ const stored = JSON.parse(localStorage.getItem("drip_events") ?? sessionStorage.getItem("drip_events") ?? "[]");
66
+ for (const event of stored) {
67
+ expect(event?.data?.visitor_sessions).toBeUndefined();
68
+ }
69
+ } finally {
70
+ configureIdentityStorage({ storageMode: "localStorage" });
71
+ }
72
+ });
73
+
74
+ it("counts lifetime visitor sessions without double-counting tracker re-initialization", () => {
75
+ sessionStorage.setItem("drip_sid", "session-one");
76
+
77
+ new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
78
+ new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
79
+ expect(JSON.parse(String(localStorage.getItem("drip_visitor_sessions_v1")))).toEqual({
80
+ count: 1,
81
+ recentSessionIds: ["session-one"],
82
+ });
83
+
84
+ sessionStorage.setItem("drip_sid", "session-two");
85
+ new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
86
+ expect(JSON.parse(String(localStorage.getItem("drip_visitor_sessions_v1")))).toEqual({
87
+ count: 2,
88
+ recentSessionIds: ["session-two", "session-one"],
89
+ });
90
+ });
91
+
92
+ it("does not re-count remembered sessions when parallel tabs alternate", () => {
93
+ // Tab A (session-one) and tab B (session-two) each count once…
94
+ sessionStorage.setItem("drip_sid", "session-one");
95
+ new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
96
+ sessionStorage.setItem("drip_sid", "session-two");
97
+ new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
98
+ // …and a reload of tab A must not increment again.
99
+ sessionStorage.setItem("drip_sid", "session-one");
100
+ new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
101
+
102
+ expect(JSON.parse(String(localStorage.getItem("drip_visitor_sessions_v1")))).toEqual({
103
+ count: 2,
104
+ recentSessionIds: ["session-two", "session-one"],
105
+ });
106
+ });
107
+
108
+ it("migrates a legacy lastSessionId record without re-counting it", () => {
109
+ localStorage.setItem(
110
+ "drip_visitor_sessions_v1",
111
+ JSON.stringify({ count: 3, lastSessionId: "legacy-session" }),
112
+ );
113
+ sessionStorage.setItem("drip_sid", "legacy-session");
114
+
115
+ new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
116
+
117
+ expect(
118
+ JSON.parse(String(localStorage.getItem("drip_visitor_sessions_v1"))).count,
119
+ ).toBe(3);
120
+ });
121
+
122
+ it("restarts the visitor session count after storage is cleared", () => {
123
+ localStorage.setItem(
124
+ "drip_visitor_sessions_v1",
125
+ JSON.stringify({ count: 7, lastSessionId: "old-session" }),
126
+ );
127
+ localStorage.removeItem("drip_visitor_sessions_v1");
128
+ sessionStorage.setItem("drip_sid", "new-session");
129
+
130
+ new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
131
+
132
+ expect(JSON.parse(String(localStorage.getItem("drip_visitor_sessions_v1")))).toEqual({
133
+ count: 1,
134
+ recentSessionIds: ["new-session"],
135
+ });
136
+ });
137
+
138
+ it("recovers malformed visitor session storage at one", () => {
139
+ localStorage.setItem("drip_visitor_sessions_v1", "not-json");
140
+ sessionStorage.setItem("drip_sid", "recovered-session");
141
+
142
+ new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
143
+
144
+ expect(JSON.parse(String(localStorage.getItem("drip_visitor_sessions_v1")))).toEqual({
145
+ count: 1,
146
+ recentSessionIds: ["recovered-session"],
147
+ });
148
+ });
149
+
150
+ it("stamps result-relevant events without overwriting merchant data", async () => {
151
+ const bodies: Array<{ events: Array<{ type: string; data?: Record<string, unknown> }> }> = [];
152
+ globalThis.fetch = vi.fn(async (_url: string, init: RequestInit) => {
153
+ bodies.push(JSON.parse(String(init.body)));
154
+ return new Response("ok", { status: 202 });
155
+ }) as any;
156
+ sessionStorage.setItem("drip_sid", "session-one");
157
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
158
+
159
+ tracker.track({ type: "experiment_viewed", data: {} });
160
+ tracker.track({ type: "personalization_rendered", data: {} });
161
+ tracker.track({ type: "goal", data: { goalId: "signup" } });
162
+ tracker.track({ type: "conversion" });
163
+ tracker.track({ type: "pageview", data: { path: "/" } });
164
+ tracker.track({ type: "custom", data: {} });
165
+ tracker.track({ type: "goal", data: { visitor_sessions: 99 } });
166
+ await tracker.flush();
167
+
168
+ expect(bodies[0].events.map((event) => [event.type, event.data?.visitor_sessions])).toEqual([
169
+ ["experiment_viewed", 1],
170
+ ["personalization_rendered", 1],
171
+ ["goal", 1],
172
+ ["conversion", 1],
173
+ ["pageview", 1],
174
+ ["custom", undefined],
175
+ ["goal", 99],
176
+ ]);
177
+ });
178
+
179
+ it("keeps sending events without visitor_sessions when localStorage throws", async () => {
180
+ (globalThis as any).localStorage = {
181
+ getItem: () => { throw new Error("storage denied"); },
182
+ setItem: () => { throw new Error("storage denied"); },
183
+ };
184
+ const bodies: Array<{ events: Array<{ data?: Record<string, unknown> }> }> = [];
185
+ globalThis.fetch = vi.fn(async (_url: string, init: RequestInit) => {
186
+ bodies.push(JSON.parse(String(init.body)));
187
+ return new Response("ok", { status: 202 });
188
+ }) as any;
189
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
190
+ tracker.track({ type: "pageview", data: { path: "/" } });
191
+
192
+ await tracker.flush();
193
+
194
+ expect(bodies[0].events[0].data).toEqual({ path: "/" });
195
+ });
196
+
197
+ it("re-queues events on 429 and respects Retry-After backoff", async () => {
198
+ const fetchMock = vi.fn(async () => new Response("rate limit", {
199
+ status: 429,
200
+ headers: { "Retry-After": "2" },
201
+ }));
202
+ globalThis.fetch = fetchMock as any;
203
+
204
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
205
+ tracker.start();
206
+ tracker.track({ type: "pageview", data: {} } as any);
207
+
208
+ await tracker.flush();
209
+ expect(fetchMock).toHaveBeenCalledTimes(1);
210
+
211
+ // Immediate second flush should be skipped due to backoff.
212
+ await tracker.flush();
213
+ expect(fetchMock).toHaveBeenCalledTimes(1);
214
+
215
+ // After >2s, flush should attempt again.
216
+ // Backoff includes small jitter; advance enough to clear it.
217
+ vi.advanceTimersByTime(3000);
218
+ await tracker.flush();
219
+ expect(fetchMock).toHaveBeenCalledTimes(2);
220
+ });
221
+
222
+ it("drains queued events in bounded batches before awaited flush resolves", async () => {
223
+ const seenCounts: number[] = [];
224
+ const fetchMock = vi.fn(async (_url: string, init: any) => {
225
+ const body = JSON.parse(init.body);
226
+ seenCounts.push(Array.isArray(body.events) ? body.events.length : 0);
227
+ return new Response("ok", { status: 202 });
228
+ });
229
+ globalThis.fetch = fetchMock as any;
230
+
231
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "s", maxBatchSize: 100 });
232
+ tracker.start();
233
+ for (let i = 0; i < 60; i++) tracker.track({ type: "pageview", data: { i } } as any);
234
+ (tracker as any).maxBatchSize = 20;
235
+
236
+ await tracker.flush();
237
+ expect(fetchMock).toHaveBeenCalledTimes(3);
238
+ expect(seenCounts).toEqual([20, 20, 20]);
239
+ });
240
+
241
+ it("adds event IDs before sending and preserves caller-provided non-blank IDs", async () => {
242
+ const bodies: Array<{ events: Array<{ event_id?: string; type: string }> }> = [];
243
+ const fetchMock = vi.fn(async (_url: string, init: any) => {
244
+ bodies.push(JSON.parse(init.body));
245
+ return new Response("ok", { status: 202 });
246
+ });
247
+ globalThis.fetch = fetchMock as any;
248
+
249
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
250
+ tracker.start();
251
+ tracker.track({ type: "pageview", data: {} } as any);
252
+ tracker.track({ type: "blank_id", event_id: "", data: {} } as any);
253
+ tracker.track({ type: "lead_submitted", event_id: "evt_known", data: {} } as any);
254
+
255
+ await tracker.flush();
256
+
257
+ expect(bodies).toHaveLength(1);
258
+ expect(bodies[0].events[0].event_id).toEqual(expect.any(String));
259
+ expect(bodies[0].events[0].event_id).not.toBe("");
260
+ expect(bodies[0].events[1].event_id).toEqual(expect.any(String));
261
+ expect(bodies[0].events[1].event_id).not.toBe("");
262
+ expect(bodies[0].events[2]).toMatchObject({
263
+ type: "lead_submitted",
264
+ event_id: "evt_known",
265
+ });
266
+ });
267
+
268
+ it("awaits an in-flight automatic flush before resolving an explicit flush", async () => {
269
+ const seenCounts: number[] = [];
270
+ let resolveFirst: (() => void) | null = null;
271
+ const firstResponse = new Promise<Response>((resolve) => {
272
+ resolveFirst = () => resolve(new Response("ok", { status: 202 }));
273
+ });
274
+ let callCount = 0;
275
+ const fetchMock = vi.fn(async (_url: string, init: any) => {
276
+ const body = JSON.parse(init.body);
277
+ seenCounts.push(Array.isArray(body.events) ? body.events.length : 0);
278
+ callCount += 1;
279
+ if (callCount === 1) return firstResponse;
280
+ return new Response("ok", { status: 202 });
281
+ });
282
+ globalThis.fetch = fetchMock as any;
283
+
284
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "s", maxBatchSize: 20 });
285
+ tracker.start();
286
+ for (let i = 0; i < 20; i++) tracker.track({ type: "pageview", data: { i } } as any);
287
+ await Promise.resolve();
288
+ expect(fetchMock).toHaveBeenCalledTimes(1);
289
+
290
+ for (let i = 20; i < 25; i++) tracker.track({ type: "pageview", data: { i } } as any);
291
+ let resolved = false;
292
+ const joined = tracker.flush().then(() => {
293
+ resolved = true;
294
+ });
295
+ await Promise.resolve();
296
+ expect(resolved).toBe(false);
297
+
298
+ resolveFirst?.();
299
+ await joined;
300
+ expect(resolved).toBe(true);
301
+ expect(fetchMock).toHaveBeenCalledTimes(2);
302
+ expect(seenCounts).toEqual([20, 5]);
303
+ });
304
+
305
+ it("flushes pagehide events with sendBeacon and does not resend them later", async () => {
306
+ const windowListeners: Record<string, () => void> = {};
307
+ const sendBeacon = vi.fn(() => true);
308
+ const fetchMock = vi.fn(async () => new Response("ok", { status: 202 }));
309
+ setNavigator({ sendBeacon });
310
+ globalThis.fetch = fetchMock as any;
311
+ (globalThis as any).window = {
312
+ setInterval: () => 1,
313
+ clearInterval: () => {},
314
+ addEventListener: (name: string, handler: () => void) => {
315
+ windowListeners[name] = handler;
316
+ },
317
+ removeEventListener: () => {},
318
+ };
319
+
320
+ const tracker = new Tracker({
321
+ endpoint: "https://e.test/events",
322
+ shopId: "shop_public",
323
+ ingestShopId: "shop_ingest",
324
+ ingestSignature: "sig_1",
325
+ });
326
+ tracker.start();
327
+ tracker.track({ type: "checkout_started", data: {} } as any);
328
+
329
+ windowListeners.pagehide?.();
330
+ await tracker.flush();
331
+
332
+ expect(sendBeacon).toHaveBeenCalledTimes(1);
333
+ expect(fetchMock).not.toHaveBeenCalled();
334
+ const [, body] = sendBeacon.mock.calls[0];
335
+ expect(body).toBeInstanceOf(Blob);
336
+ expect((body as Blob).type).toBe("text/plain");
337
+ const payload = JSON.parse(await (body as Blob).text());
338
+ expect(payload).toMatchObject({
339
+ _shopId: "shop_ingest",
340
+ _signature: "sig_1",
341
+ });
342
+ expect(payload.events).toHaveLength(1);
343
+ expect(payload.events[0]).toMatchObject({ type: "checkout_started" });
344
+ });
345
+
346
+ it("persists page-exit batches when beacon is refused and keepalive fetch rejects", async () => {
347
+ const windowListeners: Record<string, () => void> = {};
348
+ const sendBeacon = vi.fn(() => false);
349
+ const fetchMock = vi.fn()
350
+ .mockRejectedValueOnce(new Error("network unavailable"))
351
+ .mockResolvedValueOnce(new Response("ok", { status: 202 }));
352
+ setNavigator({ sendBeacon });
353
+ globalThis.fetch = fetchMock as any;
354
+ (globalThis as any).window = {
355
+ setInterval: () => 1,
356
+ clearInterval: () => {},
357
+ addEventListener: (name: string, handler: () => void) => {
358
+ windowListeners[name] = handler;
359
+ },
360
+ removeEventListener: () => {},
361
+ };
362
+
363
+ const tracker = new Tracker({
364
+ endpoint: "https://e.test/events",
365
+ shopId: "shop_public",
366
+ environment: "staging",
367
+ });
368
+ tracker.start();
369
+ tracker.track({ type: "checkout_started", data: {} } as any);
370
+
371
+ windowListeners.pagehide?.();
372
+ await vi.waitFor(() => expect(sessionStorage.getItem("drip_events")).not.toBeNull());
373
+
374
+ const recovered = JSON.parse(String(sessionStorage.getItem("drip_events")));
375
+ expect(recovered).toHaveLength(1);
376
+ expect(recovered[0]).toMatchObject({ type: "checkout_started", environment: "staging" });
377
+
378
+ const nextTracker = new Tracker({
379
+ endpoint: "https://e.test/events",
380
+ shopId: "shop_public",
381
+ environment: "production",
382
+ });
383
+ nextTracker.start();
384
+ await nextTracker.flush();
385
+
386
+ expect(fetchMock).toHaveBeenCalledTimes(2);
387
+ const payload = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body));
388
+ expect(payload.events).toHaveLength(1);
389
+ expect(payload.events[0]).toMatchObject({
390
+ type: "checkout_started",
391
+ event_id: recovered[0].event_id,
392
+ environment: "staging",
393
+ });
394
+ expect(sessionStorage.getItem("drip_events")).toBeNull();
395
+ });
396
+
397
+ it("merges reverse-order unload failures without dropping parallel batches", async () => {
398
+ const windowListeners: Record<string, () => void> = {};
399
+ const rejectors: Array<(error: Error) => void> = [];
400
+ setNavigator({ sendBeacon: vi.fn(() => false) });
401
+ globalThis.fetch = vi.fn(() => new Promise<Response>((_resolve, reject) => {
402
+ rejectors.push(reject);
403
+ })) as any;
404
+ (globalThis as any).window = {
405
+ setInterval: () => 1,
406
+ clearInterval: () => {},
407
+ addEventListener: (name: string, handler: () => void) => {
408
+ windowListeners[name] = handler;
409
+ },
410
+ removeEventListener: () => {},
411
+ };
412
+
413
+ const tracker = new Tracker({
414
+ endpoint: "https://e.test/events",
415
+ shopId: "shop_public",
416
+ maxBatchSize: 100,
417
+ });
418
+ tracker.start();
419
+ for (let i = 0; i < 5; i++) {
420
+ tracker.track({ type: "checkout_step", event_id: `evt_${i}`, data: { i } } as any);
421
+ }
422
+ (tracker as any).maxBatchSize = 2;
423
+
424
+ windowListeners.pagehide?.();
425
+ expect(rejectors).toHaveLength(3);
426
+ rejectors[2](new Error("third failed"));
427
+ await Promise.resolve();
428
+ rejectors[0](new Error("first failed"));
429
+ await Promise.resolve();
430
+ rejectors[1](new Error("second failed"));
431
+ await vi.waitFor(() => {
432
+ const recovered = JSON.parse(String(sessionStorage.getItem("drip_events")));
433
+ expect(recovered).toHaveLength(5);
434
+ });
435
+
436
+ const recovered = JSON.parse(String(sessionStorage.getItem("drip_events")));
437
+ expect(new Set(recovered.map((event: { event_id: string }) => event.event_id))).toEqual(
438
+ new Set(["evt_0", "evt_1", "evt_2", "evt_3", "evt_4"]),
439
+ );
440
+ });
441
+
442
+ it("keeps differently typed recovery events with the same scoped event ID", () => {
443
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "shop" });
444
+ (tracker as any).saveRecovery([
445
+ { type: "goal", event_id: "evt_shared", experimentId: "exp", variationId: "var" },
446
+ { type: "revenue", event_id: "evt_shared", experimentId: "exp", variationId: "var" },
447
+ ]);
448
+
449
+ const recovered = JSON.parse(String(sessionStorage.getItem("drip_events")));
450
+ expect(recovered.map((event: { type: string }) => event.type)).toEqual(["goal", "revenue"]);
451
+ });
452
+
453
+ it("keeps recovery events from different environments with the same scoped event ID", () => {
454
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "shop" });
455
+ (tracker as any).saveRecovery([
456
+ {
457
+ type: "goal",
458
+ event_id: "evt_shared",
459
+ experimentId: "exp",
460
+ variationId: "var",
461
+ environment: "production",
462
+ },
463
+ {
464
+ type: "goal",
465
+ event_id: "evt_shared",
466
+ experimentId: "exp",
467
+ variationId: "var",
468
+ environment: "staging",
469
+ },
470
+ ]);
471
+
472
+ const recovered = JSON.parse(String(sessionStorage.getItem("drip_events")));
473
+ expect(recovered.map((event: { environment: string }) => event.environment)).toEqual([
474
+ "production",
475
+ "staging",
476
+ ]);
477
+ });
478
+
479
+ it("recovers an active in-flight batch when its keepalive request fails after pagehide", async () => {
480
+ const windowListeners: Record<string, () => void> = {};
481
+ const rejectors: Array<(error: Error) => void> = [];
482
+ setNavigator({ sendBeacon: vi.fn(() => false) });
483
+ globalThis.fetch = vi.fn(() => new Promise<Response>((_resolve, reject) => {
484
+ rejectors.push(reject);
485
+ })) as any;
486
+ (globalThis as any).window = {
487
+ setInterval: () => 1,
488
+ clearInterval: () => {},
489
+ addEventListener: (name: string, handler: () => void) => {
490
+ windowListeners[name] = handler;
491
+ },
492
+ removeEventListener: () => {},
493
+ };
494
+
495
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "shop", maxBatchSize: 2 });
496
+ tracker.start();
497
+ tracker.track({ type: "goal", event_id: "evt_parallel", experimentId: "exp_a", variationId: "var_a" } as any);
498
+ tracker.track({ type: "goal", event_id: "evt_parallel", experimentId: "exp_b", variationId: "var_b" } as any);
499
+ await Promise.resolve();
500
+ expect(rejectors).toHaveLength(1);
501
+
502
+ windowListeners.pagehide?.();
503
+ expect(rejectors).toHaveLength(1);
504
+ rejectors[0](new Error("original failed"));
505
+ await vi.waitFor(() => {
506
+ const recovered = JSON.parse(String(sessionStorage.getItem("drip_events")));
507
+ expect(recovered).toHaveLength(2);
508
+ });
509
+
510
+ const recovered = JSON.parse(String(sessionStorage.getItem("drip_events")));
511
+ expect(recovered.map((event: { experimentId: string }) => event.experimentId).sort()).toEqual([
512
+ "exp_a",
513
+ "exp_b",
514
+ ]);
515
+ });
516
+
517
+ it("recovers an active keepalive batch when the Worker rejects it after pagehide", async () => {
518
+ const windowListeners: Record<string, () => void> = {};
519
+ let resolveRequest: ((response: Response) => void) | null = null;
520
+ globalThis.fetch = vi.fn(() => new Promise<Response>((resolve) => {
521
+ resolveRequest = resolve;
522
+ })) as any;
523
+ setNavigator({ sendBeacon: vi.fn(() => true) });
524
+ (globalThis as any).window = {
525
+ setInterval: () => 1,
526
+ clearInterval: () => {},
527
+ addEventListener: (name: string, handler: () => void) => {
528
+ windowListeners[name] = handler;
529
+ },
530
+ removeEventListener: () => {},
531
+ };
532
+
533
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "shop", maxBatchSize: 1 });
534
+ tracker.start();
535
+ tracker.track({ type: "goal", event_id: "evt_rejected" } as any);
536
+ await Promise.resolve();
537
+
538
+ windowListeners.pagehide?.();
539
+ resolveRequest?.(new Response("unavailable", { status: 503 }));
540
+ await tracker.flush();
541
+
542
+ const recovered = JSON.parse(String(sessionStorage.getItem("drip_events")));
543
+ expect(recovered).toEqual([expect.objectContaining({ event_id: "evt_rejected" })]);
544
+ });
545
+
546
+ it("clears terminal recovery after the surviving page later receives an acknowledgement", async () => {
547
+ const windowListeners: Record<string, () => void> = {};
548
+ const fetchMock = vi.fn()
549
+ .mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
550
+ .mockResolvedValueOnce(new Response("accepted", { status: 202 }));
551
+ globalThis.fetch = fetchMock as any;
552
+ setNavigator({ sendBeacon: vi.fn(() => true) });
553
+ (globalThis as any).window = {
554
+ setInterval: () => 1,
555
+ clearInterval: () => {},
556
+ addEventListener: (name: string, handler: () => void) => {
557
+ windowListeners[name] = handler;
558
+ },
559
+ removeEventListener: () => {},
560
+ };
561
+
562
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "shop", maxBatchSize: 1 });
563
+ tracker.start();
564
+ tracker.track({ type: "goal", event_id: "evt_survived" } as any);
565
+ windowListeners.pagehide?.();
566
+ await tracker.flush();
567
+ expect(sessionStorage.getItem("drip_events")).not.toBeNull();
568
+
569
+ vi.advanceTimersByTime(2_000);
570
+ await tracker.flush();
571
+
572
+ expect(fetchMock).toHaveBeenCalledTimes(2);
573
+ expect(sessionStorage.getItem("drip_events")).toBeNull();
574
+ });
575
+
576
+ it("does not duplicate an active keepalive batch when its request succeeds after pagehide", async () => {
577
+ const windowListeners: Record<string, () => void> = {};
578
+ const resolvers: Array<(response: Response) => void> = [];
579
+ const fetchMock = vi.fn(() => new Promise<Response>((resolve) => {
580
+ resolvers.push(resolve);
581
+ }));
582
+ setNavigator({ sendBeacon: vi.fn(() => false) });
583
+ globalThis.fetch = fetchMock as any;
584
+ (globalThis as any).window = {
585
+ setInterval: () => 1,
586
+ clearInterval: () => {},
587
+ addEventListener: (name: string, handler: () => void) => {
588
+ windowListeners[name] = handler;
589
+ },
590
+ removeEventListener: () => {},
591
+ };
592
+
593
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "shop" });
594
+ tracker.start();
595
+ tracker.track({ type: "goal", event_id: "evt_pagehide", experimentId: "exp", variationId: "var" } as any);
596
+ const originalFlush = tracker.flush();
597
+ await Promise.resolve();
598
+ expect(fetchMock).toHaveBeenCalledTimes(1);
599
+
600
+ windowListeners.pagehide?.();
601
+ expect(fetchMock).toHaveBeenCalledTimes(1);
602
+ const originalPayload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
603
+ expect(originalPayload.events[0]).toMatchObject({
604
+ event_id: "evt_pagehide",
605
+ experimentId: "exp",
606
+ variationId: "var",
607
+ });
608
+
609
+ resolvers[0](new Response("ok", { status: 202 }));
610
+ await originalFlush;
611
+ await vi.waitFor(() => expect(sessionStorage.getItem("drip_events")).toBeNull());
612
+ });
613
+
614
+ it("does not persist a recovery copy when the active keepalive request succeeds", async () => {
615
+ const windowListeners: Record<string, () => void> = {};
616
+ const pending: Array<{
617
+ resolve: (response: Response) => void;
618
+ reject: (error: Error) => void;
619
+ }> = [];
620
+ const fetchMock = vi.fn(() => new Promise<Response>((resolve, reject) => {
621
+ pending.push({ resolve, reject });
622
+ }));
623
+ setNavigator({ sendBeacon: vi.fn(() => false) });
624
+ globalThis.fetch = fetchMock as any;
625
+ (globalThis as any).window = {
626
+ setInterval: () => 1,
627
+ clearInterval: () => {},
628
+ addEventListener: (name: string, handler: () => void) => {
629
+ windowListeners[name] = handler;
630
+ },
631
+ removeEventListener: () => {},
632
+ };
633
+
634
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "shop" });
635
+ tracker.start();
636
+ tracker.track({ type: "goal", event_id: "evt_mixed", experimentId: "exp", variationId: "var" } as any);
637
+ const originalFlush = tracker.flush();
638
+ await Promise.resolve();
639
+
640
+ windowListeners.pagehide?.();
641
+ expect(fetchMock).toHaveBeenCalledTimes(1);
642
+
643
+ pending[0].resolve(new Response("ok", { status: 202 }));
644
+ await originalFlush;
645
+
646
+ expect(sessionStorage.getItem("drip_events")).toBeNull();
647
+ });
648
+
649
+ it("flushes only once across hidden, pagehide, and beforeunload signals", async () => {
650
+ const windowListeners: Record<string, () => void> = {};
651
+ const documentListeners: Record<string, () => void> = {};
652
+ const sendBeacon = vi.fn(() => true);
653
+ setNavigator({ sendBeacon });
654
+ (globalThis as any).window = {
655
+ setInterval: () => 1,
656
+ clearInterval: () => {},
657
+ addEventListener: (name: string, handler: () => void) => {
658
+ windowListeners[name] = handler;
659
+ },
660
+ removeEventListener: () => {},
661
+ };
662
+ (globalThis as any).document = {
663
+ visibilityState: "hidden",
664
+ addEventListener: (name: string, handler: () => void) => {
665
+ documentListeners[name] = handler;
666
+ },
667
+ removeEventListener: () => {},
668
+ };
669
+
670
+ const tracker = new Tracker({
671
+ endpoint: "https://e.test/events",
672
+ shopId: "shop",
673
+ });
674
+ tracker.start();
675
+ tracker.track({ type: "page_exit", event_id: "evt_exit" } as any);
676
+
677
+ documentListeners.visibilitychange?.();
678
+ windowListeners.pagehide?.();
679
+ windowListeners.beforeunload?.();
680
+
681
+ expect(sendBeacon).toHaveBeenCalledTimes(1);
682
+ const payload = JSON.parse(await (sendBeacon.mock.calls[0][1] as Blob).text());
683
+ expect(payload.events).toEqual([expect.objectContaining({ event_id: "evt_exit" })]);
684
+
685
+ (document as any).visibilityState = "visible";
686
+ documentListeners.visibilitychange?.();
687
+ tracker.track({ type: "page_exit", event_id: "evt_next_exit" } as any);
688
+ (document as any).visibilityState = "hidden";
689
+ documentListeners.visibilitychange?.();
690
+
691
+ expect(sendBeacon).toHaveBeenCalledTimes(2);
692
+ const nextPayload = JSON.parse(await (sendBeacon.mock.calls[1][1] as Blob).text());
693
+ expect(nextPayload.events).toEqual([expect.objectContaining({ event_id: "evt_next_exit" })]);
694
+ });
695
+
696
+ it("does not persist an active failure when pagehide enters the back-forward cache", async () => {
697
+ const windowListeners: Record<string, (event?: { persisted?: boolean }) => void> = {};
698
+ let rejectRequest: ((error: Error) => void) | null = null;
699
+ globalThis.fetch = vi.fn(() => new Promise<Response>((_resolve, reject) => {
700
+ rejectRequest = reject;
701
+ })) as any;
702
+ setNavigator({ sendBeacon: vi.fn(() => true) });
703
+ (globalThis as any).window = {
704
+ setInterval: () => 1,
705
+ clearInterval: () => {},
706
+ addEventListener: (name: string, handler: (event?: { persisted?: boolean }) => void) => {
707
+ windowListeners[name] = handler;
708
+ },
709
+ removeEventListener: () => {},
710
+ };
711
+
712
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "shop", maxBatchSize: 1 });
713
+ tracker.start();
714
+ tracker.track({ type: "goal", event_id: "evt_bfcache" } as any);
715
+ await Promise.resolve();
716
+
717
+ windowListeners.pagehide?.({ persisted: true });
718
+ rejectRequest?.(new Error("network unavailable"));
719
+ await tracker.flush();
720
+
721
+ windowListeners.pageshow?.({ persisted: true });
722
+ expect(sessionStorage.getItem("drip_events")).toBeNull();
723
+ });
724
+
725
+ it("resets provisional teardown state when beforeunload is canceled", async () => {
726
+ const windowListeners: Record<string, () => void> = {};
727
+ let rejectRequest: ((error: Error) => void) | null = null;
728
+ globalThis.fetch = vi.fn(() => new Promise<Response>((_resolve, reject) => {
729
+ rejectRequest = reject;
730
+ })) as any;
731
+ setNavigator({ sendBeacon: vi.fn(() => true) });
732
+ (globalThis as any).window = {
733
+ setInterval: () => 1,
734
+ clearInterval: () => {},
735
+ setTimeout: (handler: () => void, ms: number) => globalThis.setTimeout(handler, ms),
736
+ clearTimeout: (id: number) => globalThis.clearTimeout(id),
737
+ addEventListener: (name: string, handler: () => void) => {
738
+ windowListeners[name] = handler;
739
+ },
740
+ removeEventListener: () => {},
741
+ };
742
+
743
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "shop", maxBatchSize: 1 });
744
+ tracker.start();
745
+ tracker.track({ type: "goal", event_id: "evt_canceled" } as any);
746
+ await Promise.resolve();
747
+
748
+ windowListeners.beforeunload?.();
749
+ await vi.runOnlyPendingTimersAsync();
750
+ rejectRequest?.(new Error("network unavailable"));
751
+ await tracker.flush();
752
+
753
+ expect(sessionStorage.getItem("drip_events")).toBeNull();
754
+ });
755
+
756
+ it("falls back to keepalive fetch on visibilitychange hidden when beacon is unavailable", async () => {
757
+ const documentListeners: Record<string, () => void> = {};
758
+ const fetchMock = vi.fn(async () => new Response("ok", { status: 202 }));
759
+ globalThis.fetch = fetchMock as any;
760
+ (globalThis as any).window = {
761
+ setInterval: () => 1,
762
+ clearInterval: () => {},
763
+ addEventListener: () => {},
764
+ removeEventListener: () => {},
765
+ };
766
+ (globalThis as any).document = {
767
+ visibilityState: "hidden",
768
+ addEventListener: (name: string, handler: () => void) => {
769
+ documentListeners[name] = handler;
770
+ },
771
+ removeEventListener: () => {},
772
+ };
773
+ setNavigator({ sendBeacon: undefined });
774
+
775
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "shop_public" });
776
+ tracker.start();
777
+ tracker.track({ type: "checkout_started", data: {} } as any);
778
+
779
+ documentListeners.visibilitychange?.();
780
+ await tracker.flush();
781
+
782
+ expect(fetchMock).toHaveBeenCalledTimes(1);
783
+ expect(fetchMock).toHaveBeenCalledWith(
784
+ "https://e.test/events",
785
+ expect.objectContaining({
786
+ method: "POST",
787
+ keepalive: true,
788
+ }),
789
+ );
790
+ const payload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
791
+ expect(payload.events).toHaveLength(1);
792
+ expect(payload.events[0]).toMatchObject({ type: "checkout_started" });
793
+ });
794
+
795
+ it("removes unload listeners when stopped", () => {
796
+ const addEventListener = vi.fn();
797
+ const removeEventListener = vi.fn();
798
+ (globalThis as any).window = {
799
+ setInterval: () => 1,
800
+ clearInterval: () => {},
801
+ addEventListener,
802
+ removeEventListener,
803
+ };
804
+
805
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "s" });
806
+ tracker.start();
807
+ tracker.stop();
808
+
809
+ expect(addEventListener).toHaveBeenCalledWith("pagehide", expect.any(Function));
810
+ expect(addEventListener).toHaveBeenCalledWith("beforeunload", expect.any(Function));
811
+ expect(addEventListener).toHaveBeenCalledWith("pageshow", expect.any(Function));
812
+ expect(removeEventListener).toHaveBeenCalledWith("pagehide", expect.any(Function));
813
+ expect(removeEventListener).toHaveBeenCalledWith("beforeunload", expect.any(Function));
814
+ expect(removeEventListener).toHaveBeenCalledWith("pageshow", expect.any(Function));
815
+ expect(removeEventListener.mock.calls[0][1]).toBe(addEventListener.mock.calls[0][1]);
816
+ });
817
+ });