@probat/react 0.4.1 → 0.4.3

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.
@@ -0,0 +1,682 @@
1
+ import React, { StrictMode } from "react";
2
+ import { render, screen, fireEvent, act } from "@testing-library/react";
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
+ import { ProbatProviderClient } from "../components/ProbatProviderClient";
5
+ import { useTrack } from "../hooks/useTrack";
6
+ import { resetDedupe } from "../utils/dedupeStorage";
7
+ import { resetInstanceIdState } from "../utils/stableInstanceId";
8
+ import { MockIntersectionObserver } from "./setup";
9
+
10
+ // ── Test component that uses useTrack ──────────────────────────────────────
11
+
12
+ function TrackedContent(props: {
13
+ experimentId: string;
14
+ variantKey: string;
15
+ componentInstanceId?: string;
16
+ impression?: boolean;
17
+ click?: boolean;
18
+ children: React.ReactNode;
19
+ }) {
20
+ const { experimentId, variantKey, componentInstanceId, impression, click, children } = props;
21
+ const trackRef = useTrack({
22
+ experimentId,
23
+ variantKey,
24
+ componentInstanceId,
25
+ impression,
26
+ click,
27
+ });
28
+
29
+ return (
30
+ <div ref={trackRef as React.RefObject<HTMLDivElement>} data-testid="tracked">
31
+ {children}
32
+ </div>
33
+ );
34
+ }
35
+
36
+ // ── Helpers ────────────────────────────────────────────────────────────────
37
+
38
+ function wrapper({ children }: { children: React.ReactNode }) {
39
+ return (
40
+ <ProbatProviderClient customerId="test-customer-id" host="https://api.test.com">
41
+ {children}
42
+ </ProbatProviderClient>
43
+ );
44
+ }
45
+
46
+ // ── Setup / teardown ───────────────────────────────────────────────────────
47
+
48
+ beforeEach(() => {
49
+ vi.useFakeTimers();
50
+ localStorage.clear();
51
+ sessionStorage.clear();
52
+ resetDedupe();
53
+ resetInstanceIdState();
54
+ MockIntersectionObserver._reset();
55
+ vi.restoreAllMocks();
56
+ global.fetch = vi.fn().mockResolvedValue({ ok: true });
57
+ });
58
+
59
+ afterEach(() => {
60
+ vi.useRealTimers();
61
+ });
62
+
63
+ // ── Impression tracking ───────────────────────────────────────────────────
64
+
65
+ describe("useTrack impression tracking", () => {
66
+ it("sends impression after >=250ms of 50% visibility", async () => {
67
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
68
+
69
+ render(
70
+ <TrackedContent experimentId="vis-exp" variantKey="ai_v1">
71
+ <div>Visible</div>
72
+ </TrackedContent>,
73
+ { wrapper }
74
+ );
75
+
76
+ await act(async () => {
77
+ await vi.runAllTimersAsync();
78
+ });
79
+
80
+ const observer = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
81
+ expect(observer).toBeDefined();
82
+
83
+ act(() => observer._trigger(true));
84
+
85
+ // Before 250ms
86
+ await act(async () => {
87
+ await vi.advanceTimersByTimeAsync(200);
88
+ });
89
+
90
+ let impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
91
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
92
+ const body = JSON.parse(c[1]?.body || "{}");
93
+ return body.event === "$experiment_exposure";
94
+ });
95
+ expect(impressionCalls.length).toBe(0);
96
+
97
+ // After 250ms
98
+ await act(async () => {
99
+ await vi.advanceTimersByTimeAsync(100);
100
+ });
101
+
102
+ impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
103
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
104
+ const body = JSON.parse(c[1]?.body || "{}");
105
+ return body.event === "$experiment_exposure";
106
+ });
107
+ expect(impressionCalls.length).toBe(1);
108
+
109
+ const payload = JSON.parse(impressionCalls[0][1].body);
110
+ expect(payload.properties.experiment_id).toBe("vis-exp");
111
+ expect(payload.properties.variant_key).toBe("ai_v1");
112
+ });
113
+
114
+ it("cancels impression if element leaves viewport before 250ms", async () => {
115
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
116
+
117
+ render(
118
+ <TrackedContent experimentId="cancel-exp" variantKey="control">
119
+ <div>Short Visit</div>
120
+ </TrackedContent>,
121
+ { wrapper }
122
+ );
123
+
124
+ await act(async () => {
125
+ await vi.runAllTimersAsync();
126
+ });
127
+
128
+ const observer = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
129
+
130
+ act(() => observer._trigger(true));
131
+ await act(async () => {
132
+ await vi.advanceTimersByTimeAsync(100);
133
+ });
134
+
135
+ act(() => observer._trigger(false));
136
+ await act(async () => {
137
+ await vi.advanceTimersByTimeAsync(300);
138
+ });
139
+
140
+ const impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
141
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
142
+ const body = JSON.parse(c[1]?.body || "{}");
143
+ return body.event === "$experiment_exposure";
144
+ });
145
+ expect(impressionCalls.length).toBe(0);
146
+ });
147
+
148
+ it("dedupes impressions across unmount/remount with stable componentInstanceId", async () => {
149
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
150
+
151
+ const { unmount } = render(
152
+ <TrackedContent experimentId="dedupe-exp" variantKey="control" componentInstanceId="stable-inst">
153
+ <div>Content</div>
154
+ </TrackedContent>,
155
+ { wrapper }
156
+ );
157
+
158
+ await act(async () => {
159
+ await vi.runAllTimersAsync();
160
+ });
161
+
162
+ const observer1 = MockIntersectionObserver._instances[0];
163
+ if (observer1) {
164
+ act(() => observer1._trigger(true));
165
+ await act(async () => {
166
+ await vi.advanceTimersByTimeAsync(300);
167
+ });
168
+ }
169
+
170
+ const metricCalls1 = fetchMock.mock.calls.filter(
171
+ (c: any[]) => typeof c[0] === "string" && c[0].includes("/experiment/metrics")
172
+ );
173
+
174
+ unmount();
175
+
176
+ render(
177
+ <TrackedContent experimentId="dedupe-exp" variantKey="control" componentInstanceId="stable-inst">
178
+ <div>Content</div>
179
+ </TrackedContent>,
180
+ { wrapper }
181
+ );
182
+
183
+ await act(async () => {
184
+ await vi.runAllTimersAsync();
185
+ });
186
+
187
+ const observer2 = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
188
+ if (observer2) {
189
+ act(() => observer2._trigger(true));
190
+ await act(async () => {
191
+ await vi.advanceTimersByTimeAsync(300);
192
+ });
193
+ }
194
+
195
+ const metricCalls2 = fetchMock.mock.calls.filter(
196
+ (c: any[]) => typeof c[0] === "string" && c[0].includes("/experiment/metrics")
197
+ );
198
+
199
+ expect(metricCalls2.length).toBe(metricCalls1.length);
200
+ });
201
+
202
+ it("does not track impressions when impression=false", async () => {
203
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
204
+
205
+ render(
206
+ <TrackedContent experimentId="no-imp-exp" variantKey="ai_v1" impression={false}>
207
+ <div>No Track</div>
208
+ </TrackedContent>,
209
+ { wrapper }
210
+ );
211
+
212
+ await act(async () => {
213
+ await vi.runAllTimersAsync();
214
+ });
215
+
216
+ // No observer should be created for this component
217
+ const impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
218
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
219
+ const body = JSON.parse(c[1]?.body || "{}");
220
+ return body.event === "$experiment_exposure";
221
+ });
222
+ expect(impressionCalls.length).toBe(0);
223
+ });
224
+ });
225
+
226
+ // ── Click tracking ────────────────────────────────────────────────────────
227
+
228
+ describe("useTrack click tracking", () => {
229
+ it("captures clicks on data-probat-click='primary'", async () => {
230
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
231
+
232
+ render(
233
+ <TrackedContent experimentId="click-exp" variantKey="control">
234
+ <span data-probat-click="primary" data-testid="primary-el">
235
+ Primary CTA
236
+ </span>
237
+ </TrackedContent>,
238
+ { wrapper }
239
+ );
240
+
241
+ await act(async () => {
242
+ await vi.runAllTimersAsync();
243
+ });
244
+
245
+ fireEvent.click(screen.getByTestId("primary-el"));
246
+
247
+ await act(async () => {
248
+ await vi.runAllTimersAsync();
249
+ });
250
+
251
+ const clickCalls = fetchMock.mock.calls.filter((c: any[]) => {
252
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
253
+ const body = JSON.parse(c[1]?.body || "{}");
254
+ return body.event === "$experiment_click";
255
+ });
256
+
257
+ expect(clickCalls.length).toBe(1);
258
+ const payload = JSON.parse(clickCalls[0][1].body);
259
+ expect(payload.properties.click_is_primary).toBe(true);
260
+ expect(payload.properties.click_target_text).toBe("Primary CTA");
261
+ });
262
+
263
+ it("captures clicks on <button>", async () => {
264
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
265
+
266
+ render(
267
+ <TrackedContent experimentId="click-exp" variantKey="control">
268
+ <button data-testid="btn">Click Me</button>
269
+ </TrackedContent>,
270
+ { wrapper }
271
+ );
272
+
273
+ await act(async () => {
274
+ await vi.runAllTimersAsync();
275
+ });
276
+
277
+ fireEvent.click(screen.getByTestId("btn"));
278
+
279
+ await act(async () => {
280
+ await vi.runAllTimersAsync();
281
+ });
282
+
283
+ const clickCalls = fetchMock.mock.calls.filter((c: any[]) => {
284
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
285
+ const body = JSON.parse(c[1]?.body || "{}");
286
+ return body.event === "$experiment_click";
287
+ });
288
+
289
+ expect(clickCalls.length).toBe(1);
290
+ const payload = JSON.parse(clickCalls[0][1].body);
291
+ expect(payload.properties.click_target_tag).toBe("BUTTON");
292
+ expect(payload.properties.click_is_primary).toBe(false);
293
+ });
294
+
295
+ it("ignores clicks on non-interactive elements", async () => {
296
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
297
+
298
+ render(
299
+ <TrackedContent experimentId="click-exp" variantKey="control">
300
+ <p data-testid="paragraph">Just text</p>
301
+ </TrackedContent>,
302
+ { wrapper }
303
+ );
304
+
305
+ await act(async () => {
306
+ await vi.runAllTimersAsync();
307
+ });
308
+
309
+ fireEvent.click(screen.getByTestId("paragraph"));
310
+
311
+ await act(async () => {
312
+ await vi.runAllTimersAsync();
313
+ });
314
+
315
+ const clickCalls = fetchMock.mock.calls.filter((c: any[]) => {
316
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
317
+ const body = JSON.parse(c[1]?.body || "{}");
318
+ return body.event === "$experiment_click";
319
+ });
320
+
321
+ expect(clickCalls.length).toBe(0);
322
+ });
323
+
324
+ it("does not track clicks when click=false", async () => {
325
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
326
+
327
+ render(
328
+ <TrackedContent experimentId="no-click-exp" variantKey="control" click={false}>
329
+ <button data-testid="btn">Click Me</button>
330
+ </TrackedContent>,
331
+ { wrapper }
332
+ );
333
+
334
+ await act(async () => {
335
+ await vi.runAllTimersAsync();
336
+ });
337
+
338
+ fireEvent.click(screen.getByTestId("btn"));
339
+
340
+ await act(async () => {
341
+ await vi.runAllTimersAsync();
342
+ });
343
+
344
+ const clickCalls = fetchMock.mock.calls.filter((c: any[]) => {
345
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
346
+ const body = JSON.parse(c[1]?.body || "{}");
347
+ return body.event === "$experiment_click";
348
+ });
349
+
350
+ expect(clickCalls.length).toBe(0);
351
+ });
352
+ });
353
+
354
+ // ── Event payload fields ──────────────────────────────────────────────────
355
+
356
+ describe("useTrack event payload", () => {
357
+ it("includes experiment_id, variant_key, and distinct_id in events", async () => {
358
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
359
+
360
+ render(
361
+ <TrackedContent experimentId="payload-exp" variantKey="ai_v1" componentInstanceId="hero-cta">
362
+ <div>Content</div>
363
+ </TrackedContent>,
364
+ { wrapper }
365
+ );
366
+
367
+ await act(async () => {
368
+ await vi.runAllTimersAsync();
369
+ });
370
+
371
+ const observer = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
372
+ act(() => observer._trigger(true));
373
+ await act(async () => {
374
+ await vi.advanceTimersByTimeAsync(300);
375
+ });
376
+
377
+ const impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
378
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
379
+ const body = JSON.parse(c[1]?.body || "{}");
380
+ return body.event === "$experiment_exposure";
381
+ });
382
+
383
+ expect(impressionCalls.length).toBe(1);
384
+ const payload = JSON.parse(impressionCalls[0][1].body);
385
+
386
+ expect(payload.properties.experiment_id).toBe("payload-exp");
387
+ expect(payload.properties.variant_key).toBe("ai_v1");
388
+ expect(payload.properties.component_instance_id).toBe("hero-cta");
389
+ expect(payload.properties.distinct_id).toBe("test-customer-id");
390
+ expect(payload.properties.source).toBe("react-sdk");
391
+ expect(payload.properties.captured_at).toBeDefined();
392
+ });
393
+ });
394
+
395
+ // ── Customer mode ─────────────────────────────────────────────────────────
396
+
397
+ function CustomerTrackedContent(props: {
398
+ experimentId: string;
399
+ customerId?: string;
400
+ componentInstanceId?: string;
401
+ impression?: boolean;
402
+ click?: boolean;
403
+ children: React.ReactNode;
404
+ }) {
405
+ const { experimentId, customerId, componentInstanceId, impression, click, children } = props;
406
+ const trackRef = useTrack({
407
+ experimentId,
408
+ customerId,
409
+ componentInstanceId,
410
+ impression,
411
+ click,
412
+ });
413
+
414
+ return (
415
+ <div ref={trackRef as React.RefObject<HTMLDivElement>} data-testid="tracked">
416
+ {children}
417
+ </div>
418
+ );
419
+ }
420
+
421
+ describe("useTrack customer mode", () => {
422
+ it("sends impression without variant_key but with distinct_id + experiment_id", async () => {
423
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
424
+
425
+ render(
426
+ <CustomerTrackedContent experimentId="cust-exp" customerId="user_42">
427
+ <div>Content</div>
428
+ </CustomerTrackedContent>,
429
+ { wrapper }
430
+ );
431
+
432
+ await act(async () => {
433
+ await vi.runAllTimersAsync();
434
+ });
435
+
436
+ const observer = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
437
+ act(() => observer._trigger(true));
438
+ await act(async () => {
439
+ await vi.advanceTimersByTimeAsync(300);
440
+ });
441
+
442
+ const impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
443
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
444
+ const body = JSON.parse(c[1]?.body || "{}");
445
+ return body.event === "$experiment_exposure";
446
+ });
447
+
448
+ expect(impressionCalls.length).toBe(1);
449
+ const payload = JSON.parse(impressionCalls[0][1].body);
450
+ expect(payload.properties.experiment_id).toBe("cust-exp");
451
+ expect(payload.properties.distinct_id).toBe("user_42");
452
+ expect(payload.properties).not.toHaveProperty("variant_key");
453
+ });
454
+
455
+ it("dedupes impressions using customerId in the dedupe key", async () => {
456
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
457
+
458
+ const { unmount } = render(
459
+ <CustomerTrackedContent experimentId="cust-dedupe" customerId="user_42" componentInstanceId="stable">
460
+ <div>Content</div>
461
+ </CustomerTrackedContent>,
462
+ { wrapper }
463
+ );
464
+
465
+ await act(async () => {
466
+ await vi.runAllTimersAsync();
467
+ });
468
+
469
+ const observer1 = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
470
+ act(() => observer1._trigger(true));
471
+ await act(async () => {
472
+ await vi.advanceTimersByTimeAsync(300);
473
+ });
474
+
475
+ const callsAfterFirst = fetchMock.mock.calls.filter((c: any[]) => {
476
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
477
+ const body = JSON.parse(c[1]?.body || "{}");
478
+ return body.event === "$experiment_exposure";
479
+ });
480
+ expect(callsAfterFirst.length).toBe(1);
481
+
482
+ unmount();
483
+
484
+ render(
485
+ <CustomerTrackedContent experimentId="cust-dedupe" customerId="user_42" componentInstanceId="stable">
486
+ <div>Content</div>
487
+ </CustomerTrackedContent>,
488
+ { wrapper }
489
+ );
490
+
491
+ await act(async () => {
492
+ await vi.runAllTimersAsync();
493
+ });
494
+
495
+ const observer2 = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
496
+ act(() => observer2._trigger(true));
497
+ await act(async () => {
498
+ await vi.advanceTimersByTimeAsync(300);
499
+ });
500
+
501
+ const callsAfterSecond = fetchMock.mock.calls.filter((c: any[]) => {
502
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
503
+ const body = JSON.parse(c[1]?.body || "{}");
504
+ return body.event === "$experiment_exposure";
505
+ });
506
+ // Should still be 1 -- deduped
507
+ expect(callsAfterSecond.length).toBe(1);
508
+ });
509
+
510
+ it("tracks clicks in customer mode", async () => {
511
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
512
+
513
+ render(
514
+ <CustomerTrackedContent experimentId="cust-click" customerId="user_42">
515
+ <button data-testid="btn">Buy</button>
516
+ </CustomerTrackedContent>,
517
+ { wrapper }
518
+ );
519
+
520
+ await act(async () => {
521
+ await vi.runAllTimersAsync();
522
+ });
523
+
524
+ fireEvent.click(screen.getByTestId("btn"));
525
+
526
+ await act(async () => {
527
+ await vi.runAllTimersAsync();
528
+ });
529
+
530
+ const clickCalls = fetchMock.mock.calls.filter((c: any[]) => {
531
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
532
+ const body = JSON.parse(c[1]?.body || "{}");
533
+ return body.event === "$experiment_click";
534
+ });
535
+
536
+ expect(clickCalls.length).toBe(1);
537
+ const payload = JSON.parse(clickCalls[0][1].body);
538
+ expect(payload.properties.experiment_id).toBe("cust-click");
539
+ expect(payload.properties.distinct_id).toBe("user_42");
540
+ expect(payload.properties).not.toHaveProperty("variant_key");
541
+ });
542
+
543
+ it("falls back to provider customerId when customerId option is omitted", async () => {
544
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
545
+
546
+ render(
547
+ <CustomerTrackedContent experimentId="cust-fallback">
548
+ <div>Content</div>
549
+ </CustomerTrackedContent>,
550
+ { wrapper }
551
+ );
552
+
553
+ await act(async () => {
554
+ await vi.runAllTimersAsync();
555
+ });
556
+
557
+ const observer = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
558
+ act(() => observer._trigger(true));
559
+ await act(async () => {
560
+ await vi.advanceTimersByTimeAsync(300);
561
+ });
562
+
563
+ const impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
564
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
565
+ const body = JSON.parse(c[1]?.body || "{}");
566
+ return body.event === "$experiment_exposure";
567
+ });
568
+
569
+ expect(impressionCalls.length).toBe(1);
570
+ const payload = JSON.parse(impressionCalls[0][1].body);
571
+ // Falls back to provider's customerId ("test-customer-id" from wrapper)
572
+ expect(payload.properties.distinct_id).toBe("test-customer-id");
573
+ expect(payload.properties).not.toHaveProperty("variant_key");
574
+ });
575
+
576
+ it("warns when neither variantKey nor customerId is available", async () => {
577
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
578
+
579
+ function NoCustomerProvider({ children }: { children: React.ReactNode }) {
580
+ return (
581
+ <ProbatProviderClient host="https://api.test.com">
582
+ {children}
583
+ </ProbatProviderClient>
584
+ );
585
+ }
586
+
587
+ render(
588
+ <NoCustomerProvider>
589
+ <CustomerTrackedContent experimentId="no-id-exp" impression={true}>
590
+ <div>Content</div>
591
+ </CustomerTrackedContent>
592
+ </NoCustomerProvider>
593
+ );
594
+
595
+ await act(async () => {
596
+ await vi.runAllTimersAsync();
597
+ });
598
+
599
+ // debug is false by default, so the warning should not fire
600
+ expect(warnSpy).not.toHaveBeenCalled();
601
+
602
+ warnSpy.mockRestore();
603
+ });
604
+
605
+ it("warns when debug=true and no customerId available", async () => {
606
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
607
+
608
+ function NoCustomerProvider({ children }: { children: React.ReactNode }) {
609
+ return (
610
+ <ProbatProviderClient host="https://api.test.com">
611
+ {children}
612
+ </ProbatProviderClient>
613
+ );
614
+ }
615
+
616
+ function DebugCustomerTrackedContent() {
617
+ const trackRef = useTrack({
618
+ experimentId: "warn-exp",
619
+ debug: true,
620
+ });
621
+ return (
622
+ <div ref={trackRef as React.RefObject<HTMLDivElement>} data-testid="tracked">
623
+ Content
624
+ </div>
625
+ );
626
+ }
627
+
628
+ render(
629
+ <NoCustomerProvider>
630
+ <DebugCustomerTrackedContent />
631
+ </NoCustomerProvider>
632
+ );
633
+
634
+ await act(async () => {
635
+ await vi.runAllTimersAsync();
636
+ });
637
+
638
+ expect(warnSpy).toHaveBeenCalledWith(
639
+ expect.stringContaining("no customerId")
640
+ );
641
+
642
+ warnSpy.mockRestore();
643
+ });
644
+ });
645
+
646
+ // ── StrictMode safety ─────────────────────────────────────────────────────
647
+
648
+ describe("useTrack StrictMode safety", () => {
649
+ it("does not double-count impressions in StrictMode", async () => {
650
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
651
+
652
+ render(
653
+ <StrictMode>
654
+ <ProbatProviderClient customerId="test-customer-id" host="https://api.test.com">
655
+ <TrackedContent experimentId="strict-exp" variantKey="control">
656
+ <div>Strict Content</div>
657
+ </TrackedContent>
658
+ </ProbatProviderClient>
659
+ </StrictMode>
660
+ );
661
+
662
+ await act(async () => {
663
+ await vi.runAllTimersAsync();
664
+ });
665
+
666
+ for (const obs of MockIntersectionObserver._instances) {
667
+ act(() => obs._trigger(true));
668
+ }
669
+
670
+ await act(async () => {
671
+ await vi.advanceTimersByTimeAsync(300);
672
+ });
673
+
674
+ const impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
675
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
676
+ const body = JSON.parse(c[1]?.body || "{}");
677
+ return body.event === "$experiment_exposure";
678
+ });
679
+
680
+ expect(impressionCalls.length).toBe(1);
681
+ });
682
+ });