@sessionsight/split-testing 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SessionSight
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # @sessionsight/split-testing
2
+
3
+ Split testing SDK for SessionSight. Framework-agnostic, zero-flicker, server-driven.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @sessionsight/split-testing
9
+ ```
10
+
11
+ Or use via script tag:
12
+
13
+ ```html
14
+ <script src="https://cdn.sessionsight.com/split-testing.js"></script>
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```typescript
20
+ import SplitTesting from '@sessionsight/split-testing';
21
+
22
+ await SplitTesting.init({
23
+ publicApiKey: 'sessionsight_pub_...',
24
+ propertyId: 'your-property-id',
25
+ });
26
+
27
+ // ID-based test (component branching)
28
+ const checkout = SplitTesting.get('checkout-flow', 'control');
29
+ if (checkout === 'single-page') {
30
+ renderSinglePageCheckout();
31
+ } else {
32
+ renderDefaultCheckout();
33
+ }
34
+
35
+ // Text test (copy testing)
36
+ const headline = SplitTesting.get('hero-headline', 'Welcome');
37
+
38
+ // JSON test (structured data)
39
+ const layout = SplitTesting.get('pricing-layout', { columns: 3 });
40
+
41
+ // Track conversions
42
+ SplitTesting.trackConversion('signup-goal');
43
+ SplitTesting.trackConversion('purchase-goal', { value: 99.99, currency: 'USD' });
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ ```typescript
49
+ await SplitTesting.init({
50
+ // Required
51
+ publicApiKey: 'sessionsight_pub_...',
52
+ propertyId: 'your-property-id',
53
+
54
+ // Optional
55
+ apiUrl: 'https://api.sessionsight.com', // Custom API URL
56
+ visitorId: 'user-123', // Stable visitor ID (auto-generated if omitted)
57
+ attributes: { plan: 'pro' }, // Visitor attributes for targeting
58
+ bootstrap: { 'hero-headline': 1 }, // SSR: pre-resolved assignments
59
+ antiFlicker: true, // Hide [data-ss-split] elements until ready
60
+ staleTTL: 0, // ms before cached config triggers background refresh (default: 0)
61
+ maxAge: 86400000, // ms before cached config is expired (default: 24h)
62
+ onAssignment: (key, variation) => {}, // Callback when a variation is assigned
63
+ });
64
+ ```
65
+
66
+ ## Anti-Flicker
67
+
68
+ The SDK uses a three-tier approach to prevent content flashing:
69
+
70
+ 1. **localStorage cache**: On repeat visits, cached assignments are used instantly (synchronous, no network call).
71
+ 2. **SSR bootstrap**: Pass pre-evaluated assignments from the server. Zero network calls, zero flicker.
72
+ 3. **Anti-flicker snippet**: Set `antiFlicker: true` to hide `[data-ss-split]` elements until assignments resolve.
73
+
74
+ ### SSR Bootstrap Example
75
+
76
+ ```typescript
77
+ // Server-side
78
+ import SplitTesting from '@sessionsight/split-testing';
79
+ await SplitTesting.init({ publicApiKey: 'sessionsight_pub_...', propertyId: '...' });
80
+ const bootstrap = SplitTesting.getAssignments();
81
+
82
+ // Client-side (pass bootstrap from server)
83
+ await SplitTesting.init({
84
+ publicApiKey: 'sessionsight_pub_...',
85
+ propertyId: '...',
86
+ bootstrap,
87
+ });
88
+ ```
89
+
90
+ ## API
91
+
92
+ ### `SplitTesting.init(config)`
93
+
94
+ Initialize the SDK. Fetches test configuration and assigns variations. Returns a Promise that resolves when ready.
95
+
96
+ ### `SplitTesting.get(testKey, defaultValue, options?)`
97
+
98
+ Get the assigned value for a split test. Returns synchronously after init.
99
+
100
+ - **ID tests**: Returns the variation key string (e.g., `"control"`, `"variant-a"`)
101
+ - **Text tests**: Returns the variation text string
102
+ - **JSON tests**: Returns the parsed JSON object
103
+
104
+ Returns `defaultValue` if the test is not found or the visitor is not in the test.
105
+
106
+ ### `SplitTesting.trackConversion(goalId, options?)`
107
+
108
+ Track a conversion event. The backend automatically attributes it to all relevant split tests the visitor is participating in.
109
+
110
+ ### `SplitTesting.setAttributes(attrs)`
111
+
112
+ Update visitor attributes for targeting.
113
+
114
+ ### `SplitTesting.getAssignments()`
115
+
116
+ Get all current assignments as `{ testKey: variationIndex }`. Use this for SSR bootstrap.
117
+
118
+ ### `SplitTesting.refresh()`
119
+
120
+ Force refresh the test configuration from the server.
121
+
122
+ ### `SplitTesting.clearCache()`
123
+
124
+ Clear locally cached test assignments and configuration. The next call to `get()` will use fresh data from the server after a `refresh()`.
125
+
126
+ ### `SplitTesting.destroy()`
127
+
128
+ Clean up the SDK instance and flush pending events.
129
+
130
+ ## Script Tag Usage
131
+
132
+ ```html
133
+ <script src="https://cdn.sessionsight.com/split-testing.js"></script>
134
+ <script>
135
+ SessionSightSplitTesting.init({
136
+ publicApiKey: 'sessionsight_pub_...',
137
+ propertyId: '...',
138
+ }).then(function() {
139
+ var headline = SessionSightSplitTesting.get('hero-headline', 'Welcome');
140
+ document.getElementById('hero').textContent = headline;
141
+ });
142
+ </script>
143
+ ```
package/bunfig.toml ADDED
@@ -0,0 +1,2 @@
1
+ [test]
2
+ preload = ["./test/setup.ts"]
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@sessionsight/split-testing",
3
+ "version": "1.0.0",
4
+ "description": "A/B and split testing SDK for SessionSight.",
5
+ "author": "SessionSight",
6
+ "license": "MIT",
7
+ "homepage": "https://sessionsight.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/SessionSight/sdks.git",
11
+ "directory": "packages/split-testing"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/SessionSight/sdks/issues"
15
+ },
16
+ "keywords": [
17
+ "ab-testing",
18
+ "split-testing",
19
+ "experiments",
20
+ "sessionsight"
21
+ ],
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "import": "./dist/index.js",
28
+ "types": "./dist/index.d.ts"
29
+ }
30
+ },
31
+ "scripts": {
32
+ "build": "bun run build:esm && bun run build:iife",
33
+ "build:esm": "bun build src/index.ts --outdir dist --format esm && bun x tsc --emitDeclarationOnly --declaration --outDir dist",
34
+ "build:iife": "bun build src/iife.ts --outfile dist/sessionsight-split-testing.js --format iife --minify"
35
+ },
36
+ "peerDependencies": {
37
+ "typescript": "^5"
38
+ },
39
+ "dependencies": {
40
+ "@sessionsight/sdk-shared": "workspace:*"
41
+ }
42
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { SplitTestConfigResponse, Assignment } from './types.js';
2
+
3
+ export { getOrCreateVisitorId } from '@sessionsight/sdk-shared';
4
+
5
+ const CONFIG_PREFIX = 'ss-split-config:';
6
+ const ASSIGNMENTS_PREFIX = 'ss-split-assignments:';
7
+
8
+ interface CachedConfig {
9
+ data: SplitTestConfigResponse;
10
+ fetchedAt: number;
11
+ }
12
+
13
+ function hasLocalStorage(): boolean {
14
+ try {
15
+ const key = '__ss_test__';
16
+ localStorage.setItem(key, '1');
17
+ localStorage.removeItem(key);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ const canUseStorage = typeof window !== 'undefined' && hasLocalStorage();
25
+
26
+ // ── Config Cache ────────────────────────────────────────────────────
27
+
28
+ export function getCachedConfig(propertyId: string): CachedConfig | null {
29
+ if (!canUseStorage) return null;
30
+ try {
31
+ const raw = localStorage.getItem(CONFIG_PREFIX + propertyId);
32
+ if (!raw) return null;
33
+ return JSON.parse(raw);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function setCachedConfig(propertyId: string, data: SplitTestConfigResponse): void {
40
+ if (!canUseStorage) return;
41
+ try {
42
+ const cached: CachedConfig = { data, fetchedAt: Date.now() };
43
+ localStorage.setItem(CONFIG_PREFIX + propertyId, JSON.stringify(cached));
44
+ } catch {
45
+ // Storage full or unavailable
46
+ }
47
+ }
48
+
49
+ // ── Assignment Cache ────────────────────────────────────────────────
50
+
51
+ export function getCachedAssignments(propertyId: string, visitorId: string): Record<string, Assignment> | null {
52
+ if (!canUseStorage) return null;
53
+ try {
54
+ const raw = localStorage.getItem(ASSIGNMENTS_PREFIX + propertyId + ':' + visitorId);
55
+ if (!raw) return null;
56
+ return JSON.parse(raw);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export function setCachedAssignments(
63
+ propertyId: string,
64
+ visitorId: string,
65
+ assignments: Record<string, Assignment>,
66
+ ): void {
67
+ if (!canUseStorage) return;
68
+ try {
69
+ localStorage.setItem(
70
+ ASSIGNMENTS_PREFIX + propertyId + ':' + visitorId,
71
+ JSON.stringify(assignments),
72
+ );
73
+ } catch {
74
+ // Storage full or unavailable
75
+ }
76
+ }
77
+
78
+ // ── Cleanup ─────────────────────────────────────────────────────────
79
+
80
+ export function clearCache(propertyId: string): void {
81
+ if (!canUseStorage) return;
82
+ try {
83
+ // Remove all keys with our prefix for this property
84
+ const keysToRemove: string[] = [];
85
+ for (let i = 0; i < localStorage.length; i++) {
86
+ const key = localStorage.key(i);
87
+ if (key && (key.startsWith(CONFIG_PREFIX + propertyId) || key.startsWith(ASSIGNMENTS_PREFIX + propertyId))) {
88
+ keysToRemove.push(key);
89
+ }
90
+ }
91
+ keysToRemove.forEach((k) => localStorage.removeItem(k));
92
+ } catch {
93
+ // Ignore
94
+ }
95
+ }
package/src/client.ts ADDED
@@ -0,0 +1,370 @@
1
+ import type {
2
+ SplitTestConfig,
3
+ SplitTestConfigResponse,
4
+ SplitTestConfigEntry,
5
+ Assignment,
6
+ AssignedVariation,
7
+ GetOptions,
8
+ } from './types.js';
9
+ import { splitTestHash, assignVariation } from './hash.js';
10
+ import {
11
+ getOrCreateVisitorId,
12
+ getCachedConfig,
13
+ setCachedConfig,
14
+ getCachedAssignments,
15
+ setCachedAssignments,
16
+ clearCache,
17
+ } from './cache.js';
18
+
19
+ const FETCH_TIMEOUT_MS = 10_000;
20
+
21
+ function fetchWithTimeout(url: string, options: RequestInit): Promise<Response> {
22
+ const controller = new AbortController();
23
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
24
+ return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer));
25
+ }
26
+
27
+ import { normalizeApiUrl } from '@sessionsight/sdk-shared';
28
+
29
+ const DEFAULT_STALE_TTL = 0;
30
+ const DEFAULT_MAX_AGE = 86_400_000; // 24 hours
31
+
32
+ export class SplitTestingClient {
33
+ private apiUrl: string;
34
+ private publicApiKey: string;
35
+ private propertyId: string;
36
+ private visitorId: string;
37
+ private attributes: Record<string, string | number | boolean>;
38
+ private bootstrap: Record<string, number> | null;
39
+ private antiFlicker: boolean;
40
+ private staleTTL: number;
41
+ private maxAge: number;
42
+ private onAssignment: ((testKey: string, variation: AssignedVariation) => void) | null;
43
+
44
+ private config: SplitTestConfigResponse | null = null;
45
+ private assignments: Record<string, Assignment> = {};
46
+ private initialized = false;
47
+ private antiFlickerStyle: HTMLStyleElement | null = null;
48
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
49
+ private pendingExposures: Array<{
50
+ splitTestKey: string;
51
+ variationKey: string;
52
+ visitorId: string;
53
+ timestamp: number;
54
+ attributes: Record<string, string | number | boolean>;
55
+ }> = [];
56
+
57
+ constructor(config: SplitTestConfig) {
58
+ this.publicApiKey = config.publicApiKey;
59
+ this.propertyId = config.propertyId;
60
+ this.apiUrl = normalizeApiUrl(config.apiUrl || '');
61
+ this.visitorId = getOrCreateVisitorId(config.visitorId);
62
+ this.attributes = config.attributes || {};
63
+ this.bootstrap = config.bootstrap || null;
64
+ this.antiFlicker = config.antiFlicker || false;
65
+ this.staleTTL = config.staleTTL ?? DEFAULT_STALE_TTL;
66
+ this.maxAge = config.maxAge ?? DEFAULT_MAX_AGE;
67
+ this.onAssignment = config.onAssignment || null;
68
+ }
69
+
70
+ async init(): Promise<void> {
71
+ // Step 1: Anti-flicker
72
+ if (this.antiFlicker && typeof document !== 'undefined') {
73
+ this.injectAntiFlicker();
74
+ }
75
+
76
+ try {
77
+ // Step 2: Bootstrap (highest priority, zero-flicker)
78
+ if (this.bootstrap) {
79
+ // We still need config to know the test metadata (type, variations)
80
+ // Try cache first, then fetch
81
+ const cached = getCachedConfig(this.propertyId);
82
+ if (cached) {
83
+ this.config = cached.data;
84
+ this.evaluateFromBootstrap();
85
+ this.initialized = true;
86
+ // Refresh in background
87
+ this.fetchConfigInBackground();
88
+ return;
89
+ }
90
+ // Must fetch to know test structure
91
+ await this.fetchConfig();
92
+ this.evaluateFromBootstrap();
93
+ this.initialized = true;
94
+ return;
95
+ }
96
+
97
+ // Step 3: Try cached assignments
98
+ const cachedAssignments = getCachedAssignments(this.propertyId, this.visitorId);
99
+ if (cachedAssignments) {
100
+ this.assignments = cachedAssignments;
101
+ }
102
+
103
+ // Step 4: Check cached config
104
+ const cachedConfig = getCachedConfig(this.propertyId);
105
+ const now = Date.now();
106
+
107
+ if (cachedConfig) {
108
+ const age = now - cachedConfig.fetchedAt;
109
+
110
+ if (age < this.maxAge) {
111
+ this.config = cachedConfig.data;
112
+
113
+ // Re-evaluate assignments from cached config (to ensure consistency)
114
+ this.evaluateAssignments();
115
+ this.initialized = true;
116
+
117
+ // If stale, fetch in background
118
+ if (age >= this.staleTTL) {
119
+ this.fetchConfigInBackground();
120
+ }
121
+ return;
122
+ }
123
+ }
124
+
125
+ // Step 5: No usable cache. Fetch fresh.
126
+ await this.fetchConfig();
127
+ this.evaluateAssignments();
128
+ this.initialized = true;
129
+ } finally {
130
+ this.removeAntiFlicker();
131
+ }
132
+ }
133
+
134
+ get(testKey: string, defaultValue: any, _options?: GetOptions): any {
135
+ if (!this.initialized) {
136
+ console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
137
+ return defaultValue;
138
+ }
139
+
140
+ const assignment = this.assignments[testKey];
141
+ if (!assignment) return defaultValue;
142
+
143
+ // Track exposure (fire-and-forget)
144
+ if (assignment.inTest) {
145
+ this.trackExposure(testKey, assignment.variationKey);
146
+ }
147
+
148
+ switch (assignment.type) {
149
+ case 'id':
150
+ return assignment.variationKey;
151
+ case 'text':
152
+ return assignment.value || defaultValue;
153
+ case 'json':
154
+ try {
155
+ return JSON.parse(assignment.value);
156
+ } catch {
157
+ return defaultValue;
158
+ }
159
+ default:
160
+ return defaultValue;
161
+ }
162
+ }
163
+
164
+ trackConversion(goalId: string, options?: { value?: number; currency?: string }): void {
165
+ if (!this.initialized) return;
166
+
167
+ const body = {
168
+ propertyId: this.propertyId,
169
+ visitorId: this.visitorId,
170
+ goalId,
171
+ ...(options?.value != null ? { value: options.value } : {}),
172
+ ...(options?.currency ? { currency: options.currency } : {}),
173
+ };
174
+
175
+ this.sendBeacon(`${this.apiUrl}/v1/split-testing/convert`, body);
176
+ }
177
+
178
+ setAttributes(attrs: Record<string, string | number | boolean>): void {
179
+ Object.assign(this.attributes, attrs);
180
+ }
181
+
182
+ getAssignments(): Record<string, number> {
183
+ const result: Record<string, number> = {};
184
+ for (const [key, assignment] of Object.entries(this.assignments)) {
185
+ result[key] = assignment.variationIndex;
186
+ }
187
+ return result;
188
+ }
189
+
190
+ async refresh(): Promise<void> {
191
+ await this.fetchConfig();
192
+ this.evaluateAssignments();
193
+ }
194
+
195
+ clearCache(): void {
196
+ clearCache(this.propertyId);
197
+ }
198
+
199
+ destroy(): void {
200
+ if (this.flushTimer) {
201
+ clearTimeout(this.flushTimer);
202
+ this.flushTimer = null;
203
+ }
204
+ this.flushExposures();
205
+ this.removeAntiFlicker();
206
+ this.config = null;
207
+ this.assignments = {};
208
+ this.initialized = false;
209
+ this.pendingExposures = [];
210
+ }
211
+
212
+ // ── Private ─────────────────────────────────────────────────────
213
+
214
+ private evaluateFromBootstrap(): void {
215
+ if (!this.config || !this.bootstrap) return;
216
+
217
+ for (const test of this.config.tests) {
218
+ const variationIndex = this.bootstrap[test.key];
219
+ if (variationIndex === undefined) continue;
220
+
221
+ const variation = test.variations[variationIndex];
222
+ if (!variation) continue;
223
+
224
+ this.assignments[test.key] = {
225
+ testKey: test.key,
226
+ variationIndex,
227
+ variationKey: variation.key,
228
+ value: variation.value,
229
+ type: test.type,
230
+ inTest: true,
231
+ };
232
+
233
+ if (this.onAssignment) {
234
+ this.onAssignment(test.key, { key: variation.key, value: variation.value });
235
+ }
236
+ }
237
+
238
+ setCachedAssignments(this.propertyId, this.visitorId, this.assignments);
239
+ }
240
+
241
+ private evaluateAssignments(): void {
242
+ if (!this.config) return;
243
+
244
+ for (const test of this.config.tests) {
245
+ const hash = splitTestHash(test.hashSeed, this.visitorId);
246
+ const result = assignVariation(hash, test.trafficAllocation, test.variations);
247
+ const variation = test.variations[result.variationIndex];
248
+
249
+ if (!variation) continue;
250
+
251
+ this.assignments[test.key] = {
252
+ testKey: test.key,
253
+ variationIndex: result.variationIndex,
254
+ variationKey: variation.key,
255
+ value: variation.value,
256
+ type: test.type,
257
+ inTest: result.inTest,
258
+ };
259
+
260
+ if (this.onAssignment) {
261
+ this.onAssignment(test.key, { key: variation.key, value: variation.value });
262
+ }
263
+ }
264
+
265
+ setCachedAssignments(this.propertyId, this.visitorId, this.assignments);
266
+ }
267
+
268
+ private async fetchConfig(): Promise<void> {
269
+ try {
270
+ const url = `${this.apiUrl}/v1/split-testing/config?propertyId=${encodeURIComponent(this.propertyId)}`;
271
+ const res = await fetchWithTimeout(url, {
272
+ headers: { 'x-api-key': this.publicApiKey },
273
+ });
274
+
275
+ if (!res.ok) {
276
+ console.warn(`[SessionSight SplitTesting] Failed to fetch config: ${res.status}`);
277
+ return;
278
+ }
279
+
280
+ const data = await res.json();
281
+ if (!data || !Array.isArray(data.tests)) {
282
+ console.warn('[SessionSight SplitTesting] Invalid config response');
283
+ return;
284
+ }
285
+ this.config = data as SplitTestConfigResponse;
286
+ setCachedConfig(this.propertyId, data);
287
+ } catch (err) {
288
+ console.warn('[SessionSight SplitTesting] Failed to fetch config:', err);
289
+ }
290
+ }
291
+
292
+ private fetchConfigInBackground(): void {
293
+ this.fetchConfig().then(() => {
294
+ if (this.config) {
295
+ this.evaluateAssignments();
296
+ }
297
+ });
298
+ }
299
+
300
+ private trackExposure(testKey: string, variationKey: string): void {
301
+ // Deduplicate: only track once per test per session
302
+ if (this.pendingExposures.some((e) => e.splitTestKey === testKey)) return;
303
+
304
+ this.pendingExposures.push({
305
+ splitTestKey: testKey,
306
+ variationKey,
307
+ visitorId: this.visitorId,
308
+ timestamp: Date.now(),
309
+ attributes: this.attributes,
310
+ });
311
+
312
+ // Flush after a short delay to batch exposures
313
+ if (this.pendingExposures.length === 1) {
314
+ this.flushTimer = setTimeout(() => this.flushExposures(), 1000);
315
+ }
316
+ }
317
+
318
+ private flushExposures(): void {
319
+ if (this.pendingExposures.length === 0) return;
320
+
321
+ const exposures = [...this.pendingExposures];
322
+ this.pendingExposures = [];
323
+
324
+ const body = {
325
+ propertyId: this.propertyId,
326
+ exposures,
327
+ };
328
+
329
+ this.sendBeacon(`${this.apiUrl}/v1/split-testing/expose`, body);
330
+ }
331
+
332
+ private sendBeacon(url: string, body: any): void {
333
+ const json = JSON.stringify(body);
334
+
335
+ // Try sendBeacon first (works during page unload)
336
+ if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
337
+ const blob = new Blob([json], { type: 'application/json' });
338
+ if (navigator.sendBeacon(url, blob)) return;
339
+ }
340
+
341
+ // Fallback to fetch with keepalive
342
+ fetch(url, {
343
+ method: 'POST',
344
+ headers: {
345
+ 'Content-Type': 'application/json',
346
+ 'x-api-key': this.publicApiKey,
347
+ },
348
+ body: json,
349
+ keepalive: true,
350
+ }).catch(() => {
351
+ // Fire-and-forget
352
+ });
353
+ }
354
+
355
+ private injectAntiFlicker(): void {
356
+ if (typeof document === 'undefined') return;
357
+ const style = document.createElement('style');
358
+ style.id = 'ss-split-anti-flicker';
359
+ style.textContent = '[data-ss-split]{visibility:hidden!important}';
360
+ document.head.appendChild(style);
361
+ this.antiFlickerStyle = style;
362
+ }
363
+
364
+ private removeAntiFlicker(): void {
365
+ if (this.antiFlickerStyle) {
366
+ this.antiFlickerStyle.remove();
367
+ this.antiFlickerStyle = null;
368
+ }
369
+ }
370
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * djb2 hash producing 0-9999 for fine-grained traffic allocation.
3
+ * Same algorithm family as the flag-evaluation service.
4
+ */
5
+ export function splitTestHash(seed: string, visitorId: string): number {
6
+ const str = `${seed}:${visitorId}`;
7
+ let hash = 5381;
8
+ for (let i = 0; i < str.length; i++) {
9
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
10
+ }
11
+ return hash % 10000;
12
+ }
13
+
14
+ /**
15
+ * Given a hash bucket, traffic allocation, and variation weights,
16
+ * determine which variation the visitor gets.
17
+ */
18
+ export function assignVariation(
19
+ hashValue: number,
20
+ trafficAllocation: number,
21
+ variations: Array<{ key: string; weight: number }>,
22
+ ): { variationIndex: number; inTest: boolean } {
23
+ // trafficAllocation is 0-100, scale to 0-10000
24
+ const trafficThreshold = trafficAllocation * 100;
25
+
26
+ if (hashValue >= trafficThreshold) {
27
+ // Outside traffic allocation: gets control (index 0), not tracked
28
+ return { variationIndex: 0, inTest: false };
29
+ }
30
+
31
+ // Bucket within traffic allocation
32
+ const totalWeight = variations.reduce((s, v) => s + v.weight, 0);
33
+ if (totalWeight === 0) return { variationIndex: 0, inTest: true };
34
+
35
+ let cumulative = 0;
36
+ for (let i = 0; i < variations.length; i++) {
37
+ cumulative += (variations[i]!.weight / totalWeight) * trafficThreshold;
38
+ if (hashValue < cumulative) {
39
+ return { variationIndex: i, inTest: true };
40
+ }
41
+ }
42
+
43
+ return { variationIndex: variations.length - 1, inTest: true };
44
+ }
package/src/iife.ts ADDED
@@ -0,0 +1,2 @@
1
+ import SplitTesting from './index.js';
2
+ (window as any).SessionSightSplitTesting = SplitTesting;
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { SplitTestingClient } from './client.js';
2
+ import type { SplitTestConfig, GetOptions, AssignedVariation } from './types.js';
3
+
4
+ export { SplitTestingClient };
5
+ export type { SplitTestConfig, GetOptions, AssignedVariation, Assignment, SplitTestConfigResponse } from './types.js';
6
+
7
+ let instance: SplitTestingClient | null = null;
8
+
9
+ const SplitTesting = {
10
+ async init(config: SplitTestConfig): Promise<void> {
11
+ if (instance) {
12
+ console.warn('[SessionSight SplitTesting] Already initialized. Call destroy() first.');
13
+ return;
14
+ }
15
+ instance = new SplitTestingClient(config);
16
+ await instance.init();
17
+ },
18
+
19
+ get(testKey: string, defaultValue: any, options?: GetOptions): any {
20
+ if (!instance) {
21
+ console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
22
+ return defaultValue;
23
+ }
24
+ return instance.get(testKey, defaultValue, options);
25
+ },
26
+
27
+ trackConversion(goalId: string, options?: { value?: number; currency?: string }): void {
28
+ if (!instance) {
29
+ console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
30
+ return;
31
+ }
32
+ instance.trackConversion(goalId, options);
33
+ },
34
+
35
+ setAttributes(attrs: Record<string, string | number | boolean>): void {
36
+ if (!instance) {
37
+ console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
38
+ return;
39
+ }
40
+ instance.setAttributes(attrs);
41
+ },
42
+
43
+ getAssignments(): Record<string, number> {
44
+ if (!instance) {
45
+ console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
46
+ return {};
47
+ }
48
+ return instance.getAssignments();
49
+ },
50
+
51
+ async refresh(): Promise<void> {
52
+ if (!instance) {
53
+ console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
54
+ return;
55
+ }
56
+ await instance.refresh();
57
+ },
58
+
59
+ clearCache(): void {
60
+ if (!instance) {
61
+ console.warn('[SessionSight SplitTesting] Not initialized. Call init() first.');
62
+ return;
63
+ }
64
+ instance.clearCache();
65
+ },
66
+
67
+ destroy(): void {
68
+ if (instance) {
69
+ instance.destroy();
70
+ instance = null;
71
+ }
72
+ },
73
+ };
74
+
75
+ export default SplitTesting;
package/src/types.ts ADDED
@@ -0,0 +1,50 @@
1
+ export interface SplitTestConfig {
2
+ publicApiKey: string;
3
+ propertyId: string;
4
+ apiUrl?: string;
5
+ visitorId?: string;
6
+ attributes?: Record<string, string | number | boolean>;
7
+ bootstrap?: Record<string, number>;
8
+ antiFlicker?: boolean;
9
+ staleTTL?: number;
10
+ maxAge?: number;
11
+ onAssignment?: (testKey: string, variation: AssignedVariation) => void;
12
+ }
13
+
14
+ export interface AssignedVariation {
15
+ key: string;
16
+ value: string;
17
+ }
18
+
19
+ export interface SplitTestConfigEntry {
20
+ key: string;
21
+ id: string;
22
+ type: 'id' | 'text' | 'json';
23
+ status: string;
24
+ hashSeed: string;
25
+ trafficAllocation: number;
26
+ variations: Array<{
27
+ key: string;
28
+ weight: number;
29
+ value: string;
30
+ }>;
31
+ }
32
+
33
+ export interface SplitTestConfigResponse {
34
+ tests: SplitTestConfigEntry[];
35
+ ttl: number;
36
+ }
37
+
38
+ export interface Assignment {
39
+ testKey: string;
40
+ variationIndex: number;
41
+ variationKey: string;
42
+ value: string;
43
+ type: 'id' | 'text' | 'json';
44
+ inTest: boolean;
45
+ }
46
+
47
+ export interface GetOptions {
48
+ // Reserved for future use
49
+ [key: string]: any;
50
+ }
@@ -0,0 +1,224 @@
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
+ });
@@ -0,0 +1,85 @@
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 ADDED
@@ -0,0 +1,21 @@
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 ADDED
@@ -0,0 +1,13 @@
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
+ }