@tehw0lf/yaft 0.0.12 → 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/README.md CHANGED
@@ -100,6 +100,12 @@ export type Feature = {
100
100
 
101
101
  ## Evaluation rules
102
102
 
103
+ These rules are not this library's own: they are
104
+ [yaft-conformance](https://github.com/tehw0lf/yaft-conformance), the shared
105
+ specification every YaFT implementation is checked against. This port passes
106
+ the whole suite (see [Conformance](#conformance) below), so a feature evaluates
107
+ identically here and in any other port.
108
+
103
109
  A feature is on when all of the following hold. `evaluate` is exported, so the
104
110
  rules can be applied directly to a feature without going through a provider.
105
111
 
@@ -142,6 +148,31 @@ const provider = new LocalStorageFeatureProvider(
142
148
 
143
149
  The clock defaults to the system time, so existing code needs no change.
144
150
 
151
+ ## Conformance
152
+
153
+ The rules above are specified once, language-neutrally, in
154
+ [yaft-conformance](https://github.com/tehw0lf/yaft-conformance), and this port
155
+ is tested against that suite rather than only against its own expectations.
156
+
157
+ The version is pinned in `conformance.lock`:
158
+
159
+ ```
160
+ version=v1.1.0
161
+ sha256=d83ff1c960ad29830c00b57727591da628f4323b777faea245f603565d1c9ae9
162
+ ```
163
+
164
+ `npm test` fetches that release, verifies the checksum and unpacks it before
165
+ Jest runs, so there is no separate step to forget and no way to get a green run
166
+ against stale cases. Upgrading the suite is a one-line change to that file,
167
+ visible in review.
168
+
169
+ The checksum is not decoration: a Git tag can be moved, and without verifying
170
+ the asset a port's tests could change with no diff at all.
171
+
172
+ The adapter lives in `src/test/conformance-adapter/`. It fails loudly on a case
173
+ whose `target`, `toggle` or `expected` it does not implement, rather than
174
+ skipping it -- a silently skipped case is a rule that nothing enforces.
175
+
145
176
  # Licenses
146
177
 
147
178
  - Code: MIT License
@@ -0,0 +1,2 @@
1
+ version=v1.1.0
2
+ sha256=d83ff1c960ad29830c00b57727591da628f4323b777faea245f603565d1c9ae9
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@tehw0lf/yaft",
3
- "version": "0.0.12",
3
+ "version": "0.0.15",
4
4
  "description": "YaFT - Feature Toggles using Go&PostgreSQL or any source!",
5
5
  "type": "commonjs",
6
6
  "scripts": {
7
7
  "build": "tsc && cp package.json README.md logo.svg dist/yaft/ && npm pkg delete devDependencies --prefix dist/yaft",
8
- "test": "npx jest"
8
+ "test": "npx jest",
9
+ "conformance:fetch": "./scripts/fetch-conformance.sh src/test/conformance",
10
+ "pretest": "npm run conformance:fetch"
9
11
  },
10
12
  "repository": {
11
13
  "type": "git",
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Fetches the YaFT conformance suite pinned in conformance.lock.
4
+ #
5
+ # Copy this into a port, next to a conformance.lock of the form:
6
+ #
7
+ # version=v1.0.0
8
+ # sha256=<checksum of cases.tar.gz>
9
+ #
10
+ # The checksum is not optional. A Git tag can be moved; verifying the asset is
11
+ # what stops a port's tests from changing without a diff.
12
+ #
13
+ # Usage: scripts/fetch-conformance.sh [target-dir] (default: src/test/conformance)
14
+ #
15
+ # Set the default to wherever the port's adapter reads the cases from. Ports
16
+ # differ -- yaft-ts uses src/test/conformance -- and a default that does not
17
+ # match leaves the next test run reporting missing cases.
18
+
19
+ set -euo pipefail
20
+
21
+ REPO="tehw0lf/yaft-conformance"
22
+ LOCK="${LOCK:-conformance.lock}"
23
+ TARGET="${1:-src/test/conformance}"
24
+
25
+ if [[ ! -f "$LOCK" ]]; then
26
+ echo "error: $LOCK not found; run from the port's root" >&2
27
+ exit 1
28
+ fi
29
+
30
+ version="$(grep -E '^version=' "$LOCK" | cut -d= -f2-)"
31
+ expected="$(grep -E '^sha256=' "$LOCK" | cut -d= -f2-)"
32
+
33
+ if [[ -z "$version" || -z "$expected" ]]; then
34
+ echo "error: $LOCK needs both version= and sha256=" >&2
35
+ exit 1
36
+ fi
37
+
38
+ tmp="$(mktemp -d)"
39
+ trap 'rm -rf "$tmp"' EXIT
40
+
41
+ url="https://github.com/${REPO}/releases/download/${version}/cases.tar.gz"
42
+ echo "fetching conformance suite ${version}"
43
+ curl --fail --location --silent --show-error --output "$tmp/cases.tar.gz" "$url"
44
+
45
+ # sha256sum is GNU coreutils and is not present on macOS; shasum ships with
46
+ # both. Preferring sha256sum keeps Linux CI on the faster binary.
47
+ if command -v sha256sum >/dev/null 2>&1; then
48
+ actual="$(sha256sum "$tmp/cases.tar.gz" | cut -d' ' -f1)"
49
+ elif command -v shasum >/dev/null 2>&1; then
50
+ actual="$(shasum -a 256 "$tmp/cases.tar.gz" | cut -d' ' -f1)"
51
+ else
52
+ echo "error: neither sha256sum nor shasum found; cannot verify the download" >&2
53
+ exit 1
54
+ fi
55
+ if [[ "$actual" != "$expected" ]]; then
56
+ echo "error: checksum mismatch for cases.tar.gz" >&2
57
+ echo " expected $expected" >&2
58
+ echo " actual $actual" >&2
59
+ echo "The tag may have been moved. Do not update the lock without reading the diff." >&2
60
+ exit 1
61
+ fi
62
+
63
+ rm -rf "$TARGET"
64
+ mkdir -p "$TARGET"
65
+ tar --extract --gzip --file "$tmp/cases.tar.gz" --directory "$TARGET"
66
+
67
+ echo "conformance suite ${version} unpacked into ${TARGET}"
@@ -34,14 +34,27 @@ export function FeatureToggle(key: string, fallback?: any) {
34
34
  // Method
35
35
  const originalMethod = descriptor.value;
36
36
 
37
+ // An async method must keep returning a promise when it is switched
38
+ // off, or `await` at the call site breaks on a plain undefined. The
39
+ // empty class shell below already makes this distinction; without it
40
+ // here, turning a feature off would throw inside unrelated code.
41
+ const isAsync =
42
+ originalMethod?.[Symbol.toStringTag] === "AsyncFunction";
43
+
37
44
  descriptor.value = function (...args: any[]) {
38
45
  const isEnabled = FeatureToggleBase.featureProvider.isEnabled(key);
39
46
  if (isEnabled) {
40
47
  return originalMethod.apply(this, args);
41
48
  } else {
42
- return fallback !== undefined
43
- ? fallback.apply(this, args as [])
44
- : (() => {}).apply(this, args as []);
49
+ if (fallback !== undefined) {
50
+ const result = fallback.apply(this, args as []);
51
+ // A synchronous fallback on an async method would otherwise hand
52
+ // back a plain value, breaking the promise the signature
53
+ // advertises. Promise.resolve passes an existing promise through
54
+ // unchanged, so an async fallback is unaffected.
55
+ return isAsync ? Promise.resolve(result) : result;
56
+ }
57
+ return isAsync ? Promise.resolve() : undefined;
45
58
  }
46
59
  };
47
60
  return descriptor;
@@ -1,6 +1,7 @@
1
1
  import axios from "axios";
2
2
 
3
3
  import { FeatureProvider } from "../FeatureToggle";
4
+ import { normaliseCollection } from "../mapping";
4
5
 
5
6
  export class ApiServiceBooleanProvider implements FeatureProvider<boolean> {
6
7
  apiUrl: string;
@@ -30,22 +31,28 @@ export class ApiServiceBooleanProvider implements FeatureProvider<boolean> {
30
31
  async getConfig(configPathOrUrl: string): Promise<void> {
31
32
  try {
32
33
  const response = await axios.get(configPathOrUrl);
33
- // Handle Go backend response format
34
- const featuresArray = response.data.toggles || response.data.value || [];
35
-
36
- // Convert array to keyed boolean object and handle capitalized field names
34
+
35
+ // A keyed boolean object is already in this provider's shape and is
36
+ // taken as-is; anything else is a feature-shaped response and goes
37
+ // through the core normaliser, so the two providers cannot disagree
38
+ // about what a response means.
39
+ if (isKeyedBooleans(response.data)) {
40
+ this.data = response.data;
41
+ return;
42
+ }
43
+
44
+ // Only the value matters here. The boolean shape has no time logic by
45
+ // design (R21), so a feature collapses to whether its value is exactly
46
+ // "true" -- activeAt and disabledAt are dropped.
47
+ //
48
+ // That is a real trap when this provider is pointed at a backend that
49
+ // schedules toggles: the window is then enforced only by the backend's
50
+ // cron job, which lags by up to a minute, instead of being evaluated
51
+ // locally. Use ApiServiceFeatureProvider when the toggles carry dates.
52
+ const features = normaliseCollection(response.data);
37
53
  this.data = {};
38
- if (Array.isArray(featuresArray)) {
39
- featuresArray.forEach((feature: any) => {
40
- const key = feature.key || feature.Key;
41
- const value = feature.value || feature.Value;
42
- if (key) {
43
- this.data[key] = value === 'true' || value === true;
44
- }
45
- });
46
- } else {
47
- // Fallback for object format
48
- this.data = featuresArray;
54
+ for (const [key, feature] of Object.entries(features)) {
55
+ this.data[key] = feature.value === 'true';
49
56
  }
50
57
  } catch (error) {
51
58
  console.error("Failed to fetch feature toggle from API:", error);
@@ -58,3 +65,18 @@ export class ApiServiceBooleanProvider implements FeatureProvider<boolean> {
58
65
  return feature;
59
66
  }
60
67
  }
68
+
69
+ /**
70
+ * True when the payload is already `{ "myToggle": true }`.
71
+ *
72
+ * Distinguishing this from a feature-shaped response matters: running a keyed
73
+ * boolean object through the feature normaliser would look for a `key` field,
74
+ * find none and discard every entry.
75
+ */
76
+ function isKeyedBooleans(data: unknown): data is Record<string, boolean> {
77
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
78
+ return false;
79
+ }
80
+ const values = Object.values(data as Record<string, unknown>);
81
+ return values.length > 0 && values.every((v) => typeof v === 'boolean');
82
+ }
@@ -1,6 +1,7 @@
1
1
  import axios from "axios";
2
2
 
3
3
  import { Clock, evaluate, systemClock } from "../evaluate";
4
+ import { normaliseCollection } from "../mapping";
4
5
  import { Feature, FeatureProvider } from "../FeatureToggle";
5
6
 
6
7
  export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
@@ -37,28 +38,9 @@ export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
37
38
  async getConfig(configPathOrUrl: string): Promise<void> {
38
39
  try {
39
40
  const response = await axios.get(configPathOrUrl);
40
- // Handle Go backend response format
41
- const featuresArray = response.data.toggles || response.data.value || [];
42
-
43
- // Convert array to keyed object and handle capitalized field names
44
- this.data = {};
45
- if (Array.isArray(featuresArray)) {
46
- featuresArray.forEach((feature: any) => {
47
- const normalizedFeature = {
48
- key: feature.key || feature.Key,
49
- value: feature.value || feature.Value,
50
- activeAt: feature.activeAt || feature.ActiveAt,
51
- disabledAt: feature.disabledAt || feature.DisabledAt,
52
- tags: feature.tags || feature.Tags || [],
53
- };
54
- if (normalizedFeature.key) {
55
- this.data[normalizedFeature.key] = normalizedFeature;
56
- }
57
- });
58
- } else {
59
- // Fallback for object format
60
- this.data = featuresArray;
61
- }
41
+ // The mapping rules live in the core, next to the evaluation rules,
42
+ // rather than being reimplemented per provider.
43
+ this.data = normaliseCollection(response.data);
62
44
  } catch (error) {
63
45
  console.error("Failed to fetch feature toggle from API:", error);
64
46
  }
package/src/index.ts CHANGED
@@ -5,3 +5,4 @@ export {
5
5
  FeatureProvider,
6
6
  } from "./FeatureToggle";
7
7
  export { Clock, evaluate, parseTimestamp, systemClock } from "./evaluate";
8
+ export { normaliseCollection, normaliseFeature } from "./mapping";
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
+ });