@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/src/mapping.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { Feature } from './FeatureToggle';
2
+
3
+ /**
4
+ * Turns a backend response into provider data.
5
+ *
6
+ * This is the single definition of YaFT's mapping rules, the way `evaluate` is
7
+ * the single definition of the evaluation rules. Providers call it instead of
8
+ * unpacking responses themselves, so every provider -- and every port that
9
+ * mirrors it -- agrees on what a response means.
10
+ */
11
+
12
+ /** A raw entry as it arrives over the wire, in either field spelling. */
13
+ type RawFeature = Record<string, unknown>;
14
+
15
+ /**
16
+ * Reads a field by presence, not by truthiness.
17
+ *
18
+ * The obvious `raw.value || raw.Value` is wrong: a present but empty value
19
+ * falls through to the other spelling, so a feature stored as `""` reads as
20
+ * whatever the capitalised field holds. An off feature then reports on. The
21
+ * same trap applies to `tags: []`.
22
+ *
23
+ * Backends from 0.2.0 on send only the lowercase spelling; the capitalised one
24
+ * is read because instances before that are still around.
25
+ */
26
+ function field(raw: RawFeature, lower: string, upper: string): unknown {
27
+ if (lower in raw) return raw[lower];
28
+ if (upper in raw) return raw[upper];
29
+ return undefined;
30
+ }
31
+
32
+ /**
33
+ * Normalises a date field. The backend sends `null` for an unset bound and
34
+ * local fixtures use `""`; both mean "no bound", and `evaluate` ignores either.
35
+ */
36
+ function date(value: unknown): string {
37
+ return typeof value === 'string' ? value : '';
38
+ }
39
+
40
+ /** Normalises one entry into a `Feature`, whichever spelling it arrived in. */
41
+ export function normaliseFeature(raw: RawFeature): Feature {
42
+ const tags = field(raw, 'tags', 'Tags');
43
+
44
+ return {
45
+ key: String(field(raw, 'key', 'Key') ?? ''),
46
+ value: String(field(raw, 'value', 'Value') ?? ''),
47
+ activeAt: date(field(raw, 'activeAt', 'ActiveAt')),
48
+ disabledAt: date(field(raw, 'disabledAt', 'DisabledAt')),
49
+ // Filtered rather than asserted: `as string[]` is a compile-time claim
50
+ // that a backend sending a mixed array would quietly break, handing
51
+ // callers a non-string through a field typed as string.
52
+ tags: Array.isArray(tags)
53
+ ? tags.filter((tag): tag is string => typeof tag === 'string')
54
+ : [],
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Normalises a whole response into features keyed by their key.
60
+ *
61
+ * Three envelopes are accepted, because the backend uses all three:
62
+ *
63
+ * - `{ "toggles": [...] }` for a UUID group;
64
+ * - `{ "value": [...] }`, the same thing under a different name;
65
+ * - a flat object for a single toggle.
66
+ *
67
+ * An entry without a usable key is skipped rather than stored under `""`,
68
+ * where `isEnabled("")` could reach it.
69
+ */
70
+ export function normaliseCollection(response: unknown): Record<string, Feature> {
71
+ if (response === null || typeof response !== 'object') return {};
72
+
73
+ const body = response as Record<string, unknown>;
74
+ const collection = Array.isArray(body['toggles'])
75
+ ? body['toggles']
76
+ : Array.isArray(body['value'])
77
+ ? body['value']
78
+ : undefined;
79
+
80
+ // A single toggle comes back flat, not wrapped. Older code only handled the
81
+ // collections and dropped this shape entirely.
82
+ const entries = collection ?? [body];
83
+
84
+ const data: Record<string, Feature> = {};
85
+ for (const entry of entries) {
86
+ if (entry === null || typeof entry !== 'object') continue;
87
+
88
+ const feature = normaliseFeature(entry as RawFeature);
89
+ if (feature.key !== '') data[feature.key] = feature;
90
+ }
91
+ return data;
92
+ }
@@ -0,0 +1,104 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Loads the conformance case files.
6
+ *
7
+ * The cases live in `cases/`, fetched by `scripts/fetch-conformance.sh` from
8
+ * the version pinned in `conformance.lock`. They are not checked in: the lock
9
+ * file plus its checksum is what makes a suite bump a reviewable one-line
10
+ * diff, and a committed copy would drift from the tag it claims to be.
11
+ */
12
+
13
+ const CASES_DIR = join(__dirname, '..', 'conformance', 'cases');
14
+
15
+ /** A rule id from SPEC.md, such as `R5` or `R22a`. */
16
+ export type Rule = string;
17
+
18
+ interface CaseFile<T> {
19
+ suite: string;
20
+ version: number;
21
+ cases: T[];
22
+ }
23
+
24
+ export interface EvaluationCase {
25
+ name: string;
26
+ rules: Rule[];
27
+ why?: string;
28
+ now: string;
29
+ features: Record<string, unknown>;
30
+ key: string;
31
+ expected: boolean;
32
+ }
33
+
34
+ export interface DecoratorCase {
35
+ name: string;
36
+ rules: Rule[];
37
+ why?: string;
38
+ target: 'class' | 'method' | 'async-method';
39
+ toggle: 'on' | 'off' | 'on-then-off' | 'off-then-on' | 'no-provider';
40
+ fallback: 'none' | 'class' | 'method';
41
+ expected:
42
+ | 'original'
43
+ | 'fallback'
44
+ | 'nothing'
45
+ | 'resolved-nothing'
46
+ | 'empty-shell'
47
+ | 'decoration-error';
48
+ assertions?: ('same-arguments' | 'same-receiver')[];
49
+ }
50
+
51
+ export interface MappingCase {
52
+ name: string;
53
+ rules: Rule[];
54
+ why?: string;
55
+ shape: 'feature' | 'boolean';
56
+ response: unknown;
57
+ expected: Record<string, unknown>;
58
+ }
59
+
60
+ function load<T>(suite: string): CaseFile<T> {
61
+ const path = join(CASES_DIR, `${suite}.json`);
62
+
63
+ if (!existsSync(path)) {
64
+ throw new Error(
65
+ `Conformance cases missing at ${path}.\n` +
66
+ 'Run scripts/fetch-conformance.sh before the tests; CI does this in a ' +
67
+ 'dedicated step.'
68
+ );
69
+ }
70
+
71
+ const file = JSON.parse(readFileSync(path, 'utf8')) as CaseFile<T>;
72
+ if (file.suite !== suite) {
73
+ throw new Error(`${path} declares suite "${file.suite}", expected "${suite}"`);
74
+ }
75
+ return file;
76
+ }
77
+
78
+ export const evaluationCases = () => load<EvaluationCase>('evaluation').cases;
79
+ export const decoratorCases = () => load<DecoratorCase>('decorator').cases;
80
+ export const mappingCases = () => load<MappingCase>('mapping').cases;
81
+
82
+ /**
83
+ * Names a case for the test output, so a failure points at a rule without
84
+ * anyone having to open the case file.
85
+ */
86
+ export function title(c: { name: string; rules: Rule[] }): string {
87
+ return `${c.name} [${c.rules.join(', ')}]`;
88
+ }
89
+
90
+ /**
91
+ * Rejects a value the adapter does not know how to handle.
92
+ *
93
+ * A case whose `target`, `toggle` or `expected` this adapter has never seen is
94
+ * a rule that nothing enforces here. Skipping it would leave the suite looking
95
+ * green while a requirement goes unchecked, so an unknown value is a failure
96
+ * instead -- it means the suite grew and the adapter has to catch up.
97
+ */
98
+ export function unsupported(kind: string, value: string, caseName: string): never {
99
+ throw new Error(
100
+ `Case "${caseName}" uses ${kind} "${value}", which this adapter does not ` +
101
+ 'implement. The conformance suite has gained a case this port does not ' +
102
+ 'cover yet -- extend the adapter rather than skipping the case.'
103
+ );
104
+ }
@@ -0,0 +1,376 @@
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
+ }
@@ -0,0 +1,31 @@
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
+ });
@@ -0,0 +1,79 @@
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
+ });