@sessionsight/split-testing 1.0.0 → 1.0.1

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.
@@ -1,224 +0,0 @@
1
- import { test, expect, beforeEach, mock } from 'bun:test';
2
- import {
3
- getCachedConfig,
4
- setCachedConfig,
5
- getCachedAssignments,
6
- setCachedAssignments,
7
- clearCache,
8
- } from '../src/cache';
9
- import { SplitTestingClient } from '../src/client';
10
- import type { SplitTestConfigResponse, Assignment } from '../src/types';
11
-
12
- // Storage map injected by test/setup.ts preload
13
- const storage: Map<string, string> = (globalThis as any).__testStorage;
14
-
15
- // ── Helpers ────────────────────────────────────────────────────────
16
-
17
- const PROPERTY = 'prop-1';
18
- const VISITOR = 'visitor-1';
19
-
20
- const fakeConfigResponse: SplitTestConfigResponse = {
21
- tests: [
22
- {
23
- key: 'hero-test',
24
- id: 'test-id-1',
25
- type: 'text',
26
- status: 'running',
27
- hashSeed: 'seed-abc',
28
- trafficAllocation: 100,
29
- variations: [
30
- { key: 'control', weight: 50, value: 'Hello' },
31
- { key: 'variant-a', weight: 50, value: 'Hey there' },
32
- ],
33
- },
34
- ],
35
- ttl: 300,
36
- };
37
-
38
- function makeClient(overrides: Record<string, any> = {}): SplitTestingClient {
39
- return new SplitTestingClient({
40
- publicApiKey: 'pk_test',
41
- propertyId: PROPERTY,
42
- apiUrl: 'https://api.test.com',
43
- visitorId: VISITOR,
44
- ...overrides,
45
- });
46
- }
47
-
48
- beforeEach(() => {
49
- storage.clear();
50
- });
51
-
52
- // ════════════════════════════════════════════════════════════════════
53
- // Cache tests
54
- // ════════════════════════════════════════════════════════════════════
55
-
56
- test('setCachedConfig / getCachedConfig round trip', () => {
57
- setCachedConfig(PROPERTY, fakeConfigResponse);
58
- const cached = getCachedConfig(PROPERTY);
59
- expect(cached).not.toBeNull();
60
- expect(cached!.data.tests[0].key).toBe('hero-test');
61
- expect(typeof cached!.fetchedAt).toBe('number');
62
- });
63
-
64
- test('getCachedConfig returns null when nothing is stored', () => {
65
- expect(getCachedConfig('nonexistent')).toBeNull();
66
- });
67
-
68
- test('setCachedAssignments / getCachedAssignments round trip', () => {
69
- const assignments: Record<string, Assignment> = {
70
- 'hero-test': {
71
- testKey: 'hero-test',
72
- variationIndex: 1,
73
- variationKey: 'variant-a',
74
- value: 'Hey there',
75
- type: 'text',
76
- inTest: true,
77
- },
78
- };
79
- setCachedAssignments(PROPERTY, VISITOR, assignments);
80
- const cached = getCachedAssignments(PROPERTY, VISITOR);
81
- expect(cached).not.toBeNull();
82
- expect(cached!['hero-test'].variationKey).toBe('variant-a');
83
- });
84
-
85
- test('clearCache removes config and assignment entries for the property', () => {
86
- setCachedConfig(PROPERTY, fakeConfigResponse);
87
- setCachedAssignments(PROPERTY, VISITOR, {
88
- 'hero-test': {
89
- testKey: 'hero-test',
90
- variationIndex: 0,
91
- variationKey: 'control',
92
- value: 'Hello',
93
- type: 'text',
94
- inTest: true,
95
- },
96
- });
97
- expect(storage.size).toBeGreaterThan(0);
98
-
99
- clearCache(PROPERTY);
100
-
101
- expect(getCachedConfig(PROPERTY)).toBeNull();
102
- expect(getCachedAssignments(PROPERTY, VISITOR)).toBeNull();
103
- });
104
-
105
- test('clearCache does not remove entries for a different property', () => {
106
- setCachedConfig('prop-other', fakeConfigResponse);
107
- setCachedConfig(PROPERTY, fakeConfigResponse);
108
-
109
- clearCache(PROPERTY);
110
-
111
- expect(getCachedConfig(PROPERTY)).toBeNull();
112
- expect(getCachedConfig('prop-other')).not.toBeNull();
113
- });
114
-
115
- // ════════════════════════════════════════════════════════════════════
116
- // Client tests
117
- // ════════════════════════════════════════════════════════════════════
118
-
119
- test('get() returns default value before init', () => {
120
- const client = makeClient();
121
- expect(client.get('hero-test', 'fallback')).toBe('fallback');
122
- });
123
-
124
- test('get() returns cached assignment after init with pre-cached config', async () => {
125
- // Pre-populate cache so no fetch is needed
126
- setCachedConfig(PROPERTY, fakeConfigResponse);
127
-
128
- const client = makeClient({ maxAge: 999_999_999, staleTTL: 999_999_999 });
129
- await client.init();
130
-
131
- const value = client.get('hero-test', 'fallback');
132
- // Should be one of the variation values, not the fallback
133
- expect(['Hello', 'Hey there']).toContain(value);
134
- });
135
-
136
- test('get() fetches config from API when cache is empty', async () => {
137
- const originalFetch = globalThis.fetch;
138
- globalThis.fetch = mock(async (url: string | URL | Request) => {
139
- const urlStr = typeof url === 'string' ? url : url.toString();
140
- if (urlStr.includes('/v1/split-testing/config')) {
141
- return new Response(JSON.stringify(fakeConfigResponse), {
142
- status: 200,
143
- headers: { 'Content-Type': 'application/json' },
144
- });
145
- }
146
- return new Response('{}', { status: 200 });
147
- }) as any;
148
-
149
- try {
150
- const client = makeClient();
151
- await client.init();
152
-
153
- const value = client.get('hero-test', 'fallback');
154
- expect(['Hello', 'Hey there']).toContain(value);
155
- // Config should now be cached
156
- expect(getCachedConfig(PROPERTY)).not.toBeNull();
157
- } finally {
158
- globalThis.fetch = originalFetch;
159
- }
160
- });
161
-
162
- test('trackConversion sends POST with correct payload', async () => {
163
- const calls: Array<{ url: string; body: any }> = [];
164
- const originalFetch = globalThis.fetch;
165
- globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
166
- const urlStr = typeof url === 'string' ? url : url.toString();
167
- if (init?.body) {
168
- calls.push({ url: urlStr, body: JSON.parse(init.body as string) });
169
- }
170
- if (urlStr.includes('/v1/split-testing/config')) {
171
- return new Response(JSON.stringify(fakeConfigResponse), {
172
- status: 200,
173
- headers: { 'Content-Type': 'application/json' },
174
- });
175
- }
176
- return new Response('{}', { status: 200 });
177
- }) as any;
178
-
179
- // Disable sendBeacon so it falls through to fetch
180
- const origBeacon = globalThis.navigator.sendBeacon;
181
- globalThis.navigator.sendBeacon = (() => false) as any;
182
-
183
- try {
184
- const client = makeClient();
185
- await client.init();
186
-
187
- client.trackConversion('goal-signup', { value: 42, currency: 'USD' });
188
-
189
- // Find the conversion call
190
- const conversionCall = calls.find((c) => c.url.includes('/convert'));
191
- expect(conversionCall).toBeDefined();
192
- expect(conversionCall!.body.goalId).toBe('goal-signup');
193
- expect(conversionCall!.body.propertyId).toBe(PROPERTY);
194
- expect(conversionCall!.body.visitorId).toBe(VISITOR);
195
- expect(conversionCall!.body.value).toBe(42);
196
- expect(conversionCall!.body.currency).toBe('USD');
197
- } finally {
198
- globalThis.fetch = originalFetch;
199
- globalThis.navigator.sendBeacon = origBeacon;
200
- }
201
- });
202
-
203
- test('getAssignments returns variation indices keyed by test key', async () => {
204
- setCachedConfig(PROPERTY, fakeConfigResponse);
205
-
206
- const client = makeClient({ maxAge: 999_999_999, staleTTL: 999_999_999 });
207
- await client.init();
208
-
209
- const assignments = client.getAssignments();
210
- expect(assignments).toHaveProperty('hero-test');
211
- expect(typeof assignments['hero-test']).toBe('number');
212
- });
213
-
214
- test('destroy clears internal state', async () => {
215
- setCachedConfig(PROPERTY, fakeConfigResponse);
216
-
217
- const client = makeClient({ maxAge: 999_999_999, staleTTL: 999_999_999 });
218
- await client.init();
219
-
220
- client.destroy();
221
-
222
- // After destroy, get() should return default
223
- expect(client.get('hero-test', 'fallback')).toBe('fallback');
224
- });
package/test/hash.test.ts DELETED
@@ -1,85 +0,0 @@
1
- import { test, expect } from 'bun:test';
2
- import { splitTestHash, assignVariation } from '../src/hash';
3
-
4
- test('splitTestHash returns consistent results', () => {
5
- const h1 = splitTestHash('seed-abc', 'visitor-123');
6
- const h2 = splitTestHash('seed-abc', 'visitor-123');
7
- expect(h1).toBe(h2);
8
- });
9
-
10
- test('splitTestHash returns values in 0-9999', () => {
11
- for (let i = 0; i < 100; i++) {
12
- const h = splitTestHash(`seed-${i}`, `visitor-${i}`);
13
- expect(h).toBeGreaterThanOrEqual(0);
14
- expect(h).toBeLessThan(10000);
15
- }
16
- });
17
-
18
- test('splitTestHash produces different values for different visitors', () => {
19
- const h1 = splitTestHash('seed', 'visitor-1');
20
- const h2 = splitTestHash('seed', 'visitor-2');
21
- expect(h1).not.toBe(h2);
22
- });
23
-
24
- test('splitTestHash produces different values for different seeds', () => {
25
- const h1 = splitTestHash('seed-a', 'visitor');
26
- const h2 = splitTestHash('seed-b', 'visitor');
27
- expect(h1).not.toBe(h2);
28
- });
29
-
30
- test('assignVariation puts visitor in test when within traffic allocation', () => {
31
- const result = assignVariation(500, 100, [
32
- { key: 'control', weight: 50 },
33
- { key: 'variant-a', weight: 50 },
34
- ]);
35
- expect(result.inTest).toBe(true);
36
- });
37
-
38
- test('assignVariation excludes visitor when outside traffic allocation', () => {
39
- const result = assignVariation(9500, 50, [
40
- { key: 'control', weight: 50 },
41
- { key: 'variant-a', weight: 50 },
42
- ]);
43
- expect(result.inTest).toBe(false);
44
- expect(result.variationIndex).toBe(0); // defaults to control
45
- });
46
-
47
- test('assignVariation distributes evenly with equal weights', () => {
48
- const counts = [0, 0];
49
- for (let i = 0; i < 10000; i++) {
50
- const result = assignVariation(i, 100, [
51
- { key: 'control', weight: 50 },
52
- { key: 'variant-a', weight: 50 },
53
- ]);
54
- counts[result.variationIndex]!++;
55
- }
56
- // Should be roughly 50/50
57
- expect(counts[0]).toBeGreaterThan(4000);
58
- expect(counts[0]).toBeLessThan(6000);
59
- expect(counts[1]).toBeGreaterThan(4000);
60
- expect(counts[1]).toBeLessThan(6000);
61
- });
62
-
63
- test('assignVariation respects unequal weights', () => {
64
- const counts = [0, 0, 0];
65
- for (let i = 0; i < 10000; i++) {
66
- const result = assignVariation(i, 100, [
67
- { key: 'control', weight: 70 },
68
- { key: 'variant-a', weight: 20 },
69
- { key: 'variant-b', weight: 10 },
70
- ]);
71
- counts[result.variationIndex]!++;
72
- }
73
- // control ~70%, variant-a ~20%, variant-b ~10%
74
- expect(counts[0]).toBeGreaterThan(6000);
75
- expect(counts[1]).toBeGreaterThan(1000);
76
- expect(counts[2]).toBeGreaterThan(500);
77
- });
78
-
79
- test('assignVariation handles zero traffic allocation', () => {
80
- const result = assignVariation(500, 0, [
81
- { key: 'control', weight: 50 },
82
- { key: 'variant-a', weight: 50 },
83
- ]);
84
- expect(result.inTest).toBe(false);
85
- });
package/test/setup.ts DELETED
@@ -1,21 +0,0 @@
1
- // Preload: set up browser globals before any module evaluates canUseStorage
2
-
3
- const storage = new Map<string, string>();
4
- globalThis.localStorage = {
5
- getItem: (key: string) => storage.get(key) ?? null,
6
- setItem: (key: string, value: string) => { storage.set(key, value); },
7
- removeItem: (key: string) => { storage.delete(key); },
8
- clear: () => storage.clear(),
9
- get length() { return storage.size; },
10
- key: (i: number) => [...storage.keys()][i] ?? null,
11
- } as any;
12
-
13
- globalThis.window = globalThis as any;
14
- globalThis.document = {
15
- createElement: () => ({ id: '', textContent: '', remove() {} }),
16
- head: { appendChild() {} },
17
- } as any;
18
- globalThis.navigator = { sendBeacon: () => true } as any;
19
-
20
- // Export storage so tests can access it for clearing
21
- (globalThis as any).__testStorage = storage;
package/tsconfig.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "declaration": true,
6
- "noEmit": false,
7
- "rootDir": "./src",
8
- "lib": ["ES2022", "DOM"],
9
- "module": "ESNext",
10
- "moduleResolution": "bundler"
11
- },
12
- "include": ["src/**/*.ts"]
13
- }