@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.
- package/dist/cache.d.ts +11 -0
- package/dist/client.d.ts +40 -0
- package/dist/hash.d.ts +16 -0
- package/dist/hash.test.d.ts +1 -0
- package/dist/iife.d.ts +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +535 -0
- package/dist/sessionsight-split-testing.js +1 -0
- package/dist/types.d.ts +44 -0
- package/package.json +4 -1
- package/bunfig.toml +0 -2
- package/src/cache.ts +0 -95
- package/src/client.ts +0 -370
- package/src/hash.ts +0 -44
- package/src/iife.ts +0 -2
- package/src/index.ts +0 -75
- package/src/types.ts +0 -50
- package/test/client.test.ts +0 -224
- package/test/hash.test.ts +0 -85
- package/test/setup.ts +0 -21
- package/tsconfig.json +0 -13
package/test/client.test.ts
DELETED
|
@@ -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
|
-
}
|