@tehw0lf/yaft 0.0.15 → 0.0.17

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 (51) hide show
  1. package/FeatureToggle.d.ts +20 -0
  2. package/FeatureToggle.js +72 -0
  3. package/README.md +25 -3
  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 +85 -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 +23 -0
  12. package/examples/LocalStorageFeatureProvider.d.ts +14 -0
  13. package/examples/LocalStorageFeatureProvider.js +31 -0
  14. package/index.d.ts +3 -0
  15. package/index.js +14 -0
  16. package/mapping.d.ts +35 -0
  17. package/mapping.js +98 -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/index.ts +0 -8
  33. package/src/mapping.ts +0 -92
  34. package/src/test/conformance-adapter/cases.ts +0 -104
  35. package/src/test/conformance-adapter/decorator.spec.ts +0 -376
  36. package/src/test/conformance-adapter/evaluation.spec.ts +0 -31
  37. package/src/test/conformance-adapter/mapping.spec.ts +0 -79
  38. package/src/test/decorator-behavior.spec.ts +0 -359
  39. package/src/test/error-handling.spec.ts +0 -396
  40. package/src/test/evaluate.spec.ts +0 -260
  41. package/src/test/feature-data.spec.ts +0 -102
  42. package/src/test/injectable-clock.spec.ts +0 -112
  43. package/src/test/integration.spec.ts +0 -472
  44. package/src/test/providers.spec.ts +0 -218
  45. package/src/test/test-boolean.json +0 -1
  46. package/src/test/test-feature.json +0 -20
  47. package/src/test/test-setup.ts +0 -2
  48. package/src/test/time-based-logic.spec.ts +0 -169
  49. package/src/test/yaft.demo.spec.ts +0 -53
  50. package/tsconfig.json +0 -14
  51. package/tsconfig.spec.json +0 -10
