@probat/react 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,764 @@
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 { Experiment } from "../components/Experiment";
6
+ import { resetDedupe } from "../utils/dedupeStorage";
7
+ import { resetInstanceIdState } from "../utils/stableInstanceId";
8
+ import { MockIntersectionObserver } from "./setup";
9
+
10
+ // ── Helpers ────────────────────────────────────────────────────────────────
11
+
12
+ function wrapper({ children }: { children: React.ReactNode }) {
13
+ return (
14
+ <ProbatProviderClient userId="test-user-id" host="https://api.test.com">
15
+ {children}
16
+ </ProbatProviderClient>
17
+ );
18
+ }
19
+
20
+ function wrapperWithBootstrap(bootstrap: Record<string, string>) {
21
+ return function BootstrapWrapper({ children }: { children: React.ReactNode }) {
22
+ return (
23
+ <ProbatProviderClient
24
+ userId="test-user-id"
25
+ host="https://api.test.com"
26
+ bootstrap={bootstrap}
27
+ >
28
+ {children}
29
+ </ProbatProviderClient>
30
+ );
31
+ };
32
+ }
33
+
34
+ // ── Setup / teardown ───────────────────────────────────────────────────────
35
+
36
+ beforeEach(() => {
37
+ vi.useFakeTimers();
38
+ localStorage.clear();
39
+ sessionStorage.clear();
40
+ resetDedupe();
41
+ resetInstanceIdState();
42
+ MockIntersectionObserver._reset();
43
+ vi.restoreAllMocks();
44
+ global.fetch = vi.fn();
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.useRealTimers();
49
+ });
50
+
51
+ // ── Assignment caching ─────────────────────────────────────────────────────
52
+
53
+ describe("assignment caching", () => {
54
+ it("caches assignment in localStorage after fetch", async () => {
55
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
56
+ ok: true,
57
+ json: () => Promise.resolve({ variant_key: "ai_v1" }),
58
+ });
59
+
60
+ render(
61
+ <Experiment
62
+ id="test-exp"
63
+ control={<div>Control</div>}
64
+ variants={{ ai_v1: <div>Variant AI</div> }}
65
+ />,
66
+ { wrapper }
67
+ );
68
+
69
+ // Initially renders control (sync)
70
+ expect(screen.getByText("Control")).toBeInTheDocument();
71
+
72
+ // Flush fetch
73
+ await act(async () => {
74
+ await vi.runAllTimersAsync();
75
+ });
76
+
77
+ expect(screen.getByText("Variant AI")).toBeInTheDocument();
78
+
79
+ // Verify localStorage
80
+ const stored = localStorage.getItem("probat:assignment:test-exp");
81
+ expect(stored).toBeTruthy();
82
+ const parsed = JSON.parse(stored!);
83
+ expect(parsed.variantKey).toBe("ai_v1");
84
+ });
85
+
86
+ it("reads cached assignment without fetching", async () => {
87
+ localStorage.setItem(
88
+ "probat:assignment:cached-exp",
89
+ JSON.stringify({ variantKey: "ai_v1", ts: Date.now() })
90
+ );
91
+
92
+ render(
93
+ <Experiment
94
+ id="cached-exp"
95
+ control={<div>Control</div>}
96
+ variants={{ ai_v1: <div>Cached Variant</div> }}
97
+ />,
98
+ { wrapper }
99
+ );
100
+
101
+ // Should render variant immediately from cache
102
+ expect(screen.getByText("Cached Variant")).toBeInTheDocument();
103
+ expect(global.fetch).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("uses bootstrap over localStorage", () => {
107
+ localStorage.setItem(
108
+ "probat:assignment:boot-exp",
109
+ JSON.stringify({ variantKey: "old_variant", ts: Date.now() })
110
+ );
111
+
112
+ render(
113
+ <Experiment
114
+ id="boot-exp"
115
+ control={<div>Control</div>}
116
+ variants={{ bootstrapped: <div>Bootstrapped</div>, old_variant: <div>Old</div> }}
117
+ />,
118
+ { wrapper: wrapperWithBootstrap({ "boot-exp": "bootstrapped" }) }
119
+ );
120
+
121
+ expect(screen.getByText("Bootstrapped")).toBeInTheDocument();
122
+ });
123
+
124
+ it("falls back to control on fetch failure", async () => {
125
+ (global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
126
+ new Error("Network error")
127
+ );
128
+
129
+ render(
130
+ <Experiment
131
+ id="fail-exp"
132
+ control={<div>Fallback Control</div>}
133
+ variants={{ ai_v1: <div>Variant</div> }}
134
+ />,
135
+ { wrapper }
136
+ );
137
+
138
+ await act(async () => {
139
+ await vi.runAllTimersAsync();
140
+ });
141
+
142
+ expect(screen.getByText("Fallback Control")).toBeInTheDocument();
143
+ // Should NOT persist fallback
144
+ expect(localStorage.getItem("probat:assignment:fail-exp")).toBeNull();
145
+ });
146
+
147
+ it("falls back to control for unknown variant key", async () => {
148
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
149
+ ok: true,
150
+ json: () => Promise.resolve({ variant_key: "nonexistent_variant" }),
151
+ });
152
+
153
+ render(
154
+ <Experiment
155
+ id="unknown-exp"
156
+ control={<div>Control</div>}
157
+ variants={{ ai_v1: <div>Variant</div> }}
158
+ debug
159
+ />,
160
+ { wrapper }
161
+ );
162
+
163
+ await act(async () => {
164
+ await vi.runAllTimersAsync();
165
+ });
166
+
167
+ expect(screen.getByText("Control")).toBeInTheDocument();
168
+ });
169
+ });
170
+
171
+ // ── Dedupe key behavior ────────────────────────────────────────────────────
172
+
173
+ describe("dedupe", () => {
174
+ it("does not double-send impressions for same experiment on same page", async () => {
175
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
176
+ // First call: decide endpoint, second+ calls: metrics
177
+ fetchMock.mockResolvedValue({
178
+ ok: true,
179
+ json: () => Promise.resolve({ variant_key: "control" }),
180
+ });
181
+
182
+ // Use explicit componentInstanceId so dedupe key is stable across mounts
183
+ const { unmount } = render(
184
+ <Experiment
185
+ id="dedupe-exp"
186
+ control={<div>Control</div>}
187
+ variants={{}}
188
+ componentInstanceId="stable-inst"
189
+ />,
190
+ { wrapper }
191
+ );
192
+
193
+ await act(async () => {
194
+ await vi.runAllTimersAsync();
195
+ });
196
+
197
+ // Trigger intersection
198
+ const observer = MockIntersectionObserver._instances[0];
199
+ if (observer) {
200
+ act(() => observer._trigger(true));
201
+ await act(async () => {
202
+ await vi.advanceTimersByTimeAsync(300);
203
+ });
204
+ }
205
+
206
+ const metricCalls1 = fetchMock.mock.calls.filter(
207
+ (c: any[]) => typeof c[0] === "string" && c[0].includes("/experiment/metrics")
208
+ );
209
+
210
+ // Unmount and remount with same instanceId
211
+ unmount();
212
+
213
+ render(
214
+ <Experiment
215
+ id="dedupe-exp"
216
+ control={<div>Control</div>}
217
+ variants={{}}
218
+ componentInstanceId="stable-inst"
219
+ />,
220
+ { wrapper }
221
+ );
222
+
223
+ await act(async () => {
224
+ await vi.runAllTimersAsync();
225
+ });
226
+
227
+ const observer2 = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
228
+ if (observer2) {
229
+ act(() => observer2._trigger(true));
230
+ await act(async () => {
231
+ await vi.advanceTimersByTimeAsync(300);
232
+ });
233
+ }
234
+
235
+ const metricCalls2 = fetchMock.mock.calls.filter(
236
+ (c: any[]) => typeof c[0] === "string" && c[0].includes("/experiment/metrics")
237
+ );
238
+
239
+ // Should only have 1 impression total (deduped on second mount)
240
+ expect(metricCalls2.length).toBe(metricCalls1.length);
241
+ });
242
+ });
243
+
244
+ // ── Click capture rules ────────────────────────────────────────────────────
245
+
246
+ describe("click tracking", () => {
247
+ beforeEach(() => {
248
+ localStorage.setItem(
249
+ "probat:assignment:click-exp",
250
+ JSON.stringify({ variantKey: "control", ts: Date.now() })
251
+ );
252
+ });
253
+
254
+ it("captures clicks on data-probat-click='primary'", async () => {
255
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
256
+ fetchMock.mockResolvedValue({ ok: true });
257
+
258
+ render(
259
+ <Experiment
260
+ id="click-exp"
261
+ control={
262
+ <div>
263
+ <span data-probat-click="primary" data-testid="primary-el">
264
+ Primary CTA
265
+ </span>
266
+ </div>
267
+ }
268
+ variants={{}}
269
+ />,
270
+ { wrapper }
271
+ );
272
+
273
+ await act(async () => {
274
+ await vi.runAllTimersAsync();
275
+ });
276
+
277
+ fireEvent.click(screen.getByTestId("primary-el"));
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_is_primary).toBe(true);
292
+ expect(payload.properties.click_target_text).toBe("Primary CTA");
293
+ });
294
+
295
+ it("captures clicks on <button>", async () => {
296
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
297
+ fetchMock.mockResolvedValue({ ok: true });
298
+
299
+ render(
300
+ <Experiment
301
+ id="click-exp"
302
+ control={<button data-testid="btn">Click Me</button>}
303
+ variants={{}}
304
+ />,
305
+ { wrapper }
306
+ );
307
+
308
+ await act(async () => {
309
+ await vi.runAllTimersAsync();
310
+ });
311
+
312
+ fireEvent.click(screen.getByTestId("btn"));
313
+
314
+ await act(async () => {
315
+ await vi.runAllTimersAsync();
316
+ });
317
+
318
+ const clickCalls = fetchMock.mock.calls.filter((c: any[]) => {
319
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
320
+ const body = JSON.parse(c[1]?.body || "{}");
321
+ return body.event === "$experiment_click";
322
+ });
323
+
324
+ expect(clickCalls.length).toBe(1);
325
+ const payload = JSON.parse(clickCalls[0][1].body);
326
+ expect(payload.properties.click_target_tag).toBe("BUTTON");
327
+ expect(payload.properties.click_is_primary).toBe(false);
328
+ });
329
+
330
+ it("captures clicks on <a>", async () => {
331
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
332
+ fetchMock.mockResolvedValue({ ok: true });
333
+
334
+ render(
335
+ <Experiment
336
+ id="click-exp"
337
+ control={<a href="#" data-testid="link">Link</a>}
338
+ variants={{}}
339
+ />,
340
+ { wrapper }
341
+ );
342
+
343
+ await act(async () => {
344
+ await vi.runAllTimersAsync();
345
+ });
346
+
347
+ fireEvent.click(screen.getByTestId("link"));
348
+
349
+ await act(async () => {
350
+ await vi.runAllTimersAsync();
351
+ });
352
+
353
+ const clickCalls = fetchMock.mock.calls.filter((c: any[]) => {
354
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
355
+ const body = JSON.parse(c[1]?.body || "{}");
356
+ return body.event === "$experiment_click";
357
+ });
358
+
359
+ expect(clickCalls.length).toBe(1);
360
+ });
361
+
362
+ it("captures clicks on role='button'", async () => {
363
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
364
+ fetchMock.mockResolvedValue({ ok: true });
365
+
366
+ render(
367
+ <Experiment
368
+ id="click-exp"
369
+ control={<div role="button" data-testid="role-btn">Role Btn</div>}
370
+ variants={{}}
371
+ />,
372
+ { wrapper }
373
+ );
374
+
375
+ await act(async () => {
376
+ await vi.runAllTimersAsync();
377
+ });
378
+
379
+ fireEvent.click(screen.getByTestId("role-btn"));
380
+
381
+ await act(async () => {
382
+ await vi.runAllTimersAsync();
383
+ });
384
+
385
+ const clickCalls = fetchMock.mock.calls.filter((c: any[]) => {
386
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
387
+ const body = JSON.parse(c[1]?.body || "{}");
388
+ return body.event === "$experiment_click";
389
+ });
390
+
391
+ expect(clickCalls.length).toBe(1);
392
+ });
393
+
394
+ it("ignores clicks on non-interactive elements", async () => {
395
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
396
+ fetchMock.mockResolvedValue({ ok: true });
397
+
398
+ render(
399
+ <Experiment
400
+ id="click-exp"
401
+ control={<p data-testid="paragraph">Just text</p>}
402
+ variants={{}}
403
+ />,
404
+ { wrapper }
405
+ );
406
+
407
+ await act(async () => {
408
+ await vi.runAllTimersAsync();
409
+ });
410
+
411
+ fireEvent.click(screen.getByTestId("paragraph"));
412
+
413
+ await act(async () => {
414
+ await vi.runAllTimersAsync();
415
+ });
416
+
417
+ const clickCalls = fetchMock.mock.calls.filter((c: any[]) => {
418
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
419
+ const body = JSON.parse(c[1]?.body || "{}");
420
+ return body.event === "$experiment_click";
421
+ });
422
+
423
+ expect(clickCalls.length).toBe(0);
424
+ });
425
+ });
426
+
427
+ // ── Impression visibility (IntersectionObserver + 250ms) ───────────────────
428
+
429
+ describe("impression tracking", () => {
430
+ beforeEach(() => {
431
+ localStorage.setItem(
432
+ "probat:assignment:vis-exp",
433
+ JSON.stringify({ variantKey: "control", ts: Date.now() })
434
+ );
435
+ });
436
+
437
+ it("sends impression after >=250ms of 50% visibility", async () => {
438
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
439
+ fetchMock.mockResolvedValue({ ok: true });
440
+
441
+ render(
442
+ <Experiment
443
+ id="vis-exp"
444
+ control={<div>Visible</div>}
445
+ variants={{}}
446
+ />,
447
+ { wrapper }
448
+ );
449
+
450
+ await act(async () => {
451
+ await vi.runAllTimersAsync();
452
+ });
453
+
454
+ const observer = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
455
+ expect(observer).toBeDefined();
456
+
457
+ // Trigger visible
458
+ act(() => observer._trigger(true));
459
+
460
+ // Before 250ms — should NOT have sent
461
+ await act(async () => {
462
+ await vi.advanceTimersByTimeAsync(200);
463
+ });
464
+
465
+ let impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
466
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
467
+ const body = JSON.parse(c[1]?.body || "{}");
468
+ return body.event === "$experiment_exposure";
469
+ });
470
+ expect(impressionCalls.length).toBe(0);
471
+
472
+ // After 250ms total — should have sent
473
+ await act(async () => {
474
+ await vi.advanceTimersByTimeAsync(100);
475
+ });
476
+
477
+ impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
478
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
479
+ const body = JSON.parse(c[1]?.body || "{}");
480
+ return body.event === "$experiment_exposure";
481
+ });
482
+ expect(impressionCalls.length).toBe(1);
483
+
484
+ const payload = JSON.parse(impressionCalls[0][1].body);
485
+ expect(payload.properties.experiment_id).toBe("vis-exp");
486
+ expect(payload.properties.variant_key).toBe("control");
487
+ });
488
+
489
+ it("cancels impression if element leaves viewport before 250ms", async () => {
490
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
491
+ fetchMock.mockResolvedValue({ ok: true });
492
+
493
+ render(
494
+ <Experiment
495
+ id="vis-exp"
496
+ control={<div>Short Visit</div>}
497
+ variants={{}}
498
+ />,
499
+ { wrapper }
500
+ );
501
+
502
+ await act(async () => {
503
+ await vi.runAllTimersAsync();
504
+ });
505
+
506
+ const observer = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
507
+
508
+ // Enter viewport
509
+ act(() => observer._trigger(true));
510
+ await act(async () => {
511
+ await vi.advanceTimersByTimeAsync(100);
512
+ });
513
+
514
+ // Leave viewport before 250ms
515
+ act(() => observer._trigger(false));
516
+ await act(async () => {
517
+ await vi.advanceTimersByTimeAsync(300);
518
+ });
519
+
520
+ const impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
521
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
522
+ const body = JSON.parse(c[1]?.body || "{}");
523
+ return body.event === "$experiment_exposure";
524
+ });
525
+ expect(impressionCalls.length).toBe(0);
526
+ });
527
+ });
528
+
529
+ // ── StrictMode double-mount safety ─────────────────────────────────────────
530
+
531
+ describe("StrictMode safety", () => {
532
+ it("does not double-count impressions in StrictMode (no componentInstanceId)", async () => {
533
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
534
+ fetchMock.mockResolvedValue({ ok: true });
535
+
536
+ localStorage.setItem(
537
+ "probat:assignment:strict-exp",
538
+ JSON.stringify({ variantKey: "control", ts: Date.now() })
539
+ );
540
+
541
+ render(
542
+ <StrictMode>
543
+ <ProbatProviderClient userId="test-user-id" host="https://api.test.com">
544
+ <Experiment
545
+ id="strict-exp"
546
+ control={<div>Strict Control</div>}
547
+ variants={{}}
548
+ />
549
+ </ProbatProviderClient>
550
+ </StrictMode>
551
+ );
552
+
553
+ await act(async () => {
554
+ await vi.runAllTimersAsync();
555
+ });
556
+
557
+ // Trigger ALL observers — the dedupe key must be identical across the
558
+ // StrictMode double-mount so that even if both observers fire only one
559
+ // impression is sent. Earlier observers have empty element sets (cleanup
560
+ // called disconnect()) so _trigger produces an empty entries array which
561
+ // the component guards with `if (!entry)`.
562
+ for (const obs of MockIntersectionObserver._instances) {
563
+ act(() => obs._trigger(true));
564
+ }
565
+
566
+ await act(async () => {
567
+ await vi.advanceTimersByTimeAsync(300);
568
+ });
569
+
570
+ const impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
571
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
572
+ const body = JSON.parse(c[1]?.body || "{}");
573
+ return body.event === "$experiment_exposure";
574
+ });
575
+
576
+ // Should be exactly 1, not 2
577
+ expect(impressionCalls.length).toBe(1);
578
+ });
579
+
580
+ it("auto instanceId is stable across StrictMode double-mount", async () => {
581
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
582
+ fetchMock.mockResolvedValue({ ok: true });
583
+
584
+ localStorage.setItem(
585
+ "probat:assignment:stable-id-exp",
586
+ JSON.stringify({ variantKey: "control", ts: Date.now() })
587
+ );
588
+
589
+ render(
590
+ <StrictMode>
591
+ <ProbatProviderClient userId="test-user-id" host="https://api.test.com">
592
+ <Experiment
593
+ id="stable-id-exp"
594
+ control={<button>Buy</button>}
595
+ variants={{}}
596
+ />
597
+ </ProbatProviderClient>
598
+ </StrictMode>
599
+ );
600
+
601
+ await act(async () => {
602
+ await vi.runAllTimersAsync();
603
+ });
604
+
605
+ // Fire a click so we get a metric payload with the auto-generated instanceId
606
+ fireEvent.click(screen.getByText("Buy"));
607
+
608
+ await act(async () => {
609
+ await vi.runAllTimersAsync();
610
+ });
611
+
612
+ // Now trigger impression via the active observer
613
+ const lastObs = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
614
+ act(() => lastObs._trigger(true));
615
+ await act(async () => {
616
+ await vi.advanceTimersByTimeAsync(300);
617
+ });
618
+
619
+ // Collect all metric payloads
620
+ const metricPayloads = fetchMock.mock.calls
621
+ .filter((c: any[]) => typeof c[0] === "string" && c[0].includes("/experiment/metrics"))
622
+ .map((c: any[]) => JSON.parse(c[1].body));
623
+
624
+ // Should have at least click + impression
625
+ expect(metricPayloads.length).toBeGreaterThanOrEqual(2);
626
+
627
+ // Every payload must share the same component_instance_id
628
+ const instanceIds = new Set(
629
+ metricPayloads.map((p: any) => p.properties.component_instance_id)
630
+ );
631
+ expect(instanceIds.size).toBe(1);
632
+
633
+ // The id should have the inst_ prefix (auto-generated, not undefined)
634
+ const theId = [...instanceIds][0] as string;
635
+ expect(theId).toMatch(/^inst_/);
636
+ });
637
+
638
+ it("persists auto instanceId mapping in sessionStorage", () => {
639
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
640
+
641
+ localStorage.setItem(
642
+ "probat:assignment:persist-exp",
643
+ JSON.stringify({ variantKey: "control", ts: Date.now() })
644
+ );
645
+
646
+ render(
647
+ <Experiment
648
+ id="persist-exp"
649
+ control={<div>Control</div>}
650
+ variants={{}}
651
+ />,
652
+ { wrapper }
653
+ );
654
+
655
+ // Check that a sessionStorage key was written for the instance mapping
656
+ const keys = Object.keys(sessionStorage);
657
+ const instanceKey = keys.find((k) => k.startsWith("probat:instance:persist-exp:"));
658
+ expect(instanceKey).toBeDefined();
659
+
660
+ // The stored value should be the stable inst_ id
661
+ const storedId = sessionStorage.getItem(instanceKey!);
662
+ expect(storedId).toMatch(/^inst_/);
663
+ });
664
+ });
665
+
666
+ // ── Event payload fields ───────────────────────────────────────────────────
667
+
668
+ describe("event payload", () => {
669
+ it("includes all required fields in exposure event", async () => {
670
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
671
+ fetchMock.mockResolvedValue({ ok: true });
672
+
673
+ localStorage.setItem(
674
+ "probat:assignment:payload-exp",
675
+ JSON.stringify({ variantKey: "ai_v1", ts: Date.now() })
676
+ );
677
+
678
+ render(
679
+ <Experiment
680
+ id="payload-exp"
681
+ control={<div>Control</div>}
682
+ variants={{ ai_v1: <div>AI Variant</div> }}
683
+ componentInstanceId="hero-cta"
684
+ />,
685
+ { wrapper }
686
+ );
687
+
688
+ await act(async () => {
689
+ await vi.runAllTimersAsync();
690
+ });
691
+
692
+ const observer = MockIntersectionObserver._instances[MockIntersectionObserver._instances.length - 1];
693
+ act(() => observer._trigger(true));
694
+ await act(async () => {
695
+ await vi.advanceTimersByTimeAsync(300);
696
+ });
697
+
698
+ const impressionCalls = fetchMock.mock.calls.filter((c: any[]) => {
699
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
700
+ const body = JSON.parse(c[1]?.body || "{}");
701
+ return body.event === "$experiment_exposure";
702
+ });
703
+
704
+ expect(impressionCalls.length).toBe(1);
705
+ const payload = JSON.parse(impressionCalls[0][1].body);
706
+
707
+ expect(payload.event).toBe("$experiment_exposure");
708
+ expect(payload.properties.experiment_id).toBe("payload-exp");
709
+ expect(payload.properties.variant_key).toBe("ai_v1");
710
+ expect(payload.properties.component_instance_id).toBe("hero-cta");
711
+ expect(payload.properties.distinct_id).toBeDefined();
712
+ expect(payload.properties.session_id).toBeDefined();
713
+ expect(payload.properties.source).toBe("react-sdk");
714
+ expect(payload.properties.captured_at).toBeDefined();
715
+ });
716
+
717
+ it("includes all required fields in click event", async () => {
718
+ const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
719
+ fetchMock.mockResolvedValue({ ok: true });
720
+
721
+ localStorage.setItem(
722
+ "probat:assignment:click-payload-exp",
723
+ JSON.stringify({ variantKey: "control", ts: Date.now() })
724
+ );
725
+
726
+ render(
727
+ <Experiment
728
+ id="click-payload-exp"
729
+ control={<button id="cta-btn">Buy Now</button>}
730
+ variants={{}}
731
+ componentInstanceId="footer-cta"
732
+ />,
733
+ { wrapper }
734
+ );
735
+
736
+ await act(async () => {
737
+ await vi.runAllTimersAsync();
738
+ });
739
+
740
+ fireEvent.click(screen.getByText("Buy Now"));
741
+
742
+ await act(async () => {
743
+ await vi.runAllTimersAsync();
744
+ });
745
+
746
+ const clickCalls = fetchMock.mock.calls.filter((c: any[]) => {
747
+ if (typeof c[0] !== "string" || !c[0].includes("/experiment/metrics")) return false;
748
+ const body = JSON.parse(c[1]?.body || "{}");
749
+ return body.event === "$experiment_click";
750
+ });
751
+
752
+ expect(clickCalls.length).toBe(1);
753
+ const payload = JSON.parse(clickCalls[0][1].body);
754
+
755
+ expect(payload.event).toBe("$experiment_click");
756
+ expect(payload.properties.experiment_id).toBe("click-payload-exp");
757
+ expect(payload.properties.variant_key).toBe("control");
758
+ expect(payload.properties.component_instance_id).toBe("footer-cta");
759
+ expect(payload.properties.click_target_tag).toBe("BUTTON");
760
+ expect(payload.properties.click_target_text).toBe("Buy Now");
761
+ expect(payload.properties.click_target_id).toBe("cta-btn");
762
+ expect(payload.properties.click_is_primary).toBe(false);
763
+ });
764
+ });