@tehw0lf/yaft 0.0.12 → 0.0.16
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/FeatureToggle.d.ts +20 -0
- package/FeatureToggle.js +72 -0
- package/README.md +41 -1
- package/evaluate.d.ts +42 -0
- package/evaluate.js +102 -0
- package/examples/ApiServiceBooleanProvider.d.ts +11 -0
- package/examples/ApiServiceBooleanProvider.js +82 -0
- package/examples/ApiServiceFeatureProvider.d.ts +17 -0
- package/examples/ApiServiceFeatureProvider.js +54 -0
- package/examples/LocalStorageBooleanProvider.d.ts +7 -0
- package/examples/LocalStorageBooleanProvider.js +26 -0
- package/examples/LocalStorageFeatureProvider.d.ts +14 -0
- package/examples/LocalStorageFeatureProvider.js +31 -0
- package/index.d.ts +3 -0
- package/index.js +13 -0
- package/mapping.d.ts +27 -0
- package/mapping.js +79 -0
- package/package.json +24 -10
- package/.github/workflows/build.yml +0 -28
- package/.github/workflows/security-scan.yml +0 -79
- package/CLAUDE.md +0 -151
- package/LICENSE +0 -21
- package/jest.config.js +0 -16
- package/src/FeatureToggle.ts +0 -82
- package/src/evaluate.ts +0 -122
- package/src/examples/ApiServiceBooleanProvider.ts +0 -60
- package/src/examples/ApiServiceFeatureProvider.ts +0 -70
- package/src/examples/LocalStorageBooleanProvider.ts +0 -25
- package/src/examples/LocalStorageFeatureProvider.ts +0 -31
- package/src/index.ts +0 -7
- package/src/test/decorator-behavior.spec.ts +0 -359
- package/src/test/error-handling.spec.ts +0 -396
- package/src/test/evaluate.spec.ts +0 -260
- package/src/test/feature-data.spec.ts +0 -102
- package/src/test/injectable-clock.spec.ts +0 -112
- package/src/test/integration.spec.ts +0 -472
- package/src/test/providers.spec.ts +0 -218
- package/src/test/test-boolean.json +0 -1
- package/src/test/test-feature.json +0 -20
- package/src/test/test-setup.ts +0 -2
- package/src/test/time-based-logic.spec.ts +0 -169
- package/src/test/yaft.demo.spec.ts +0 -53
- package/tsconfig.json +0 -14
- package/tsconfig.spec.json +0 -10
|
@@ -1,396 +0,0 @@
|
|
|
1
|
-
import { FeatureToggle, FeatureToggleBase, FeatureProvider, Feature } from '../FeatureToggle';
|
|
2
|
-
import { LocalStorageFeatureProvider } from '../examples/LocalStorageFeatureProvider';
|
|
3
|
-
import { ApiServiceFeatureProvider } from '../examples/ApiServiceFeatureProvider';
|
|
4
|
-
import axios from 'axios';
|
|
5
|
-
|
|
6
|
-
// Mock axios for API provider tests
|
|
7
|
-
jest.mock('axios');
|
|
8
|
-
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
9
|
-
mockedAxios.get = jest.fn();
|
|
10
|
-
|
|
11
|
-
describe('Error Handling and Edge Cases', () => {
|
|
12
|
-
let mockProvider: FeatureProvider<boolean>;
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
mockProvider = {
|
|
16
|
-
data: {},
|
|
17
|
-
getConfig: jest.fn(),
|
|
18
|
-
isEnabled: jest.fn()
|
|
19
|
-
};
|
|
20
|
-
FeatureToggleBase.featureProvider = mockProvider;
|
|
21
|
-
jest.clearAllMocks();
|
|
22
|
-
// Reset axios mock
|
|
23
|
-
(mockedAxios.get as jest.Mock).mockReset();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
describe('Provider Initialization Errors', () => {
|
|
27
|
-
it('should handle missing provider gracefully', () => {
|
|
28
|
-
FeatureToggleBase.featureProvider = null as any;
|
|
29
|
-
|
|
30
|
-
expect(() => {
|
|
31
|
-
class TestClass {
|
|
32
|
-
@FeatureToggle('test')
|
|
33
|
-
method() {}
|
|
34
|
-
}
|
|
35
|
-
}).toThrow('FeatureToggleProvider not set');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('should handle undefined provider gracefully', () => {
|
|
39
|
-
FeatureToggleBase.featureProvider = undefined as any;
|
|
40
|
-
|
|
41
|
-
expect(() => {
|
|
42
|
-
class TestClass {
|
|
43
|
-
@FeatureToggle('test')
|
|
44
|
-
method() {}
|
|
45
|
-
}
|
|
46
|
-
}).toThrow('FeatureToggleProvider not set');
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe('LocalStorageFeatureProvider Error Handling', () => {
|
|
51
|
-
it('should handle non-existent configuration files', () => {
|
|
52
|
-
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
53
|
-
|
|
54
|
-
const provider = new LocalStorageFeatureProvider('./non-existent-file.json');
|
|
55
|
-
|
|
56
|
-
expect(provider.data).toEqual({});
|
|
57
|
-
expect(provider.isEnabled('anyKey')).toBe(false);
|
|
58
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
59
|
-
'Failed to load configuration from local file:',
|
|
60
|
-
expect.objectContaining({
|
|
61
|
-
code: 'MODULE_NOT_FOUND'
|
|
62
|
-
})
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
consoleSpy.mockRestore();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('should handle corrupted JSON files', () => {
|
|
69
|
-
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
70
|
-
const originalRequire = require;
|
|
71
|
-
|
|
72
|
-
// Mock require to throw JSON parsing error
|
|
73
|
-
(global as any).require = jest.fn(() => {
|
|
74
|
-
throw new SyntaxError('Unexpected token in JSON');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
const provider = new LocalStorageFeatureProvider('./corrupted.json');
|
|
78
|
-
|
|
79
|
-
expect(provider.data).toEqual({});
|
|
80
|
-
expect(provider.isEnabled('anyKey')).toBe(false);
|
|
81
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
82
|
-
|
|
83
|
-
// Restore
|
|
84
|
-
(global as any).require = originalRequire;
|
|
85
|
-
consoleSpy.mockRestore();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('should handle features with malformed date strings', () => {
|
|
89
|
-
const provider = new (class extends LocalStorageFeatureProvider {
|
|
90
|
-
constructor() {
|
|
91
|
-
super('');
|
|
92
|
-
this.data = {
|
|
93
|
-
'malformedDates': {
|
|
94
|
-
key: 'malformedDates',
|
|
95
|
-
value: 'true',
|
|
96
|
-
activeAt: 'invalid-date',
|
|
97
|
-
disabledAt: 'also-invalid-date'
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
})();
|
|
102
|
-
|
|
103
|
-
// Date.parse returns NaN for invalid dates, which should be handled
|
|
104
|
-
const result = provider.isEnabled('malformedDates');
|
|
105
|
-
expect(typeof result).toBe('boolean');
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('should handle features with null/undefined date values', () => {
|
|
109
|
-
const provider = new (class extends LocalStorageFeatureProvider {
|
|
110
|
-
constructor() {
|
|
111
|
-
super('');
|
|
112
|
-
this.data = {
|
|
113
|
-
'nullDates': {
|
|
114
|
-
key: 'nullDates',
|
|
115
|
-
value: 'true',
|
|
116
|
-
activeAt: null as any,
|
|
117
|
-
disabledAt: undefined as any
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
})();
|
|
122
|
-
|
|
123
|
-
const result = provider.isEnabled('nullDates');
|
|
124
|
-
expect(typeof result).toBe('boolean');
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('should handle features with invalid boolean values', () => {
|
|
128
|
-
const provider = new (class extends LocalStorageFeatureProvider {
|
|
129
|
-
constructor() {
|
|
130
|
-
super('');
|
|
131
|
-
this.data = {
|
|
132
|
-
'invalidBoolean': {
|
|
133
|
-
key: 'invalidBoolean',
|
|
134
|
-
value: 'not-a-boolean',
|
|
135
|
-
activeAt: '',
|
|
136
|
-
disabledAt: ''
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
})();
|
|
141
|
-
|
|
142
|
-
// New behavior: safely returns false for any value that is not "true"
|
|
143
|
-
expect(provider.isEnabled('invalidBoolean')).toBe(false);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
describe('ApiServiceFeatureProvider Error Handling', () => {
|
|
148
|
-
it('should handle network failures gracefully', async () => {
|
|
149
|
-
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
150
|
-
mockedAxios.get.mockRejectedValue(new Error('Network error'));
|
|
151
|
-
|
|
152
|
-
const provider = new ApiServiceFeatureProvider('https://api.test.com', 'test-uuid');
|
|
153
|
-
|
|
154
|
-
// Allow async operations to complete
|
|
155
|
-
await new Promise(resolve => setTimeout(resolve, 0));
|
|
156
|
-
|
|
157
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
158
|
-
'Failed to fetch feature toggle from API:',
|
|
159
|
-
expect.any(Error)
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
consoleSpy.mockRestore();
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('should handle API returning invalid data format', async () => {
|
|
166
|
-
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
167
|
-
mockedAxios.get
|
|
168
|
-
.mockResolvedValueOnce({ data: { value: 'invalid-hash' } }) // collection hash
|
|
169
|
-
.mockResolvedValueOnce({ data: { value: 'not-an-object' } }); // features
|
|
170
|
-
|
|
171
|
-
const provider = new ApiServiceFeatureProvider('https://api.test.com', 'test-uuid');
|
|
172
|
-
|
|
173
|
-
await new Promise(resolve => setTimeout(resolve, 0));
|
|
174
|
-
|
|
175
|
-
// Should handle gracefully without crashing
|
|
176
|
-
expect(provider.data).toBeDefined();
|
|
177
|
-
|
|
178
|
-
consoleSpy.mockRestore();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('should handle HTTP error responses', async () => {
|
|
182
|
-
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
183
|
-
mockedAxios.get.mockRejectedValue({
|
|
184
|
-
response: { status: 404, statusText: 'Not Found' }
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
const provider = new ApiServiceFeatureProvider('https://api.test.com', 'test-uuid');
|
|
188
|
-
|
|
189
|
-
await new Promise(resolve => setTimeout(resolve, 0));
|
|
190
|
-
|
|
191
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
192
|
-
|
|
193
|
-
consoleSpy.mockRestore();
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('should handle timeout errors', async () => {
|
|
197
|
-
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
198
|
-
mockedAxios.get.mockRejectedValue({ code: 'ECONNABORTED', message: 'timeout' });
|
|
199
|
-
|
|
200
|
-
const provider = new ApiServiceFeatureProvider('https://api.test.com', 'test-uuid');
|
|
201
|
-
|
|
202
|
-
await new Promise(resolve => setTimeout(resolve, 0));
|
|
203
|
-
|
|
204
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
205
|
-
|
|
206
|
-
consoleSpy.mockRestore();
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
describe('Decorator Edge Cases', () => {
|
|
211
|
-
beforeEach(() => {
|
|
212
|
-
(mockProvider.isEnabled as jest.Mock).mockReturnValue(false);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('should handle methods throwing errors in fallbacks', () => {
|
|
216
|
-
function errorFallback() {
|
|
217
|
-
throw new Error('Fallback error');
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
class TestClass {
|
|
221
|
-
@FeatureToggle('disabled', errorFallback)
|
|
222
|
-
method() {
|
|
223
|
-
return 'original';
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const instance = new TestClass();
|
|
228
|
-
|
|
229
|
-
expect(() => {
|
|
230
|
-
instance.method();
|
|
231
|
-
}).toThrow('Fallback error');
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it('should handle async methods throwing errors', async () => {
|
|
235
|
-
(mockProvider.isEnabled as jest.Mock).mockReturnValue(true);
|
|
236
|
-
|
|
237
|
-
class TestClass {
|
|
238
|
-
@FeatureToggle('enabled')
|
|
239
|
-
async asyncMethod() {
|
|
240
|
-
throw new Error('Async error');
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const instance = new TestClass();
|
|
245
|
-
|
|
246
|
-
await expect(instance.asyncMethod()).rejects.toThrow('Async error');
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('should handle circular reference in fallback class', () => {
|
|
250
|
-
class CircularClass {
|
|
251
|
-
constructor() {
|
|
252
|
-
(this as any).self = this;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
@FeatureToggle('disabled', CircularClass)
|
|
257
|
-
class TestClass {
|
|
258
|
-
constructor() {}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
expect(() => {
|
|
262
|
-
new TestClass();
|
|
263
|
-
}).not.toThrow();
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it('should handle fallback class constructor throwing errors', () => {
|
|
267
|
-
class ErrorClass {
|
|
268
|
-
constructor() {
|
|
269
|
-
throw new Error('Constructor error');
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
@FeatureToggle('disabled', ErrorClass)
|
|
274
|
-
class TestClass {
|
|
275
|
-
constructor() {}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
expect(() => {
|
|
279
|
-
new TestClass();
|
|
280
|
-
}).toThrow('Constructor error');
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it('should handle deeply nested method calls', () => {
|
|
284
|
-
(mockProvider.isEnabled as jest.Mock).mockReturnValue(true);
|
|
285
|
-
|
|
286
|
-
class TestClass {
|
|
287
|
-
@FeatureToggle('enabled')
|
|
288
|
-
level1() {
|
|
289
|
-
return this.level2();
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
level2() {
|
|
293
|
-
return this.level3();
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
level3() {
|
|
297
|
-
return 'deep result';
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const instance = new TestClass();
|
|
302
|
-
expect(instance.level1()).toBe('deep result');
|
|
303
|
-
});
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
describe('Memory and Performance Edge Cases', () => {
|
|
307
|
-
it('should handle large number of decorated methods', () => {
|
|
308
|
-
(mockProvider.isEnabled as jest.Mock).mockReturnValue(true);
|
|
309
|
-
|
|
310
|
-
class TestClass {
|
|
311
|
-
@FeatureToggle('feature1') method1() { return 1; }
|
|
312
|
-
@FeatureToggle('feature2') method2() { return 2; }
|
|
313
|
-
@FeatureToggle('feature3') method3() { return 3; }
|
|
314
|
-
@FeatureToggle('feature4') method4() { return 4; }
|
|
315
|
-
@FeatureToggle('feature5') method5() { return 5; }
|
|
316
|
-
// ... many more methods would be here in a real test
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const instance = new TestClass();
|
|
320
|
-
|
|
321
|
-
expect(instance.method1()).toBe(1);
|
|
322
|
-
expect(instance.method2()).toBe(2);
|
|
323
|
-
expect(instance.method3()).toBe(3);
|
|
324
|
-
expect(instance.method4()).toBe(4);
|
|
325
|
-
expect(instance.method5()).toBe(5);
|
|
326
|
-
expect(mockProvider.isEnabled).toHaveBeenCalledTimes(5);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it('should handle rapid instantiation of decorated classes', () => {
|
|
330
|
-
(mockProvider.isEnabled as jest.Mock).mockReturnValue(false);
|
|
331
|
-
|
|
332
|
-
@FeatureToggle('disabled')
|
|
333
|
-
class TestClass {
|
|
334
|
-
value = Math.random();
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const instances = Array.from({ length: 100 }, () => new TestClass());
|
|
338
|
-
|
|
339
|
-
expect(instances).toHaveLength(100);
|
|
340
|
-
expect(mockProvider.isEnabled).toHaveBeenCalledTimes(1); // Called once during decoration
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it('should handle very long feature keys', () => {
|
|
344
|
-
const longKey = 'a'.repeat(10000);
|
|
345
|
-
(mockProvider.isEnabled as jest.Mock).mockReturnValue(true);
|
|
346
|
-
|
|
347
|
-
class TestClass {
|
|
348
|
-
@FeatureToggle(longKey)
|
|
349
|
-
method() {
|
|
350
|
-
return 'result';
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const instance = new TestClass();
|
|
355
|
-
expect(instance.method()).toBe('result');
|
|
356
|
-
expect(mockProvider.isEnabled).toHaveBeenCalledWith(longKey);
|
|
357
|
-
});
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
describe('Type Safety Edge Cases', () => {
|
|
361
|
-
it('should handle mixed data types in providers', () => {
|
|
362
|
-
const mixedProvider = {
|
|
363
|
-
data: {
|
|
364
|
-
'stringFeature': 'true',
|
|
365
|
-
'booleanFeature': true,
|
|
366
|
-
'numberFeature': 1,
|
|
367
|
-
'objectFeature': { enabled: true },
|
|
368
|
-
'nullFeature': null,
|
|
369
|
-
'undefinedFeature': undefined
|
|
370
|
-
} as Record<string, unknown>,
|
|
371
|
-
getConfig: jest.fn(),
|
|
372
|
-
isEnabled: (key: string) => {
|
|
373
|
-
const value = mixedProvider.data[key];
|
|
374
|
-
if (value === null || value === undefined) return false;
|
|
375
|
-
return Boolean(value);
|
|
376
|
-
}
|
|
377
|
-
};
|
|
378
|
-
|
|
379
|
-
FeatureToggleBase.featureProvider = mixedProvider;
|
|
380
|
-
|
|
381
|
-
class TestClass {
|
|
382
|
-
@FeatureToggle('stringFeature') stringMethod() { return 'string'; }
|
|
383
|
-
@FeatureToggle('booleanFeature') booleanMethod() { return 'boolean'; }
|
|
384
|
-
@FeatureToggle('numberFeature') numberMethod() { return 'number'; }
|
|
385
|
-
@FeatureToggle('nullFeature') nullMethod() { return 'null'; }
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const instance = new TestClass();
|
|
389
|
-
|
|
390
|
-
expect(instance.stringMethod()).toBe('string');
|
|
391
|
-
expect(instance.booleanMethod()).toBe('boolean');
|
|
392
|
-
expect(instance.numberMethod()).toBe('number');
|
|
393
|
-
expect(instance.nullMethod()).toBeUndefined(); // null should be falsy
|
|
394
|
-
});
|
|
395
|
-
});
|
|
396
|
-
});
|
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
import { Feature } from '../FeatureToggle';
|
|
2
|
-
import { Clock, evaluate, parseTimestamp, systemClock } from '../evaluate';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Tests for the central evaluation rules.
|
|
6
|
-
*
|
|
7
|
-
* Every case carries its own `now`, so nothing here depends on when the suite
|
|
8
|
-
* runs. This mirrors how the conformance suite supplies a `now` per case, and
|
|
9
|
-
* is the reason the clock had to become injectable.
|
|
10
|
-
*/
|
|
11
|
-
describe('evaluate', () => {
|
|
12
|
-
const NOW = Date.parse('2026-09-18T12:00:00Z');
|
|
13
|
-
|
|
14
|
-
const feature = (overrides: Partial<Feature> = {}): Feature => ({
|
|
15
|
-
key: 'f',
|
|
16
|
-
value: 'true',
|
|
17
|
-
activeAt: '',
|
|
18
|
-
disabledAt: '',
|
|
19
|
-
...overrides,
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe('missing features', () => {
|
|
23
|
-
it('treats undefined as off', () => {
|
|
24
|
-
expect(evaluate(undefined, NOW)).toBe(false);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('treats null as off', () => {
|
|
28
|
-
expect(evaluate(null, NOW)).toBe(false);
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe('value', () => {
|
|
33
|
-
it('is on for exactly "true"', () => {
|
|
34
|
-
expect(evaluate(feature({ value: 'true' }), NOW)).toBe(true);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
// The backend stores the value as a string. Anything but "true" must read
|
|
38
|
-
// as off, or backend and client would silently disagree.
|
|
39
|
-
it.each(['false', 'TRUE', 'True', '1', '', 'yes'])(
|
|
40
|
-
'is off for %p',
|
|
41
|
-
(value) => {
|
|
42
|
-
expect(evaluate(feature({ value }), NOW)).toBe(false);
|
|
43
|
-
}
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
it('is off when the value is missing entirely', () => {
|
|
47
|
-
const withoutValue = { key: 'f', activeAt: '', disabledAt: '' } as Feature;
|
|
48
|
-
expect(evaluate(withoutValue, NOW)).toBe(false);
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe('activeAt', () => {
|
|
53
|
-
it('is off before activeAt', () => {
|
|
54
|
-
expect(
|
|
55
|
-
evaluate(feature({ activeAt: '2026-09-18T13:00:00Z' }), NOW)
|
|
56
|
-
).toBe(false);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('is on after activeAt', () => {
|
|
60
|
-
expect(
|
|
61
|
-
evaluate(feature({ activeAt: '2026-09-18T11:00:00Z' }), NOW)
|
|
62
|
-
).toBe(true);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
// The window is half-open: the comparison is `now < activeAt`.
|
|
66
|
-
it('is on at exactly activeAt', () => {
|
|
67
|
-
expect(
|
|
68
|
-
evaluate(feature({ activeAt: '2026-09-18T12:00:00Z' }), NOW)
|
|
69
|
-
).toBe(true);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe('disabledAt', () => {
|
|
74
|
-
it('is on before disabledAt', () => {
|
|
75
|
-
expect(
|
|
76
|
-
evaluate(feature({ disabledAt: '2026-09-18T13:00:00Z' }), NOW)
|
|
77
|
-
).toBe(true);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('is off after disabledAt', () => {
|
|
81
|
-
expect(
|
|
82
|
-
evaluate(feature({ disabledAt: '2026-09-18T11:00:00Z' }), NOW)
|
|
83
|
-
).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
// The comparison is `now >= disabledAt`, so the boundary is off -- the
|
|
87
|
-
// opposite of the activeAt boundary.
|
|
88
|
-
it('is off at exactly disabledAt', () => {
|
|
89
|
-
expect(
|
|
90
|
-
evaluate(feature({ disabledAt: '2026-09-18T12:00:00Z' }), NOW)
|
|
91
|
-
).toBe(false);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe('both bounds', () => {
|
|
96
|
-
const windowed = feature({
|
|
97
|
-
activeAt: '2026-09-18T10:00:00Z',
|
|
98
|
-
disabledAt: '2026-09-18T14:00:00Z',
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('is on inside the window', () => {
|
|
102
|
-
expect(evaluate(windowed, NOW)).toBe(true);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('is off before the window', () => {
|
|
106
|
-
expect(evaluate(windowed, Date.parse('2026-09-18T09:00:00Z'))).toBe(false);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('is off after the window', () => {
|
|
110
|
-
expect(evaluate(windowed, Date.parse('2026-09-18T15:00:00Z'))).toBe(false);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// Not a special case: the bounds simply never overlap.
|
|
114
|
-
it('is off everywhere when activeAt is after disabledAt', () => {
|
|
115
|
-
const inverted = feature({
|
|
116
|
-
activeAt: '2026-09-18T14:00:00Z',
|
|
117
|
-
disabledAt: '2026-09-18T10:00:00Z',
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
for (const at of [
|
|
121
|
-
'2026-09-18T09:00:00Z',
|
|
122
|
-
'2026-09-18T12:00:00Z',
|
|
123
|
-
'2026-09-18T16:00:00Z',
|
|
124
|
-
]) {
|
|
125
|
-
expect(evaluate(inverted, Date.parse(at))).toBe(false);
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
describe('timezone offsets', () => {
|
|
131
|
-
it('converts an offset to the same instant as UTC', () => {
|
|
132
|
-
// 14:00+02:00 is 12:00Z, which is exactly NOW, so the activeAt boundary
|
|
133
|
-
// is inclusive and the feature is on.
|
|
134
|
-
expect(
|
|
135
|
-
evaluate(feature({ activeAt: '2026-09-18T14:00:00+02:00' }), NOW)
|
|
136
|
-
).toBe(true);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('respects a negative offset', () => {
|
|
140
|
-
// 08:00-05:00 is 13:00Z, one hour after NOW, so it is not active yet.
|
|
141
|
-
expect(
|
|
142
|
-
evaluate(feature({ activeAt: '2026-09-18T08:00:00-05:00' }), NOW)
|
|
143
|
-
).toBe(false);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
describe('unset and invalid bounds are ignored', () => {
|
|
148
|
-
it.each([
|
|
149
|
-
['empty string', ''],
|
|
150
|
-
['null', null],
|
|
151
|
-
['undefined', undefined],
|
|
152
|
-
])('ignores %s', (_label, value) => {
|
|
153
|
-
const f = feature({
|
|
154
|
-
activeAt: value as string,
|
|
155
|
-
disabledAt: value as string,
|
|
156
|
-
});
|
|
157
|
-
expect(evaluate(f, NOW)).toBe(true);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
// Rejected on purpose: JavaScript reads a bare date as UTC midnight and an
|
|
161
|
-
// offset-less timestamp as local time, while most other languages read
|
|
162
|
-
// both as local. Accepting them would make a feature flip at a different
|
|
163
|
-
// instant depending on the port.
|
|
164
|
-
it.each([
|
|
165
|
-
['a bare date', '2026-09-18'],
|
|
166
|
-
['no offset', '2026-09-18T15:00:00'],
|
|
167
|
-
['garbage', 'not-a-date'],
|
|
168
|
-
['a unix timestamp', '1758196800'],
|
|
169
|
-
['a slash date', '2026/09/18'],
|
|
170
|
-
])('ignores %s in activeAt', (_label, value) => {
|
|
171
|
-
// Would be off if parsed as a future bound; ignoring it leaves the
|
|
172
|
-
// feature on.
|
|
173
|
-
expect(evaluate(feature({ activeAt: value }), NOW)).toBe(true);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
// Date.parse does not reject an impossible calendar date, it rolls it
|
|
177
|
-
// over: 2027-02-30 becomes 2027-03-02. The date must therefore be in the
|
|
178
|
-
// future, or the rolled-over value lands in the past and the activeAt
|
|
179
|
-
// check passes for the wrong reason.
|
|
180
|
-
it.each([
|
|
181
|
-
['a day past the end of February', '2027-02-30T00:00:00Z'],
|
|
182
|
-
['the 31st of a 30-day month', '2027-04-31T00:00:00Z'],
|
|
183
|
-
['month 13', '2027-13-01T00:00:00Z'],
|
|
184
|
-
['day zero', '2027-01-00T00:00:00Z'],
|
|
185
|
-
['February 29 in a non-leap year', '2027-02-29T00:00:00Z'],
|
|
186
|
-
['hour 24', '2027-01-01T24:00:00Z'],
|
|
187
|
-
['minute 60', '2027-01-01T00:60:00Z'],
|
|
188
|
-
])('ignores %s', (_label, value) => {
|
|
189
|
-
expect(evaluate(feature({ activeAt: value }), NOW)).toBe(true);
|
|
190
|
-
expect(parseTimestamp(value)).toBeUndefined();
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('accepts February 29 in a leap year', () => {
|
|
194
|
-
expect(parseTimestamp('2028-02-29T00:00:00Z')).toBe(
|
|
195
|
-
Date.parse('2028-02-29T00:00:00Z')
|
|
196
|
-
);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// RFC 3339 permits second 60 for a leap second, but Date.parse returns
|
|
200
|
-
// NaN for it, so the value is ignored like any other unusable bound. The
|
|
201
|
-
// range check lets it through and the NaN guard catches it; asserted here
|
|
202
|
-
// so the two stay consistent.
|
|
203
|
-
it('ignores a leap second, which Date.parse cannot represent', () => {
|
|
204
|
-
expect(parseTimestamp('2026-12-31T23:59:60Z')).toBeUndefined();
|
|
205
|
-
expect(
|
|
206
|
-
evaluate(feature({ activeAt: '2026-12-31T23:59:60Z' }), NOW)
|
|
207
|
-
).toBe(true);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('never throws on malformed input', () => {
|
|
211
|
-
expect(() =>
|
|
212
|
-
evaluate(feature({ activeAt: 'x', disabledAt: 'y' }), NOW)
|
|
213
|
-
).not.toThrow();
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
describe('fractional seconds', () => {
|
|
218
|
-
it('accepts them', () => {
|
|
219
|
-
expect(
|
|
220
|
-
evaluate(feature({ disabledAt: '2026-09-18T12:00:00.001Z' }), NOW)
|
|
221
|
-
).toBe(true);
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
describe('parseTimestamp', () => {
|
|
227
|
-
it('returns the epoch milliseconds for a valid timestamp', () => {
|
|
228
|
-
expect(parseTimestamp('2026-09-18T12:00:00Z')).toBe(
|
|
229
|
-
Date.parse('2026-09-18T12:00:00Z')
|
|
230
|
-
);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it.each([undefined, null, ''])('returns undefined for %p', (value) => {
|
|
234
|
-
expect(parseTimestamp(value)).toBeUndefined();
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('returns undefined for a format without an offset', () => {
|
|
238
|
-
expect(parseTimestamp('2026-09-18T12:00:00')).toBeUndefined();
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('accepts a lowercase t and z', () => {
|
|
242
|
-
expect(parseTimestamp('2026-09-18t12:00:00z')).toBe(
|
|
243
|
-
Date.parse('2026-09-18T12:00:00Z')
|
|
244
|
-
);
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
describe('systemClock', () => {
|
|
249
|
-
it('reports the current time', () => {
|
|
250
|
-
const before = Date.now();
|
|
251
|
-
const reading = systemClock();
|
|
252
|
-
expect(reading).toBeGreaterThanOrEqual(before);
|
|
253
|
-
expect(reading).toBeLessThanOrEqual(Date.now());
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('is the default, so a provider works without one', () => {
|
|
257
|
-
const clock: Clock = () => 0;
|
|
258
|
-
expect(typeof clock()).toBe('number');
|
|
259
|
-
});
|
|
260
|
-
});
|