@@ -1,376 +0,0 @@
1
- import {
2
- FeatureProvider,
3
- FeatureToggle,
4
- FeatureToggleBase,
5
- } from '../../FeatureToggle';
6
- import { DecoratorCase, decoratorCases, title, unsupported } from './cases';
7
-
8
- /**
9
- * Runs the shared decorator cases against this port.
10
- *
11
- * Unlike the evaluation cases these are not input/output pairs -- "the
12
- * fallback receives the same receiver" cannot be expressed as JSON. Each case
13
- * names a scenario and an outcome in port-neutral terms, and this file maps
14
- * those onto TypeScript decorators.
15
- *
16
- * The mapping is deliberately explicit: an unknown `target`, `toggle` or
17
- * `expected` throws rather than being skipped, because a silently skipped case
18
- * is a rule nothing enforces.
19
- */
20
-
21
- const KEY = 'conformanceToggle';
22
-
23
- /** A provider whose answer can be changed between decoration and call. */
24
- class SwitchableProvider implements FeatureProvider<boolean> {
25
- data: Record<string, boolean> = {};
26
- constructor(private enabled: boolean) {}
27
- getConfig(): void {
28
- /* nothing to load */
29
- }
30
- isEnabled(): boolean {
31
- return this.enabled;
32
- }
33
- set(enabled: boolean): void {
34
- this.enabled = enabled;
35
- }
36
- }
37
-
38
- /**
39
- * Installs a provider and returns it, or clears the provider entirely for the
40
- * `no-provider` case.
41
- *
42
- * `toggle` also encodes when the value changes: `on-then-off` is on while the
43
- * class or method is decorated and off by the time it is used, which is what
44
- * separates "evaluated once" from "evaluated per call".
45
- */
46
- function setUp(toggle: DecoratorCase['toggle'], caseName: string) {
47
- switch (toggle) {
48
- case 'on':
49
- case 'on-then-off':
50
- return install(true);
51
- case 'off':
52
- case 'off-then-on':
53
- return install(false);
54
- case 'no-provider':
55
- // The abstract base holds the provider statically; clearing it is how a
56
- // misconfigured application looks.
57
- (FeatureToggleBase as { featureProvider?: unknown }).featureProvider =
58
- undefined;
59
- return undefined;
60
- default:
61
- return unsupported('toggle', toggle, caseName);
62
- }
63
- }
64
-
65
- function install(enabled: boolean): SwitchableProvider {
66
- const provider = new SwitchableProvider(enabled);
67
- FeatureToggleBase.featureProvider = provider;
68
- return provider;
69
- }
70
-
71
- /** Applies the flip that `on-then-off` / `off-then-on` describe. */
72
- function flipIfTwoPhase(
73
- toggle: DecoratorCase['toggle'],
74
- provider: SwitchableProvider | undefined
75
- ): void {
76
- if (toggle === 'on-then-off') provider?.set(false);
77
- if (toggle === 'off-then-on') provider?.set(true);
78
- }
79
-
80
- const ORIGINAL = 'original';
81
- const FALLBACK = 'fallback';
82
-
83
- describe('conformance: decorator', () => {
84
- const cases = decoratorCases();
85
- let savedProvider: FeatureProvider<unknown>;
86
-
87
- beforeAll(() => {
88
- savedProvider = FeatureToggleBase.featureProvider;
89
- });
90
-
91
- afterEach(() => {
92
- FeatureToggleBase.featureProvider = savedProvider;
93
- });
94
-
95
- it('loads the suite', () => {
96
- expect(cases.length).toBeGreaterThan(0);
97
- });
98
-
99
- for (const c of cases) {
100
- it(title(c), async () => {
101
- switch (c.target) {
102
- case 'method':
103
- await runMethodCase(c);
104
- break;
105
- case 'async-method':
106
- await runAsyncMethodCase(c);
107
- break;
108
- case 'class':
109
- runClassCase(c);
110
- break;
111
- default:
112
- unsupported('target', c.target, c.name);
113
- }
114
- });
115
- }
116
- });
117
-
118
- async function runMethodCase(c: DecoratorCase): Promise<void> {
119
- if (c.toggle === 'no-provider') {
120
- expectDecorationError(c);
121
- return;
122
- }
123
-
124
- const provider = setUp(c.toggle, c.name) as SwitchableProvider;
125
-
126
- // Recorded by whichever implementation runs, so the assertions can tell them
127
- // apart and check what the fallback was handed.
128
- let ran = '';
129
- let sawArgs: unknown[] = [];
130
- let sawThis: unknown;
131
-
132
- function fallbackMethod(this: unknown, ...args: unknown[]) {
133
- ran = FALLBACK;
134
- sawArgs = args;
135
- sawThis = this;
136
- return FALLBACK;
137
- }
138
-
139
- const fallback = c.fallback === 'method' ? fallbackMethod : undefined;
140
- if (c.fallback === 'class') {
141
- unsupported('fallback', 'class on a method target', c.name);
142
- }
143
-
144
- class Subject {
145
- marker = 'subject';
146
-
147
- @FeatureToggle(KEY, fallback)
148
- run(..._args: unknown[]) {
149
- ran = ORIGINAL;
150
- return ORIGINAL;
151
- }
152
- }
153
-
154
- flipIfTwoPhase(c.toggle, provider);
155
-
156
- const instance = new Subject();
157
- const result = instance.run('a', 1);
158
-
159
- switch (c.expected) {
160
- case 'original':
161
- expect(ran).toBe(ORIGINAL);
162
- expect(result).toBe(ORIGINAL);
163
- break;
164
-
165
- case 'fallback':
166
- expect(ran).toBe(FALLBACK);
167
- expect(result).toBe(FALLBACK);
168
- break;
169
-
170
- case 'nothing':
171
- // The language's empty result; in TypeScript that is undefined.
172
- expect(ran).toBe('');
173
- expect(result).toBeUndefined();
174
- break;
175
-
176
- default:
177
- unsupported('expected', c.expected, c.name);
178
- }
179
-
180
- for (const assertion of c.assertions ?? []) {
181
- switch (assertion) {
182
- case 'same-arguments':
183
- expect(sawArgs).toEqual(['a', 1]);
184
- break;
185
- case 'same-receiver':
186
- expect(sawThis).toBe(instance);
187
- break;
188
- default:
189
- unsupported('assertion', assertion, c.name);
190
- }
191
- }
192
- }
193
-
194
- async function runAsyncMethodCase(c: DecoratorCase): Promise<void> {
195
- if (c.toggle === 'no-provider') {
196
- expectDecorationError(c);
197
- return;
198
- }
199
-
200
- const provider = setUp(c.toggle, c.name) as SwitchableProvider;
201
- let ran = '';
202
-
203
- class Subject {
204
- @FeatureToggle(KEY)
205
- async run() {
206
- ran = ORIGINAL;
207
- return ORIGINAL;
208
- }
209
- }
210
-
211
- flipIfTwoPhase(c.toggle, provider);
212
-
213
- const result = new Subject().run();
214
-
215
- switch (c.expected) {
216
- case 'original':
217
- await expect(result).resolves.toBe(ORIGINAL);
218
- expect(ran).toBe(ORIGINAL);
219
- break;
220
-
221
- case 'resolved-nothing':
222
- // An await at the call site must not break, so the empty result has to
223
- // be an already-resolved promise rather than a null value.
224
- expect(result).toBeInstanceOf(Promise);
225
- await expect(result).resolves.toBeUndefined();
226
- expect(ran).toBe('');
227
- break;
228
-
229
- default:
230
- unsupported('expected', c.expected, c.name);
231
- }
232
- }
233
-
234
- function runClassCase(c: DecoratorCase): void {
235
- if (c.toggle === 'no-provider') {
236
- expectDecorationError(c);
237
- return;
238
- }
239
-
240
- const provider = setUp(c.toggle, c.name) as SwitchableProvider;
241
-
242
- class FallbackClass {
243
- which() {
244
- return FALLBACK;
245
- }
246
- }
247
-
248
- const fallback = c.fallback === 'class' ? FallbackClass : undefined;
249
- if (c.fallback === 'method') {
250
- unsupported('fallback', 'method on a class target', c.name);
251
- }
252
-
253
- @FeatureToggle(KEY, fallback)
254
- class Subject {
255
- which() {
256
- return ORIGINAL;
257
- }
258
- }
259
-
260
- // A class is decided at decoration time, so this flip must have no effect --
261
- // that is exactly what the two-phase cases check.
262
- flipIfTwoPhase(c.toggle, provider);
263
-
264
- const instance = new (Subject as unknown as new () => { which(): unknown })();
265
-
266
- switch (c.expected) {
267
- case 'original':
268
- expect(instance.which()).toBe(ORIGINAL);
269
- break;
270
-
271
- case 'fallback':
272
- expect(instance.which()).toBe(FALLBACK);
273
- break;
274
-
275
- case 'empty-shell':
276
- // The shell still answers every method of the original, each returning
277
- // nothing, so calling into a disabled class does not throw.
278
- expect(instance.which()).toBeUndefined();
279
- break;
280
-
281
- default:
282
- unsupported('expected', c.expected, c.name);
283
- }
284
- }
285
-
286
- /**
287
- * Cases the shared suite does not cover yet.
288
- *
289
- * R18 says an async method switched off must still return a promise, but the
290
- * suite only states that for the no-fallback path. A synchronous fallback on
291
- * an async method has the same problem -- the signature promises a promise and
292
- * the caller gets a plain value -- so it is pinned down here until the suite
293
- * grows a case for it.
294
- */
295
- describe('async fallback keeps the promise contract', () => {
296
- let saved: FeatureProvider<unknown>;
297
-
298
- beforeAll(() => {
299
- saved = FeatureToggleBase.featureProvider;
300
- });
301
- afterAll(() => {
302
- FeatureToggleBase.featureProvider = saved;
303
- });
304
-
305
- it('wraps a synchronous fallback for an async method', async () => {
306
- install(false);
307
-
308
- function syncFallback() {
309
- return FALLBACK;
310
- }
311
-
312
- class Subject {
313
- @FeatureToggle(KEY, syncFallback)
314
- async run() {
315
- return ORIGINAL;
316
- }
317
- }
318
-
319
- const result = new Subject().run();
320
-
321
- expect(result).toBeInstanceOf(Promise);
322
- await expect(result).resolves.toBe(FALLBACK);
323
- });
324
-
325
- it('passes an async fallback through without double-wrapping', async () => {
326
- install(false);
327
-
328
- async function asyncFallback() {
329
- return FALLBACK;
330
- }
331
-
332
- class Subject {
333
- @FeatureToggle(KEY, asyncFallback)
334
- async run() {
335
- return ORIGINAL;
336
- }
337
- }
338
-
339
- await expect(new Subject().run()).resolves.toBe(FALLBACK);
340
- });
341
-
342
- it('leaves the fallback of a synchronous method untouched', () => {
343
- install(false);
344
-
345
- function syncFallback() {
346
- return FALLBACK;
347
- }
348
-
349
- class Subject {
350
- @FeatureToggle(KEY, syncFallback)
351
- run() {
352
- return ORIGINAL;
353
- }
354
- }
355
-
356
- // Not a promise: wrapping here would change the contract of every
357
- // synchronous fallback.
358
- expect(new Subject().run()).toBe(FALLBACK);
359
- });
360
- });
361
-
362
- /**
363
- * The provider is missing, so decorating itself must fail.
364
- *
365
- * Deferring the error to the first call would turn a startup misconfiguration
366
- * into a surprise in whichever code path happens to run first.
367
- */
368
- function expectDecorationError(c: DecoratorCase): void {
369
- setUp('no-provider', c.name);
370
-
371
- if (c.expected !== 'decoration-error') {
372
- unsupported('expected', c.expected, c.name);
373
- }
374
-
375
- expect(() => FeatureToggle(KEY)).toThrow('FeatureToggleProvider not set');
376
- }
@@ -1,31 +0,0 @@
1
- import { Feature } from '../../FeatureToggle';
2
- import { evaluate } from '../../evaluate';
3
- import { evaluationCases, title } from './cases';
4
-
5
- /**
6
- * Runs the shared evaluation cases against this port.
7
- *
8
- * These are the same cases every YaFT implementation has to pass, so a
9
- * disagreement here is a disagreement with the spec, not a local test
10
- * preference. Each case brings its own `now`, which is why the clock is
11
- * injectable in the first place.
12
- */
13
- describe('conformance: evaluation', () => {
14
- const cases = evaluationCases();
15
-
16
- it('loads the suite', () => {
17
- expect(cases.length).toBeGreaterThan(0);
18
- });
19
-
20
- for (const c of cases) {
21
- it(title(c), () => {
22
- const now = Date.parse(c.now);
23
- // A case whose own `now` does not parse would silently evaluate against
24
- // NaN and pass or fail for the wrong reason.
25
- expect(Number.isNaN(now)).toBe(false);
26
-
27
- const feature = c.features[c.key] as Feature | null | undefined;
28
- expect(evaluate(feature, now)).toBe(c.expected);
29
- });
30
- }
31
- });
@@ -1,79 +0,0 @@
1
- import { normaliseCollection, normaliseFeature } from '../../mapping';
2
- import { mappingCases, title, unsupported } from './cases';
3
-
4
- /**
5
- * Runs the shared mapping cases against this port.
6
- *
7
- * These pin down how a backend response becomes provider data: which envelopes
8
- * exist, how the two field spellings are reconciled, and that a present but
9
- * empty value is kept rather than replaced.
10
- */
11
- describe('conformance: mapping', () => {
12
- const cases = mappingCases();
13
-
14
- it('loads the suite', () => {
15
- expect(cases.length).toBeGreaterThan(0);
16
- });
17
-
18
- for (const c of cases) {
19
- it(title(c), () => {
20
- switch (c.shape) {
21
- case 'feature':
22
- expect(normaliseCollection(c.response)).toEqual(c.expected);
23
- break;
24
-
25
- case 'boolean':
26
- // The boolean shape is already keyed booleans; a provider stores it
27
- // as-is and maps it straight onto isEnabled.
28
- expect(c.response).toEqual(c.expected);
29
- break;
30
-
31
- default:
32
- unsupported('shape', c.shape, c.name);
33
- }
34
- });
35
- }
36
- });
37
-
38
- describe('normaliseFeature', () => {
39
- it('keeps a present but empty value instead of falling through', () => {
40
- // The reason this function exists: `f.value || f.Value` turns an off
41
- // feature into an on one.
42
- expect(
43
- normaliseFeature({ key: 'f', value: '', Value: 'true' })
44
- ).toEqual({
45
- key: 'f',
46
- value: '',
47
- activeAt: '',
48
- disabledAt: '',
49
- tags: [],
50
- });
51
- });
52
-
53
- it('drops a tag that is not a string', () => {
54
- // `Feature.tags` is typed string[]; asserting rather than filtering would
55
- // hand a caller a number through a field that promises a string.
56
- expect(
57
- normaliseFeature({ key: 'f', value: 'true', tags: ['ok', 42, null, 'fine'] })
58
- .tags
59
- ).toEqual(['ok', 'fine']);
60
- });
61
-
62
- it('reads the capitalised spelling older backends send', () => {
63
- expect(
64
- normaliseFeature({
65
- Key: 'f',
66
- Value: 'true',
67
- ActiveAt: null,
68
- DisabledAt: null,
69
- Tags: ['beta'],
70
- })
71
- ).toEqual({
72
- key: 'f',
73
- value: 'true',
74
- activeAt: '',
75
- disabledAt: '',
76
- tags: ['beta'],
77
- });
78
- });
79
- });