@tehw0lf/yaft 0.0.15 → 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.
Files changed (50) hide show
  1. package/FeatureToggle.d.ts +20 -0
  2. package/FeatureToggle.js +72 -0
  3. package/README.md +10 -1
  4. package/evaluate.d.ts +42 -0
  5. package/evaluate.js +102 -0
  6. package/examples/ApiServiceBooleanProvider.d.ts +11 -0
  7. package/examples/ApiServiceBooleanProvider.js +82 -0
  8. package/examples/ApiServiceFeatureProvider.d.ts +17 -0
  9. package/examples/ApiServiceFeatureProvider.js +54 -0
  10. package/examples/LocalStorageBooleanProvider.d.ts +7 -0
  11. package/examples/LocalStorageBooleanProvider.js +26 -0
  12. package/examples/LocalStorageFeatureProvider.d.ts +14 -0
  13. package/examples/LocalStorageFeatureProvider.js +31 -0
  14. package/{src/index.ts → index.d.ts} +1 -6
  15. package/index.js +13 -0
  16. package/mapping.d.ts +27 -0
  17. package/mapping.js +79 -0
  18. package/package.json +21 -9
  19. package/.github/workflows/build.yml +0 -28
  20. package/.github/workflows/security-scan.yml +0 -79
  21. package/CLAUDE.md +0 -151
  22. package/LICENSE +0 -21
  23. package/conformance.lock +0 -2
  24. package/jest.config.js +0 -16
  25. package/scripts/fetch-conformance.sh +0 -67
  26. package/src/FeatureToggle.ts +0 -95
  27. package/src/evaluate.ts +0 -122
  28. package/src/examples/ApiServiceBooleanProvider.ts +0 -82
  29. package/src/examples/ApiServiceFeatureProvider.ts +0 -52
  30. package/src/examples/LocalStorageBooleanProvider.ts +0 -25
  31. package/src/examples/LocalStorageFeatureProvider.ts +0 -31
  32. package/src/mapping.ts +0 -92
  33. package/src/test/conformance-adapter/cases.ts +0 -104
  34. package/src/test/conformance-adapter/decorator.spec.ts +0 -376
  35. package/src/test/conformance-adapter/evaluation.spec.ts +0 -31
  36. package/src/test/conformance-adapter/mapping.spec.ts +0 -79
  37. package/src/test/decorator-behavior.spec.ts +0 -359
  38. package/src/test/error-handling.spec.ts +0 -396
  39. package/src/test/evaluate.spec.ts +0 -260
  40. package/src/test/feature-data.spec.ts +0 -102
  41. package/src/test/injectable-clock.spec.ts +0 -112
  42. package/src/test/integration.spec.ts +0 -472
  43. package/src/test/providers.spec.ts +0 -218
  44. package/src/test/test-boolean.json +0 -1
  45. package/src/test/test-feature.json +0 -20
  46. package/src/test/test-setup.ts +0 -2
  47. package/src/test/time-based-logic.spec.ts +0 -169
  48. package/src/test/yaft.demo.spec.ts +0 -53
  49. package/tsconfig.json +0 -14
  50. 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
- });