@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 +31 -0
- package/conformance.lock +2 -0
- package/package.json +4 -2
- package/scripts/fetch-conformance.sh +67 -0
- package/src/FeatureToggle.ts +16 -3
- package/src/examples/ApiServiceBooleanProvider.ts +37 -15
- package/src/examples/ApiServiceFeatureProvider.ts +4 -22
- package/src/index.ts +1 -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/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
|
package/conformance.lock
ADDED
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tehw0lf/yaft",
|
|
3
|
-
"version": "0.0.
|
|
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}"
|
package/src/FeatureToggle.ts
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
//
|
|
41
|
-
|
|
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
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
|
+
});
|