swetrix 3.7.1 → 4.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/dist/esnext/Lib.d.ts +148 -0
- package/dist/esnext/Lib.js +269 -2
- package/dist/esnext/Lib.js.map +1 -1
- package/dist/esnext/index.d.ts +146 -2
- package/dist/esnext/index.js +176 -0
- package/dist/esnext/index.js.map +1 -1
- package/dist/swetrix.cjs.js +585 -205
- package/dist/swetrix.cjs.js.map +1 -1
- package/dist/swetrix.es5.js +579 -207
- package/dist/swetrix.es5.js.map +1 -1
- package/dist/swetrix.js +1 -1
- package/dist/swetrix.js.map +1 -1
- package/package.json +2 -1
- package/rollup.config.mjs +5 -0
- package/src/Lib.ts +353 -4
- package/src/index.ts +202 -0
- package/tests/experiments.test.ts +330 -0
- package/tsconfig.esnext.json +3 -6
- package/tsconfig.json +4 -8
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { init, getExperiment, getExperiments, clearExperimentsCache } from '../src/index'
|
|
2
|
+
import { Lib } from '../src/Lib'
|
|
3
|
+
|
|
4
|
+
// Mock fetch globally
|
|
5
|
+
const mockFetch = jest.fn()
|
|
6
|
+
global.fetch = mockFetch
|
|
7
|
+
|
|
8
|
+
describe('A/B Testing Experiments', () => {
|
|
9
|
+
const PROJECT_ID = 'test-project-id'
|
|
10
|
+
let libInstance: Lib
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks()
|
|
14
|
+
jest.resetModules()
|
|
15
|
+
|
|
16
|
+
// Reset fetch mock
|
|
17
|
+
mockFetch.mockReset()
|
|
18
|
+
|
|
19
|
+
Object.defineProperty(window, 'location', {
|
|
20
|
+
value: {
|
|
21
|
+
hostname: 'example.com',
|
|
22
|
+
pathname: '/test-page',
|
|
23
|
+
hash: '',
|
|
24
|
+
search: '',
|
|
25
|
+
},
|
|
26
|
+
writable: true,
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('getExperiments', () => {
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
jest.resetModules()
|
|
33
|
+
const { init: freshInit } = await import('../src/index')
|
|
34
|
+
libInstance = freshInit(PROJECT_ID, { devMode: true }) as Lib
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('should return experiments from API response', async () => {
|
|
38
|
+
// Arrange
|
|
39
|
+
mockFetch.mockResolvedValueOnce({
|
|
40
|
+
ok: true,
|
|
41
|
+
json: async () => ({
|
|
42
|
+
flags: { 'feature-flag-1': true },
|
|
43
|
+
experiments: {
|
|
44
|
+
'exp-checkout': 'variant-b',
|
|
45
|
+
'exp-pricing': 'control',
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Act
|
|
51
|
+
const { getExperiments: freshGetExperiments } = await import('../src/index')
|
|
52
|
+
const experiments = await freshGetExperiments()
|
|
53
|
+
|
|
54
|
+
// Assert
|
|
55
|
+
expect(experiments).toEqual({
|
|
56
|
+
'exp-checkout': 'variant-b',
|
|
57
|
+
'exp-pricing': 'control',
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('should return empty object when no experiments in response', async () => {
|
|
62
|
+
// Arrange
|
|
63
|
+
mockFetch.mockResolvedValueOnce({
|
|
64
|
+
ok: true,
|
|
65
|
+
json: async () => ({
|
|
66
|
+
flags: { 'feature-flag-1': true },
|
|
67
|
+
}),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// Act
|
|
71
|
+
const { getExperiments: freshGetExperiments } = await import('../src/index')
|
|
72
|
+
const experiments = await freshGetExperiments()
|
|
73
|
+
|
|
74
|
+
// Assert
|
|
75
|
+
expect(experiments).toEqual({})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('should use cached experiments on subsequent calls', async () => {
|
|
79
|
+
// Arrange
|
|
80
|
+
mockFetch.mockResolvedValueOnce({
|
|
81
|
+
ok: true,
|
|
82
|
+
json: async () => ({
|
|
83
|
+
flags: {},
|
|
84
|
+
experiments: { 'exp-1': 'variant-a' },
|
|
85
|
+
}),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Act
|
|
89
|
+
const { getExperiments: freshGetExperiments } = await import('../src/index')
|
|
90
|
+
await freshGetExperiments()
|
|
91
|
+
await freshGetExperiments()
|
|
92
|
+
|
|
93
|
+
// Assert - fetch should only be called once
|
|
94
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('should bypass cache when forceRefresh is true', async () => {
|
|
98
|
+
// Arrange
|
|
99
|
+
mockFetch
|
|
100
|
+
.mockResolvedValueOnce({
|
|
101
|
+
ok: true,
|
|
102
|
+
json: async () => ({
|
|
103
|
+
flags: {},
|
|
104
|
+
experiments: { 'exp-1': 'variant-a' },
|
|
105
|
+
}),
|
|
106
|
+
})
|
|
107
|
+
.mockResolvedValueOnce({
|
|
108
|
+
ok: true,
|
|
109
|
+
json: async () => ({
|
|
110
|
+
flags: {},
|
|
111
|
+
experiments: { 'exp-1': 'variant-b' },
|
|
112
|
+
}),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// Act
|
|
116
|
+
const { getExperiments: freshGetExperiments } = await import('../src/index')
|
|
117
|
+
const first = await freshGetExperiments()
|
|
118
|
+
const second = await freshGetExperiments(undefined, true)
|
|
119
|
+
|
|
120
|
+
// Assert
|
|
121
|
+
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
122
|
+
expect(first).toEqual({ 'exp-1': 'variant-a' })
|
|
123
|
+
expect(second).toEqual({ 'exp-1': 'variant-b' })
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('getExperiment', () => {
|
|
128
|
+
beforeEach(async () => {
|
|
129
|
+
jest.resetModules()
|
|
130
|
+
const { init: freshInit } = await import('../src/index')
|
|
131
|
+
libInstance = freshInit(PROJECT_ID, { devMode: true }) as Lib
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('should return specific experiment variant', async () => {
|
|
135
|
+
// Arrange
|
|
136
|
+
mockFetch.mockResolvedValueOnce({
|
|
137
|
+
ok: true,
|
|
138
|
+
json: async () => ({
|
|
139
|
+
flags: {},
|
|
140
|
+
experiments: {
|
|
141
|
+
'exp-checkout': 'new-checkout',
|
|
142
|
+
'exp-pricing': 'control',
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Act
|
|
148
|
+
const { getExperiment: freshGetExperiment } = await import('../src/index')
|
|
149
|
+
const variant = await freshGetExperiment('exp-checkout')
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
152
|
+
expect(variant).toBe('new-checkout')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('should return defaultVariant when experiment not found', async () => {
|
|
156
|
+
// Arrange
|
|
157
|
+
mockFetch.mockResolvedValueOnce({
|
|
158
|
+
ok: true,
|
|
159
|
+
json: async () => ({
|
|
160
|
+
flags: {},
|
|
161
|
+
experiments: {},
|
|
162
|
+
}),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// Act
|
|
166
|
+
const { getExperiment: freshGetExperiment } = await import('../src/index')
|
|
167
|
+
const variant = await freshGetExperiment('non-existent', undefined, 'fallback')
|
|
168
|
+
|
|
169
|
+
// Assert
|
|
170
|
+
expect(variant).toBe('fallback')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('should return null when experiment not found and no default provided', async () => {
|
|
174
|
+
// Arrange
|
|
175
|
+
mockFetch.mockResolvedValueOnce({
|
|
176
|
+
ok: true,
|
|
177
|
+
json: async () => ({
|
|
178
|
+
flags: {},
|
|
179
|
+
experiments: {},
|
|
180
|
+
}),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// Act
|
|
184
|
+
const { getExperiment: freshGetExperiment } = await import('../src/index')
|
|
185
|
+
const variant = await freshGetExperiment('non-existent')
|
|
186
|
+
|
|
187
|
+
// Assert
|
|
188
|
+
expect(variant).toBeNull()
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('clearExperimentsCache', () => {
|
|
193
|
+
test('should clear cache and fetch fresh data on next call', async () => {
|
|
194
|
+
jest.resetModules()
|
|
195
|
+
const {
|
|
196
|
+
init: freshInit,
|
|
197
|
+
getExperiments: freshGetExperiments,
|
|
198
|
+
clearExperimentsCache: freshClearCache,
|
|
199
|
+
} = await import('../src/index')
|
|
200
|
+
freshInit(PROJECT_ID, { devMode: true })
|
|
201
|
+
|
|
202
|
+
// Arrange
|
|
203
|
+
mockFetch
|
|
204
|
+
.mockResolvedValueOnce({
|
|
205
|
+
ok: true,
|
|
206
|
+
json: async () => ({
|
|
207
|
+
flags: {},
|
|
208
|
+
experiments: { 'exp-1': 'variant-a' },
|
|
209
|
+
}),
|
|
210
|
+
})
|
|
211
|
+
.mockResolvedValueOnce({
|
|
212
|
+
ok: true,
|
|
213
|
+
json: async () => ({
|
|
214
|
+
flags: {},
|
|
215
|
+
experiments: { 'exp-1': 'variant-b' },
|
|
216
|
+
}),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// Act
|
|
220
|
+
await freshGetExperiments()
|
|
221
|
+
freshClearCache()
|
|
222
|
+
const newExperiments = await freshGetExperiments()
|
|
223
|
+
|
|
224
|
+
// Assert
|
|
225
|
+
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
226
|
+
expect(newExperiments).toEqual({ 'exp-1': 'variant-b' })
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe('profileId handling', () => {
|
|
231
|
+
test('should include profileId in request when provided in options', async () => {
|
|
232
|
+
jest.resetModules()
|
|
233
|
+
const { init: freshInit, getExperiments: freshGetExperiments } = await import('../src/index')
|
|
234
|
+
freshInit(PROJECT_ID, { devMode: true })
|
|
235
|
+
|
|
236
|
+
mockFetch.mockResolvedValueOnce({
|
|
237
|
+
ok: true,
|
|
238
|
+
json: async () => ({
|
|
239
|
+
flags: {},
|
|
240
|
+
experiments: {},
|
|
241
|
+
}),
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// Act
|
|
245
|
+
await freshGetExperiments({ profileId: 'user-123' })
|
|
246
|
+
|
|
247
|
+
// Assert
|
|
248
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
249
|
+
expect.any(String),
|
|
250
|
+
expect.objectContaining({
|
|
251
|
+
body: JSON.stringify({
|
|
252
|
+
pid: PROJECT_ID,
|
|
253
|
+
profileId: 'user-123',
|
|
254
|
+
}),
|
|
255
|
+
}),
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('should use global profileId when not provided in options', async () => {
|
|
260
|
+
jest.resetModules()
|
|
261
|
+
const { init: freshInit, getExperiments: freshGetExperiments } = await import('../src/index')
|
|
262
|
+
freshInit(PROJECT_ID, { devMode: true, profileId: 'global-user' })
|
|
263
|
+
|
|
264
|
+
mockFetch.mockResolvedValueOnce({
|
|
265
|
+
ok: true,
|
|
266
|
+
json: async () => ({
|
|
267
|
+
flags: {},
|
|
268
|
+
experiments: {},
|
|
269
|
+
}),
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
// Act
|
|
273
|
+
await freshGetExperiments()
|
|
274
|
+
|
|
275
|
+
// Assert
|
|
276
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
277
|
+
expect.any(String),
|
|
278
|
+
expect.objectContaining({
|
|
279
|
+
body: JSON.stringify({
|
|
280
|
+
pid: PROJECT_ID,
|
|
281
|
+
profileId: 'global-user',
|
|
282
|
+
}),
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('error handling', () => {
|
|
289
|
+
test('should return empty object when fetch fails', async () => {
|
|
290
|
+
jest.resetModules()
|
|
291
|
+
const { init: freshInit, getExperiments: freshGetExperiments } = await import('../src/index')
|
|
292
|
+
freshInit(PROJECT_ID, { devMode: true })
|
|
293
|
+
|
|
294
|
+
// Arrange
|
|
295
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
|
296
|
+
|
|
297
|
+
// Suppress console.warn for this test
|
|
298
|
+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
|
|
299
|
+
|
|
300
|
+
// Act
|
|
301
|
+
const experiments = await freshGetExperiments()
|
|
302
|
+
|
|
303
|
+
// Assert
|
|
304
|
+
expect(experiments).toEqual({})
|
|
305
|
+
consoleSpy.mockRestore()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('should return empty object when response is not ok', async () => {
|
|
309
|
+
jest.resetModules()
|
|
310
|
+
const { init: freshInit, getExperiments: freshGetExperiments } = await import('../src/index')
|
|
311
|
+
freshInit(PROJECT_ID, { devMode: true })
|
|
312
|
+
|
|
313
|
+
// Arrange
|
|
314
|
+
mockFetch.mockResolvedValueOnce({
|
|
315
|
+
ok: false,
|
|
316
|
+
status: 500,
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
// Suppress console.warn for this test
|
|
320
|
+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
|
|
321
|
+
|
|
322
|
+
// Act
|
|
323
|
+
const experiments = await freshGetExperiments()
|
|
324
|
+
|
|
325
|
+
// Assert
|
|
326
|
+
expect(experiments).toEqual({})
|
|
327
|
+
consoleSpy.mockRestore()
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
})
|
package/tsconfig.esnext.json
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"moduleResolution": "node",
|
|
4
|
-
"target": "
|
|
5
|
-
"module": "
|
|
6
|
-
"lib": ["
|
|
4
|
+
"target": "ES2020",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"lib": ["ES2020", "DOM"],
|
|
7
7
|
"strict": true,
|
|
8
8
|
"sourceMap": true,
|
|
9
9
|
"declaration": true,
|
|
10
|
-
"allowSyntheticDefaultImports": true,
|
|
11
|
-
"experimentalDecorators": true,
|
|
12
|
-
"emitDecoratorMetadata": true,
|
|
13
10
|
"outDir": "dist/esnext",
|
|
14
11
|
"typeRoots": ["node_modules/@types"]
|
|
15
12
|
},
|
package/tsconfig.json
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"moduleResolution": "node",
|
|
4
|
-
"target": "
|
|
5
|
-
"module": "
|
|
6
|
-
"lib": ["
|
|
4
|
+
"target": "ES2018",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"lib": ["ES2018", "DOM"],
|
|
7
7
|
"strict": true,
|
|
8
|
-
"allowSyntheticDefaultImports": true,
|
|
9
|
-
"experimentalDecorators": true,
|
|
10
|
-
"emitDecoratorMetadata": true,
|
|
11
8
|
"sourceMap": true,
|
|
12
9
|
"declaration": false,
|
|
13
|
-
"
|
|
14
|
-
"typeRoots": ["node_modules/@types"]
|
|
10
|
+
"allowSyntheticDefaultImports": true
|
|
15
11
|
},
|
|
16
12
|
"include": ["src"]
|
|
17
13
|
}
|