@tehw0lf/yaft 0.0.10 → 0.0.15
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/.github/workflows/security-scan.yml +2 -2
- package/CLAUDE.md +17 -4
- package/README.md +76 -0
- package/conformance.lock +2 -0
- package/logo.svg +3 -3
- package/package.json +4 -2
- package/scripts/fetch-conformance.sh +67 -0
- package/src/FeatureToggle.ts +16 -3
- package/src/evaluate.ts +122 -0
- package/src/examples/ApiServiceBooleanProvider.ts +37 -15
- package/src/examples/ApiServiceFeatureProvider.ts +14 -49
- package/src/examples/LocalStorageFeatureProvider.ts +11 -27
- package/src/index.ts +2 -0
- package/src/mapping.ts +92 -0
- package/src/test/conformance-adapter/cases.ts +104 -0
- package/src/test/conformance-adapter/decorator.spec.ts +376 -0
- package/src/test/conformance-adapter/evaluation.spec.ts +31 -0
- package/src/test/conformance-adapter/mapping.spec.ts +79 -0
- package/src/test/evaluate.spec.ts +260 -0
- package/src/test/injectable-clock.spec.ts +112 -0
|
@@ -0,0 +1,260 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Clock } from '../evaluate';
|
|
2
|
+
import { Feature } from '../FeatureToggle';
|
|
3
|
+
import { ApiServiceFeatureProvider } from '../examples/ApiServiceFeatureProvider';
|
|
4
|
+
import { LocalStorageFeatureProvider } from '../examples/LocalStorageFeatureProvider';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The clock has to be injectable for the conformance suite: every case there
|
|
8
|
+
* carries its own `now`, and an adapter cannot set it if the providers read
|
|
9
|
+
* `Date.now()` directly.
|
|
10
|
+
*
|
|
11
|
+
* These tests pin that down, and pin down that both feature-shaped providers
|
|
12
|
+
* answer identically -- the point of moving the logic into evaluate().
|
|
13
|
+
*/
|
|
14
|
+
describe('injectable clock', () => {
|
|
15
|
+
const windowed: Feature = {
|
|
16
|
+
key: 'windowed',
|
|
17
|
+
value: 'true',
|
|
18
|
+
activeAt: '2026-09-18T10:00:00Z',
|
|
19
|
+
disabledAt: '2026-09-18T14:00:00Z',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const at = (iso: string): Clock => () => Date.parse(iso);
|
|
23
|
+
|
|
24
|
+
/** A provider holding `windowed`, without touching the filesystem. */
|
|
25
|
+
const localAt = (iso: string) => {
|
|
26
|
+
const provider = new LocalStorageFeatureProvider('', at(iso));
|
|
27
|
+
provider.data = { windowed };
|
|
28
|
+
return provider;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The same, for the API provider, without touching the network. */
|
|
32
|
+
const apiAt = (iso: string) => {
|
|
33
|
+
const provider = new ApiServiceFeatureProvider('', '', at(iso));
|
|
34
|
+
provider.data = { windowed };
|
|
35
|
+
return provider;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('LocalStorageFeatureProvider', () => {
|
|
39
|
+
it('is off before the window', () => {
|
|
40
|
+
expect(localAt('2026-09-18T09:59:59Z').isEnabled('windowed')).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('is on inside the window', () => {
|
|
44
|
+
expect(localAt('2026-09-18T12:00:00Z').isEnabled('windowed')).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is off after the window', () => {
|
|
48
|
+
expect(localAt('2026-09-18T14:00:00Z').isEnabled('windowed')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('ApiServiceFeatureProvider', () => {
|
|
53
|
+
it('is off before the window', () => {
|
|
54
|
+
expect(apiAt('2026-09-18T09:59:59Z').isEnabled('windowed')).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('is on inside the window', () => {
|
|
58
|
+
expect(apiAt('2026-09-18T12:00:00Z').isEnabled('windowed')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('is off after the window', () => {
|
|
62
|
+
expect(apiAt('2026-09-18T14:00:00Z').isEnabled('windowed')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Before this change each provider carried its own copy of the logic, so
|
|
67
|
+
// they could drift apart unnoticed. Now they must agree everywhere.
|
|
68
|
+
it('both feature providers agree at every instant', () => {
|
|
69
|
+
for (const iso of [
|
|
70
|
+
'2026-09-18T09:00:00Z',
|
|
71
|
+
'2026-09-18T10:00:00Z',
|
|
72
|
+
'2026-09-18T12:00:00Z',
|
|
73
|
+
'2026-09-18T13:59:59Z',
|
|
74
|
+
'2026-09-18T14:00:00Z',
|
|
75
|
+
'2026-09-18T20:00:00Z',
|
|
76
|
+
]) {
|
|
77
|
+
expect(localAt(iso).isEnabled('windowed')).toBe(
|
|
78
|
+
apiAt(iso).isEnabled('windowed')
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('the same provider answers differently as its clock advances', () => {
|
|
84
|
+
let now = Date.parse('2026-09-18T09:00:00Z');
|
|
85
|
+
const provider = new LocalStorageFeatureProvider('', () => now);
|
|
86
|
+
provider.data = { windowed };
|
|
87
|
+
|
|
88
|
+
expect(provider.isEnabled('windowed')).toBe(false);
|
|
89
|
+
|
|
90
|
+
now = Date.parse('2026-09-18T12:00:00Z');
|
|
91
|
+
expect(provider.isEnabled('windowed')).toBe(true);
|
|
92
|
+
|
|
93
|
+
now = Date.parse('2026-09-18T15:00:00Z');
|
|
94
|
+
expect(provider.isEnabled('windowed')).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('defaults to the system clock when none is given', () => {
|
|
98
|
+
const provider = new LocalStorageFeatureProvider('');
|
|
99
|
+
provider.data = {
|
|
100
|
+
open: { key: 'open', value: 'true', activeAt: '', disabledAt: '' },
|
|
101
|
+
expired: {
|
|
102
|
+
key: 'expired',
|
|
103
|
+
value: 'true',
|
|
104
|
+
activeAt: '',
|
|
105
|
+
disabledAt: '2000-01-01T00:00:00Z',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
expect(provider.isEnabled('open')).toBe(true);
|
|
110
|
+
expect(provider.isEnabled('expired')).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|