@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 +21 -0
- package/README.md +143 -0
- package/bunfig.toml +2 -0
- package/package.json +42 -0
- package/src/cache.ts +95 -0
- package/src/client.ts +370 -0
- package/src/hash.ts +44 -0
- package/src/iife.ts +2 -0
- package/src/index.ts +75 -0
- package/src/types.ts +50 -0
- package/test/client.test.ts +224 -0
- package/test/hash.test.ts +85 -0
- package/test/setup.ts +21 -0
- package/tsconfig.json +13 -0
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
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
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
|
+
}
|