@statsig/js-client 0.0.1-beta.10
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/.eslintrc.json +34 -0
- package/README.md +12 -0
- package/jest.config.ts +10 -0
- package/package.json +11 -0
- package/project.json +52 -0
- package/src/EvaluationData.ts +19 -0
- package/src/EvaluationResponseDeltas.ts +108 -0
- package/src/EvaluationStore.ts +99 -0
- package/src/Network.ts +112 -0
- package/src/StatsigClient.ts +251 -0
- package/src/StatsigEvaluationsDataAdapter.ts +36 -0
- package/src/StatsigMetadataAdditions.ts +7 -0
- package/src/StatsigOptions.ts +25 -0
- package/src/__tests__/CacheEviction.test.ts +55 -0
- package/src/__tests__/ClientErrrorBoundary.test.ts +29 -0
- package/src/__tests__/EvaluationCallbacks.test.ts +118 -0
- package/src/__tests__/EvaluationsDataAdapter.test.ts +131 -0
- package/src/__tests__/InitStrategyAwaited.test.ts +48 -0
- package/src/__tests__/InitStrategyBootstrap.test.ts +67 -0
- package/src/__tests__/InitStrategyDelayed.test.ts +65 -0
- package/src/__tests__/InitializeNetworkBadResponse.test.ts +77 -0
- package/src/__tests__/InitializeNetworkFailures.test.ts +91 -0
- package/src/__tests__/MockLocalStorage.ts +38 -0
- package/src/__tests__/PrecomputedEvaluationsClient.test.ts +27 -0
- package/src/__tests__/RacingUpdates.test.ts +108 -0
- package/src/__tests__/StatsigMetadata.test.ts +30 -0
- package/src/__tests__/initialize.json +117 -0
- package/src/index.ts +22 -0
- package/tsconfig.json +14 -0
- package/tsconfig.lib.json +10 -0
- package/tsconfig.spec.json +17 -0
- package/webpack.config.js +55 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DataAdapterResult,
|
|
3
|
+
EvaluationOptions,
|
|
4
|
+
EvaluationsDataAdapter,
|
|
5
|
+
FeatureGate,
|
|
6
|
+
StatsigUser,
|
|
7
|
+
} from '@statsig/client-core';
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_EVAL_OPTIONS,
|
|
10
|
+
DJB2,
|
|
11
|
+
DynamicConfig,
|
|
12
|
+
Experiment,
|
|
13
|
+
Layer,
|
|
14
|
+
Log,
|
|
15
|
+
PrecomputedEvaluationsInterface,
|
|
16
|
+
StatsigClientBase,
|
|
17
|
+
StatsigEvent,
|
|
18
|
+
createConfigExposure,
|
|
19
|
+
createGateExposure,
|
|
20
|
+
createLayerParameterExposure,
|
|
21
|
+
makeDynamicConfig,
|
|
22
|
+
makeFeatureGate,
|
|
23
|
+
makeLayer,
|
|
24
|
+
monitorClass,
|
|
25
|
+
normalizeUser,
|
|
26
|
+
} from '@statsig/client-core';
|
|
27
|
+
|
|
28
|
+
import EvaluationStore from './EvaluationStore';
|
|
29
|
+
import Network from './Network';
|
|
30
|
+
import { StatsigEvaluationsDataAdapter } from './StatsigEvaluationsDataAdapter';
|
|
31
|
+
import './StatsigMetadataAdditions';
|
|
32
|
+
import type { StatsigOptions } from './StatsigOptions';
|
|
33
|
+
|
|
34
|
+
export default class StatsigClient
|
|
35
|
+
extends StatsigClientBase<EvaluationsDataAdapter>
|
|
36
|
+
implements PrecomputedEvaluationsInterface
|
|
37
|
+
{
|
|
38
|
+
private _options: StatsigOptions;
|
|
39
|
+
private _network: Network;
|
|
40
|
+
private _store: EvaluationStore;
|
|
41
|
+
private _user: StatsigUser;
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
sdkKey: string,
|
|
45
|
+
user: StatsigUser,
|
|
46
|
+
options: StatsigOptions | null = null,
|
|
47
|
+
) {
|
|
48
|
+
const network = new Network(options, (e) => {
|
|
49
|
+
this._emit(e);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
super(
|
|
53
|
+
sdkKey,
|
|
54
|
+
options?.dataAdapter ?? new StatsigEvaluationsDataAdapter(),
|
|
55
|
+
network,
|
|
56
|
+
options,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
monitorClass(this._errorBoundary, this);
|
|
60
|
+
monitorClass(this._errorBoundary, network);
|
|
61
|
+
|
|
62
|
+
this._options = options ?? {};
|
|
63
|
+
this._store = new EvaluationStore(sdkKey);
|
|
64
|
+
this._network = network;
|
|
65
|
+
this._user = user;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
initializeSync(): void {
|
|
69
|
+
this.updateUserSync(this._user);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
initializeAsync(): Promise<void> {
|
|
73
|
+
return this.updateUserAsync(this._user);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getCurrentUser(): StatsigUser {
|
|
77
|
+
return JSON.parse(JSON.stringify(this._user)) as StatsigUser;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
updateUserSync(user: StatsigUser): void {
|
|
81
|
+
this._resetForUser(user);
|
|
82
|
+
|
|
83
|
+
const result = this.dataAdapter.getDataSync(this._user);
|
|
84
|
+
this._store.setValuesFromDataAdapter(result);
|
|
85
|
+
|
|
86
|
+
this._store.finalize();
|
|
87
|
+
this._setStatus('Ready', result);
|
|
88
|
+
|
|
89
|
+
this._runPostUpdate(result ?? null, this._user);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async updateUserAsync(user: StatsigUser): Promise<void> {
|
|
93
|
+
this._resetForUser(user);
|
|
94
|
+
|
|
95
|
+
const initiator = this._user;
|
|
96
|
+
|
|
97
|
+
let result = this.dataAdapter.getDataSync(initiator);
|
|
98
|
+
this._setStatus('Loading', result);
|
|
99
|
+
|
|
100
|
+
this._store.setValuesFromDataAdapter(result);
|
|
101
|
+
|
|
102
|
+
result = await this.dataAdapter.getDataAsync(result, initiator);
|
|
103
|
+
|
|
104
|
+
// ensure the user hasn't changed while we were waiting
|
|
105
|
+
if (initiator === this._user) {
|
|
106
|
+
this._store.setValuesFromDataAdapter(result);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this._store.finalize();
|
|
110
|
+
this._setStatus('Ready', result);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async shutdown(): Promise<void> {
|
|
114
|
+
await this._logger.shutdown();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
checkGate(
|
|
118
|
+
name: string,
|
|
119
|
+
options: EvaluationOptions = DEFAULT_EVAL_OPTIONS,
|
|
120
|
+
): boolean {
|
|
121
|
+
return this.getFeatureGate(name, options).value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getFeatureGate(
|
|
125
|
+
name: string,
|
|
126
|
+
options: EvaluationOptions = DEFAULT_EVAL_OPTIONS,
|
|
127
|
+
): FeatureGate {
|
|
128
|
+
const hash = DJB2(name);
|
|
129
|
+
|
|
130
|
+
const { evaluation, details } = this._store.getGate(hash);
|
|
131
|
+
const gate = makeFeatureGate(name, details, evaluation);
|
|
132
|
+
const overridden = this._overrideAdapter?.getGateOverride?.(
|
|
133
|
+
gate,
|
|
134
|
+
this._user,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const result = overridden ?? gate;
|
|
138
|
+
|
|
139
|
+
this._enqueueExposure(
|
|
140
|
+
name,
|
|
141
|
+
options,
|
|
142
|
+
createGateExposure(this._user, result, evaluation?.secondary_exposures),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
this._emit({ event: 'gate_evaluation', gate: result });
|
|
146
|
+
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getDynamicConfig(
|
|
151
|
+
name: string,
|
|
152
|
+
options: EvaluationOptions = DEFAULT_EVAL_OPTIONS,
|
|
153
|
+
): DynamicConfig {
|
|
154
|
+
const dynamicConfig = this._getConfigImpl('dynamic_config', name, options);
|
|
155
|
+
this._emit({ event: 'dynamic_config_evaluation', dynamicConfig });
|
|
156
|
+
return dynamicConfig;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
getExperiment(
|
|
160
|
+
name: string,
|
|
161
|
+
options: EvaluationOptions = DEFAULT_EVAL_OPTIONS,
|
|
162
|
+
): Experiment {
|
|
163
|
+
const experiment = this._getConfigImpl('experiment', name, options);
|
|
164
|
+
this._emit({ event: 'experiment_evaluation', experiment });
|
|
165
|
+
return experiment;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getLayer(
|
|
169
|
+
name: string,
|
|
170
|
+
options: EvaluationOptions = DEFAULT_EVAL_OPTIONS,
|
|
171
|
+
): Layer {
|
|
172
|
+
const hash = DJB2(name);
|
|
173
|
+
|
|
174
|
+
const { evaluation, details } = this._store.getLayer(hash);
|
|
175
|
+
const layer = makeLayer(name, details, evaluation);
|
|
176
|
+
|
|
177
|
+
const overridden = this._overrideAdapter?.getLayerOverride?.(
|
|
178
|
+
layer,
|
|
179
|
+
this._user,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const result = overridden ?? layer;
|
|
183
|
+
this._emit({ event: 'layer_evaluation', layer: result });
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
...result,
|
|
187
|
+
getValue: (param) => {
|
|
188
|
+
if (!(param in result._value)) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const exposure = createLayerParameterExposure(
|
|
193
|
+
this._user,
|
|
194
|
+
result,
|
|
195
|
+
param,
|
|
196
|
+
result.__evaluation,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
this._enqueueExposure(name, options, exposure);
|
|
200
|
+
|
|
201
|
+
return result._value[param] ?? null;
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
logEvent(event: StatsigEvent): void {
|
|
207
|
+
this._logger.enqueue({ ...event, user: this._user, time: Date.now() });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private _runPostUpdate(
|
|
211
|
+
current: DataAdapterResult | null,
|
|
212
|
+
user: StatsigUser,
|
|
213
|
+
): void {
|
|
214
|
+
this.dataAdapter.getDataAsync(current, user).catch((err) => {
|
|
215
|
+
Log.error('An error occurred after update.', err);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private _resetForUser(user: StatsigUser) {
|
|
220
|
+
this._logger.reset();
|
|
221
|
+
this._store.reset();
|
|
222
|
+
|
|
223
|
+
this._user = normalizeUser(user, this._options.environment);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private _getConfigImpl(
|
|
227
|
+
kind: 'experiment' | 'dynamic_config',
|
|
228
|
+
name: string,
|
|
229
|
+
options: EvaluationOptions,
|
|
230
|
+
): DynamicConfig {
|
|
231
|
+
const hash = DJB2(name);
|
|
232
|
+
const { evaluation, details } = this._store.getConfig(hash);
|
|
233
|
+
|
|
234
|
+
const config = makeDynamicConfig(name, details, evaluation);
|
|
235
|
+
|
|
236
|
+
const overridden =
|
|
237
|
+
kind === 'experiment'
|
|
238
|
+
? this._overrideAdapter?.getExperimentOverride?.(config, this._user)
|
|
239
|
+
: this._overrideAdapter?.getDynamicConfigOverride?.(config, this._user);
|
|
240
|
+
|
|
241
|
+
const result = overridden ?? config;
|
|
242
|
+
|
|
243
|
+
this._enqueueExposure(
|
|
244
|
+
name,
|
|
245
|
+
options,
|
|
246
|
+
createConfigExposure(this._user, result, evaluation?.secondary_exposures),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DataAdapterCore,
|
|
3
|
+
EvaluationsDataAdapter,
|
|
4
|
+
StatsigOptionsCommon,
|
|
5
|
+
StatsigUser,
|
|
6
|
+
} from '@statsig/client-core';
|
|
7
|
+
|
|
8
|
+
import Network from './Network';
|
|
9
|
+
|
|
10
|
+
export class StatsigEvaluationsDataAdapter
|
|
11
|
+
extends DataAdapterCore
|
|
12
|
+
implements EvaluationsDataAdapter
|
|
13
|
+
{
|
|
14
|
+
private _network: Network | null = null;
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
super('EvaluationsDataAdapter', 'evaluations');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
override attach(sdkKey: string, options: StatsigOptionsCommon | null): void {
|
|
21
|
+
super.attach(sdkKey, options);
|
|
22
|
+
this._network = new Network(options ?? {});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
protected override async _fetchFromNetwork(
|
|
26
|
+
current: string | null,
|
|
27
|
+
user?: StatsigUser,
|
|
28
|
+
): Promise<string | null> {
|
|
29
|
+
const result = await this._network?.fetchEvaluations(
|
|
30
|
+
this._getSdkKey(),
|
|
31
|
+
current,
|
|
32
|
+
user,
|
|
33
|
+
);
|
|
34
|
+
return result ?? null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EvaluationsDataAdapter,
|
|
3
|
+
Flatten,
|
|
4
|
+
StatsigOptionsCommon,
|
|
5
|
+
} from '@statsig/client-core';
|
|
6
|
+
|
|
7
|
+
export type StatsigOptions = Flatten<
|
|
8
|
+
StatsigOptionsCommon & {
|
|
9
|
+
/**
|
|
10
|
+
* An implementor of {@link EvaluationsDataAdapter}, used to customize the initialization/update flow.
|
|
11
|
+
*
|
|
12
|
+
* default: `StatsigEvaluationsDataAdapter`
|
|
13
|
+
*
|
|
14
|
+
* @see {@link https://docs.statsig.com/client/javascript-sdk/using-evaluations-data-adapter}
|
|
15
|
+
*/
|
|
16
|
+
dataAdapter?: EvaluationsDataAdapter;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The URL used to fetch the latest evaluations for a given user. Takes precedence over {@link StatsigOptionsCommon.api}.
|
|
20
|
+
*
|
|
21
|
+
* default: `https://api.statsig.com/v1/initialize`
|
|
22
|
+
*/
|
|
23
|
+
initializeUrl?: string;
|
|
24
|
+
}
|
|
25
|
+
>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fetchMock from 'jest-fetch-mock';
|
|
2
|
+
|
|
3
|
+
import { DataAdapterCachePrefix } from '@statsig/client-core';
|
|
4
|
+
|
|
5
|
+
import { StatsigEvaluationsDataAdapter } from '../StatsigEvaluationsDataAdapter';
|
|
6
|
+
import { MockLocalStorage } from './MockLocalStorage';
|
|
7
|
+
import InitializeResponse from './initialize.json';
|
|
8
|
+
|
|
9
|
+
describe('Cache Eviction', () => {
|
|
10
|
+
let storageMock: MockLocalStorage;
|
|
11
|
+
let adapter: StatsigEvaluationsDataAdapter;
|
|
12
|
+
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
storageMock = MockLocalStorage.enabledMockStorage();
|
|
15
|
+
storageMock.clear();
|
|
16
|
+
|
|
17
|
+
fetchMock.enableMocks();
|
|
18
|
+
fetchMock.mockResponse(JSON.stringify(InitializeResponse));
|
|
19
|
+
|
|
20
|
+
adapter = new StatsigEvaluationsDataAdapter();
|
|
21
|
+
adapter.attach('client-key', null);
|
|
22
|
+
|
|
23
|
+
for (let i = 0; i < 20; i++) {
|
|
24
|
+
// eslint-disable-next-line no-await-in-loop
|
|
25
|
+
await adapter.getDataAsync(null, { userID: `user-${i}` });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
MockLocalStorage.disableMockStorage();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should only have 10 entries in _inMemoryCache', () => {
|
|
34
|
+
const entries = Object.entries((adapter as any)._inMemoryCache);
|
|
35
|
+
expect(entries.length).toBe(10);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should only have 10 user cache entries', () => {
|
|
39
|
+
const entries = Object.entries(storageMock.data).filter((e) =>
|
|
40
|
+
e[0].startsWith(DataAdapterCachePrefix),
|
|
41
|
+
);
|
|
42
|
+
expect(entries.length).toBe(10);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('only writes the expected keys', () => {
|
|
46
|
+
const keys = Object.keys(storageMock.data).map((k) =>
|
|
47
|
+
k.split('.').slice(0, 2).join('.'),
|
|
48
|
+
);
|
|
49
|
+
expect(Array.from(new Set(keys))).toEqual([
|
|
50
|
+
'statsig.stable_id',
|
|
51
|
+
'statsig.last_modified_time',
|
|
52
|
+
DataAdapterCachePrefix,
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fetchMock from 'jest-fetch-mock';
|
|
2
|
+
|
|
3
|
+
import { LogLevel } from '@statsig/client-core';
|
|
4
|
+
|
|
5
|
+
import StatsigClient from '../StatsigClient';
|
|
6
|
+
import InitializeResponse from './initialize.json';
|
|
7
|
+
|
|
8
|
+
describe('Client Error Boundary', () => {
|
|
9
|
+
let client: StatsigClient;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
client = new StatsigClient(
|
|
13
|
+
'client-key',
|
|
14
|
+
{ userID: '' },
|
|
15
|
+
{
|
|
16
|
+
logLevel: LogLevel.None,
|
|
17
|
+
},
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
fetchMock.enableMocks();
|
|
21
|
+
fetchMock.mockResponse(JSON.stringify(InitializeResponse));
|
|
22
|
+
client.initializeSync();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('catches errors', () => {
|
|
26
|
+
(client as any)._logger = 1;
|
|
27
|
+
expect(() => client.checkGate('test_public')).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fetchMock from 'jest-fetch-mock';
|
|
2
|
+
import { anyFunction, anyNumber, anyObject } from 'statsig-test-helpers';
|
|
3
|
+
|
|
4
|
+
import { StatsigClientEventData } from '@statsig/client-core';
|
|
5
|
+
|
|
6
|
+
import StatsigClient from '../StatsigClient';
|
|
7
|
+
import InitializeResponse from './initialize.json';
|
|
8
|
+
|
|
9
|
+
describe('Client Evaluations Callback', () => {
|
|
10
|
+
const user = { userID: 'a-user' };
|
|
11
|
+
let client: StatsigClient;
|
|
12
|
+
let events: StatsigClientEventData[] = [];
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
events = [];
|
|
16
|
+
client = new StatsigClient('client-key', user);
|
|
17
|
+
|
|
18
|
+
InitializeResponse['time'] = 123456;
|
|
19
|
+
|
|
20
|
+
fetchMock.enableMocks();
|
|
21
|
+
fetchMock.mockResponse(JSON.stringify(InitializeResponse));
|
|
22
|
+
|
|
23
|
+
await client.initializeAsync();
|
|
24
|
+
|
|
25
|
+
client.on('*', (data) => {
|
|
26
|
+
if (data.event.endsWith('_evaluation')) {
|
|
27
|
+
events.push(data);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('fires the gate_evaluation event', () => {
|
|
33
|
+
client.checkGate('a_gate');
|
|
34
|
+
expect(events).toEqual([
|
|
35
|
+
{
|
|
36
|
+
event: 'gate_evaluation',
|
|
37
|
+
gate: {
|
|
38
|
+
name: 'a_gate',
|
|
39
|
+
ruleID: '2QWhVkWdUEXR6Q3KYgV73O',
|
|
40
|
+
details: {
|
|
41
|
+
lcut: 123456,
|
|
42
|
+
reason: 'Network:Recognized',
|
|
43
|
+
receivedAt: anyNumber(),
|
|
44
|
+
},
|
|
45
|
+
value: true,
|
|
46
|
+
__evaluation: anyObject(),
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('fires the dynamic_config_evaluation event', () => {
|
|
53
|
+
client.getDynamicConfig('a_dynamic_config');
|
|
54
|
+
expect(events).toEqual([
|
|
55
|
+
{
|
|
56
|
+
event: 'dynamic_config_evaluation',
|
|
57
|
+
dynamicConfig: {
|
|
58
|
+
name: 'a_dynamic_config',
|
|
59
|
+
ruleID: 'default',
|
|
60
|
+
details: {
|
|
61
|
+
lcut: 123456,
|
|
62
|
+
reason: 'Network:Recognized',
|
|
63
|
+
receivedAt: anyNumber(),
|
|
64
|
+
},
|
|
65
|
+
value: {
|
|
66
|
+
blue: '#00FF00',
|
|
67
|
+
green: '#0000FF',
|
|
68
|
+
red: '#FF0000',
|
|
69
|
+
},
|
|
70
|
+
__evaluation: anyObject(),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('fires the experiment_evaluation event', () => {
|
|
77
|
+
client.getExperiment('an_experiment');
|
|
78
|
+
expect(events).toEqual([
|
|
79
|
+
{
|
|
80
|
+
event: 'experiment_evaluation',
|
|
81
|
+
experiment: {
|
|
82
|
+
name: 'an_experiment',
|
|
83
|
+
ruleID: '49CGlTB7z97PEdqJapQplA',
|
|
84
|
+
details: {
|
|
85
|
+
lcut: 123456,
|
|
86
|
+
reason: 'Network:Recognized',
|
|
87
|
+
receivedAt: anyNumber(),
|
|
88
|
+
},
|
|
89
|
+
value: {
|
|
90
|
+
a_string: 'Experiment Test Value',
|
|
91
|
+
},
|
|
92
|
+
__evaluation: anyObject(),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('fires the layer_evaluation event', () => {
|
|
99
|
+
client.getLayer('a_layer');
|
|
100
|
+
expect(events).toEqual([
|
|
101
|
+
{
|
|
102
|
+
event: 'layer_evaluation',
|
|
103
|
+
layer: {
|
|
104
|
+
name: 'a_layer',
|
|
105
|
+
ruleID: '49CGlTB7z97PEdqJapQplA',
|
|
106
|
+
details: {
|
|
107
|
+
lcut: 123456,
|
|
108
|
+
reason: 'Network:Recognized',
|
|
109
|
+
receivedAt: anyNumber(),
|
|
110
|
+
},
|
|
111
|
+
getValue: anyFunction(),
|
|
112
|
+
_value: anyObject(),
|
|
113
|
+
__evaluation: anyObject(),
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fetchMock from 'jest-fetch-mock';
|
|
2
|
+
|
|
3
|
+
import { DataAdapterResult, Log } from '@statsig/client-core';
|
|
4
|
+
|
|
5
|
+
import { StatsigEvaluationsDataAdapter } from '../StatsigEvaluationsDataAdapter';
|
|
6
|
+
import { MockLocalStorage } from './MockLocalStorage';
|
|
7
|
+
import InitializeResponse from './initialize.json';
|
|
8
|
+
|
|
9
|
+
const InitializeResponseString = JSON.stringify(InitializeResponse);
|
|
10
|
+
|
|
11
|
+
describe('Evaluations Data Adapter', () => {
|
|
12
|
+
const sdkKey = 'client-key';
|
|
13
|
+
const user = { userID: 'a-user' };
|
|
14
|
+
|
|
15
|
+
let storageMock: MockLocalStorage;
|
|
16
|
+
let adapter: StatsigEvaluationsDataAdapter;
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
storageMock = MockLocalStorage.enabledMockStorage();
|
|
20
|
+
storageMock.clear();
|
|
21
|
+
|
|
22
|
+
fetchMock.enableMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterAll(() => {
|
|
26
|
+
jest.clearAllMocks();
|
|
27
|
+
MockLocalStorage.disableMockStorage();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('attach', () => {
|
|
31
|
+
it('logs an error when called before attach', () => {
|
|
32
|
+
Log.error = jest.fn();
|
|
33
|
+
|
|
34
|
+
new StatsigEvaluationsDataAdapter().getDataSync(user);
|
|
35
|
+
|
|
36
|
+
expect(Log.error).toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('getDataSync', () => {
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
fetchMock.mockResponse(InitializeResponseString);
|
|
43
|
+
|
|
44
|
+
adapter = new StatsigEvaluationsDataAdapter();
|
|
45
|
+
adapter.attach(sdkKey, null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns null when nothing is found', () => {
|
|
49
|
+
const result = adapter.getDataSync(user);
|
|
50
|
+
expect(result).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns bootstrapped values', () => {
|
|
54
|
+
adapter.setData(InitializeResponseString, user);
|
|
55
|
+
|
|
56
|
+
const result = adapter.getDataSync(user);
|
|
57
|
+
|
|
58
|
+
expect(result?.source).toBe('Bootstrap');
|
|
59
|
+
expect(result?.data).toBe(InitializeResponseString);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns prefetched values', async () => {
|
|
63
|
+
await adapter.prefetchData(user);
|
|
64
|
+
|
|
65
|
+
const result = adapter.getDataSync(user);
|
|
66
|
+
|
|
67
|
+
expect(result?.source).toBe('Prefetch');
|
|
68
|
+
expect(result?.data).toBe(InitializeResponseString);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('getDataAsync', () => {
|
|
73
|
+
let result: DataAdapterResult | null;
|
|
74
|
+
|
|
75
|
+
beforeEach(async () => {
|
|
76
|
+
fetchMock.mock.calls = [];
|
|
77
|
+
fetchMock.mockResponse(InitializeResponseString);
|
|
78
|
+
|
|
79
|
+
adapter = new StatsigEvaluationsDataAdapter();
|
|
80
|
+
adapter.attach(sdkKey, null);
|
|
81
|
+
result = await adapter.getDataAsync(null, user);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns the network result on success', () => {
|
|
85
|
+
expect(result?.source).toBe('Network');
|
|
86
|
+
expect(result?.data).toBe(InitializeResponseString);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('saves the network value for later getDataSync calls', () => {
|
|
90
|
+
const syncResult = adapter.getDataSync(user);
|
|
91
|
+
expect(syncResult?.source).toBe('Network');
|
|
92
|
+
expect(syncResult?.data).toBe(InitializeResponseString);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('is cached for later sessions', () => {
|
|
96
|
+
const nextAdapter = new StatsigEvaluationsDataAdapter();
|
|
97
|
+
nextAdapter.attach(sdkKey, null);
|
|
98
|
+
|
|
99
|
+
const syncResult = nextAdapter.getDataSync(user);
|
|
100
|
+
expect(syncResult?.source).toBe('Cache');
|
|
101
|
+
expect(syncResult?.data).toBe(InitializeResponseString);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns NetworkNotModifed on 204', async () => {
|
|
105
|
+
fetchMock.mockResponse('', { status: 204 });
|
|
106
|
+
|
|
107
|
+
const notModifiedResult = await adapter.getDataAsync(null, user);
|
|
108
|
+
|
|
109
|
+
expect(notModifiedResult?.source).toBe('NetworkNotModified');
|
|
110
|
+
expect(notModifiedResult?.data).toBe(InitializeResponseString);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns null on network failure', async () => {
|
|
114
|
+
fetchMock.mockReject();
|
|
115
|
+
|
|
116
|
+
const errorResult = await adapter.getDataAsync(null, user);
|
|
117
|
+
|
|
118
|
+
expect(errorResult).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('hits error boundary', async () => {
|
|
122
|
+
(adapter as any).getDataSync = () => {
|
|
123
|
+
throw new Error('Test');
|
|
124
|
+
};
|
|
125
|
+
await adapter.prefetchData({ userID: 'a' });
|
|
126
|
+
expect(fetchMock.mock.calls[1][0]).toBe(
|
|
127
|
+
'https://statsigapi.net/v1/sdk_exception',
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fetchMock from 'jest-fetch-mock';
|
|
2
|
+
|
|
3
|
+
import StatsigClient from '../StatsigClient';
|
|
4
|
+
import { MockLocalStorage } from './MockLocalStorage';
|
|
5
|
+
import InitializeResponse from './initialize.json';
|
|
6
|
+
|
|
7
|
+
describe('Init Strategy - Awaited', () => {
|
|
8
|
+
const sdkKey = 'client-key';
|
|
9
|
+
const user = { userID: 'a-user' };
|
|
10
|
+
|
|
11
|
+
let client: StatsigClient;
|
|
12
|
+
let storageMock: MockLocalStorage;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
storageMock = MockLocalStorage.enabledMockStorage();
|
|
16
|
+
storageMock.clear();
|
|
17
|
+
|
|
18
|
+
fetchMock.enableMocks();
|
|
19
|
+
fetchMock.mockResponse(JSON.stringify(InitializeResponse));
|
|
20
|
+
|
|
21
|
+
client = new StatsigClient(sdkKey, user);
|
|
22
|
+
await client.initializeAsync();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterAll(() => {
|
|
26
|
+
MockLocalStorage.disableMockStorage();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('is ready after initialize', () => {
|
|
30
|
+
expect(client.loadingStatus).toBe('Ready');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('reports source as "Network"', () => {
|
|
34
|
+
const gate = client.getFeatureGate('a_gate');
|
|
35
|
+
expect(gate.details.reason).toBe('Network:Recognized');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('calls /initialize from network', () => {
|
|
39
|
+
expect(fetchMock.mock.calls).toHaveLength(1);
|
|
40
|
+
expect(fetchMock.mock.calls[0][0]).toContain(
|
|
41
|
+
'https://api.statsig.com/v1/initialize',
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('writes nothing to storage', () => {
|
|
46
|
+
expect(storageMock.data).toMatchObject({});
|
|
47
|
+
});
|
|
48
|
+
});
|