@probat/react 0.2.1 → 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.
- package/README.md +33 -344
- package/dist/index.d.mts +76 -247
- package/dist/index.d.ts +76 -247
- package/dist/index.js +395 -1357
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +392 -1341
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -11
- package/src/__tests__/Experiment.test.tsx +764 -0
- package/src/__tests__/setup.ts +63 -0
- package/src/__tests__/utils.test.ts +79 -0
- package/src/components/Experiment.tsx +291 -0
- package/src/components/ProbatProviderClient.tsx +19 -7
- package/src/context/ProbatContext.tsx +30 -152
- package/src/hooks/useProbatMetrics.ts +18 -134
- package/src/index.ts +9 -32
- package/src/utils/api.ts +96 -577
- package/src/utils/dedupeStorage.ts +40 -0
- package/src/utils/eventContext.ts +94 -0
- package/src/utils/stableInstanceId.ts +113 -0
- package/src/utils/storage.ts +18 -60
- package/src/hoc/itrt-frontend.code-workspace +0 -10
- package/src/hoc/withExperiment.tsx +0 -311
- package/src/hooks/useExperiment.ts +0 -188
- package/src/utils/documentClickTracker.ts +0 -215
- package/src/utils/heatmapTracker.ts +0 -665
|
@@ -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
|
+
});
|