@tehw0lf/yaft 0.0.10 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/security-scan.yml +2 -2
- package/CLAUDE.md +17 -4
- package/README.md +45 -0
- package/logo.svg +3 -3
- package/package.json +1 -1
- package/src/evaluate.ts +122 -0
- package/src/examples/ApiServiceFeatureProvider.ts +9 -26
- package/src/examples/LocalStorageFeatureProvider.ts +11 -27
- package/src/index.ts +1 -0
- package/src/test/evaluate.spec.ts +260 -0
- package/src/test/injectable-clock.spec.ts +112 -0
|
@@ -49,7 +49,7 @@ jobs:
|
|
|
49
49
|
|
|
50
50
|
steps:
|
|
51
51
|
- name: Download latest dist artifacts from CI
|
|
52
|
-
uses: dawidd6/action-download-artifact@v6
|
|
52
|
+
uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6
|
|
53
53
|
with:
|
|
54
54
|
workflow: build.yml
|
|
55
55
|
name: dist
|
|
@@ -58,7 +58,7 @@ jobs:
|
|
|
58
58
|
|
|
59
59
|
- name: Upload artifacts for scanning
|
|
60
60
|
if: hashFiles('dist/**/*') != ''
|
|
61
|
-
uses: actions/upload-artifact@v4
|
|
61
|
+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
62
62
|
with:
|
|
63
63
|
name: build
|
|
64
64
|
path: dist/
|
package/CLAUDE.md
CHANGED
|
@@ -58,18 +58,31 @@ npx jest -t "test name" # Run specific test by name pattern
|
|
|
58
58
|
- Support both boolean and Feature data types
|
|
59
59
|
- Fallback to empty configuration on file load errors
|
|
60
60
|
|
|
61
|
+
**Evaluation Core (`src/evaluate.ts`)**
|
|
62
|
+
- `evaluate(feature, now)` is the single definition of the evaluation rules
|
|
63
|
+
- Providers call it instead of implementing the logic themselves; before this
|
|
64
|
+
existed, the feature providers each carried their own copy
|
|
65
|
+
- `Clock` is a `() => number`; providers take one and default to `systemClock`,
|
|
66
|
+
so tests and the conformance adapter can pin `now`
|
|
67
|
+
- `parseTimestamp` accepts only RFC 3339 with an offset and returns
|
|
68
|
+
`undefined` for anything else, warning rather than throwing
|
|
69
|
+
|
|
61
70
|
### Feature Data Model
|
|
62
71
|
|
|
63
72
|
```typescript
|
|
64
73
|
type Feature = {
|
|
65
|
-
key: string;
|
|
66
|
-
value: string;
|
|
67
|
-
activeAt: string;
|
|
68
|
-
disabledAt: string; //
|
|
74
|
+
key: string; // Unique feature identifier
|
|
75
|
+
value: string; // Boolean value as string; only "true" is on
|
|
76
|
+
activeAt: string; // RFC 3339 with offset, or empty
|
|
77
|
+
disabledAt: string; // RFC 3339 with offset, or empty
|
|
78
|
+
tags?: string[];
|
|
69
79
|
}
|
|
70
80
|
```
|
|
71
81
|
|
|
72
82
|
Features support time-based activation/deactivation logic evaluated at runtime.
|
|
83
|
+
The window is half-open: `now == activeAt` is on, `now == disabledAt` is off.
|
|
84
|
+
Dates without an offset are ignored, because languages disagree on how to read
|
|
85
|
+
them and a feature would otherwise flip at a different instant per port.
|
|
73
86
|
|
|
74
87
|
### Decorator Behavior
|
|
75
88
|
|
package/README.md
CHANGED
|
@@ -94,9 +94,54 @@ export type Feature = {
|
|
|
94
94
|
value: string;
|
|
95
95
|
activeAt: string;
|
|
96
96
|
disabledAt: string;
|
|
97
|
+
tags?: string[];
|
|
97
98
|
};
|
|
98
99
|
```
|
|
99
100
|
|
|
101
|
+
## Evaluation rules
|
|
102
|
+
|
|
103
|
+
A feature is on when all of the following hold. `evaluate` is exported, so the
|
|
104
|
+
rules can be applied directly to a feature without going through a provider.
|
|
105
|
+
|
|
106
|
+
- The value is exactly `"true"`. `"TRUE"`, `"1"` and `""` are off -- the value
|
|
107
|
+
is stored as a string, and anything else would be a silent disagreement
|
|
108
|
+
between backend and client.
|
|
109
|
+
- `activeAt` has passed, if set. The bound is inclusive: at exactly `activeAt`
|
|
110
|
+
the feature is on.
|
|
111
|
+
- `disabledAt` has not been reached, if set. This bound is exclusive: at
|
|
112
|
+
exactly `disabledAt` the feature is off.
|
|
113
|
+
|
|
114
|
+
A missing feature is off. Unset, `null` or unparseable dates are ignored rather
|
|
115
|
+
than treated as an error, and never throw.
|
|
116
|
+
|
|
117
|
+
### Date format
|
|
118
|
+
|
|
119
|
+
Dates must be **RFC 3339 with an offset** (`2026-09-18T15:00:00Z` or
|
|
120
|
+
`2026-09-18T15:00:00+02:00`). Anything else -- a bare date such as
|
|
121
|
+
`2026-09-18`, or a timestamp without an offset -- is ignored and logged as a
|
|
122
|
+
warning.
|
|
123
|
+
|
|
124
|
+
This is stricter than `Date.parse`, on purpose: JavaScript reads a bare date as
|
|
125
|
+
UTC midnight and an offset-less timestamp as local time, while most other
|
|
126
|
+
languages read both as local. Accepting them would make a feature flip at a
|
|
127
|
+
different instant depending on which client evaluated it.
|
|
128
|
+
|
|
129
|
+
## Testing with a fixed time
|
|
130
|
+
|
|
131
|
+
Both `Feature` providers take an optional clock, so a test can evaluate against
|
|
132
|
+
a fixed instant instead of the current time:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { LocalStorageFeatureProvider } from "./provider";
|
|
136
|
+
|
|
137
|
+
const provider = new LocalStorageFeatureProvider(
|
|
138
|
+
"./test-feature.json",
|
|
139
|
+
() => Date.parse("2026-09-18T12:00:00Z")
|
|
140
|
+
);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The clock defaults to the system time, so existing code needs no change.
|
|
144
|
+
|
|
100
145
|
# Licenses
|
|
101
146
|
|
|
102
147
|
- Code: MIT License
|
package/logo.svg
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<svg viewBox="0 0 60 60" stroke="" fill="
|
|
1
|
+
<svg viewBox="0 0 60 60" stroke="" fill="#8e8e8e" xmlns="http://www.w3.org/2000/svg">
|
|
2
2
|
<title>YaFT Logo</title>
|
|
3
3
|
<desc>Yet Another Feature Toggle - Copyright 2025 tehw0lf</desc>
|
|
4
4
|
<metadata>
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
</rdf:RDF>
|
|
12
12
|
</metadata>
|
|
13
13
|
<!-- Y -->
|
|
14
|
-
<path d="M5,5 L30,28" stroke="
|
|
15
|
-
<path d="M55,5 L30,28" stroke="
|
|
14
|
+
<path d="M5,5 L30,28" stroke="#8e8e8e" stroke-width="4" fill="none" />
|
|
15
|
+
<path d="M55,5 L30,28" stroke="#8e8e8e" stroke-width="4" fill="none" />
|
|
16
16
|
<!-- A -->
|
|
17
17
|
<rect x="19" y="17.5" width="22" height="3" />
|
|
18
18
|
<!-- F -->
|
package/package.json
CHANGED
package/src/evaluate.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Feature } from "./FeatureToggle";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A source of the current time, in milliseconds since the epoch.
|
|
5
|
+
*
|
|
6
|
+
* Everything that evaluates a feature takes one of these instead of calling
|
|
7
|
+
* `Date.now()` directly, so tests -- and the conformance suite, which supplies
|
|
8
|
+
* a `now` with every case -- can evaluate against a fixed instant.
|
|
9
|
+
*/
|
|
10
|
+
export type Clock = () => number;
|
|
11
|
+
|
|
12
|
+
/** The default clock: the system time. */
|
|
13
|
+
export const systemClock: Clock = () => Date.now();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Matches RFC 3339 timestamps that carry an explicit offset (`Z` or `±hh:mm`).
|
|
17
|
+
*
|
|
18
|
+
* Only this format is accepted. A bare date such as `2026-09-18` or a
|
|
19
|
+
* timestamp without an offset is rejected, because languages disagree on how
|
|
20
|
+
* to read them -- JavaScript treats a bare date as UTC midnight and an
|
|
21
|
+
* offset-less timestamp as local time, while most other languages read both as
|
|
22
|
+
* local. A feature would then flip at a different instant depending on which
|
|
23
|
+
* port evaluated it, so such values are ignored rather than guessed at.
|
|
24
|
+
*/
|
|
25
|
+
const RFC3339_WITH_OFFSET =
|
|
26
|
+
/^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
27
|
+
|
|
28
|
+
/** Days per month, index 1-12; February is handled by the leap-year branch. */
|
|
29
|
+
const DAYS_IN_MONTH = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
30
|
+
|
|
31
|
+
function isLeapYear(year: number): boolean {
|
|
32
|
+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isRealDate(year: number, month: number, day: number): boolean {
|
|
36
|
+
if (month < 1 || month > 12 || day < 1) return false;
|
|
37
|
+
const max = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month];
|
|
38
|
+
return day <= max;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRealTime(hour: number, minute: number, second: number): boolean {
|
|
42
|
+
// RFC 3339 permits second 60 for a leap second; it is allowed through here
|
|
43
|
+
// and then rejected by the NaN guard, because Date.parse cannot represent
|
|
44
|
+
// one. The value ends up ignored either way.
|
|
45
|
+
return hour <= 23 && minute <= 59 && second <= 60;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parses an RFC 3339 timestamp with an offset.
|
|
50
|
+
*
|
|
51
|
+
* Returns `undefined` for anything unset, malformed or in another format;
|
|
52
|
+
* callers treat that as "no bound", never as an error. An invalid value is
|
|
53
|
+
* warned about but never throws, so a bad timestamp in the backend cannot take
|
|
54
|
+
* an application down.
|
|
55
|
+
*/
|
|
56
|
+
export function parseTimestamp(value: string | null | undefined): number | undefined {
|
|
57
|
+
if (value === null || value === undefined || value === "") return undefined;
|
|
58
|
+
|
|
59
|
+
const match = RFC3339_WITH_OFFSET.exec(value);
|
|
60
|
+
if (!match) {
|
|
61
|
+
console.warn(
|
|
62
|
+
`YaFT: ignoring "${value}", expected RFC 3339 with an offset (e.g. 2026-09-18T15:00:00Z)`
|
|
63
|
+
);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The pattern only checks the shape, and Date.parse does not reject an
|
|
68
|
+
// impossible calendar date -- it rolls it over, turning 2027-02-30 into
|
|
69
|
+
// 2027-03-02. Silently shifting a bound by days is worse than ignoring it,
|
|
70
|
+
// so the components are range-checked first.
|
|
71
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
72
|
+
if (!isRealDate(+year, +month, +day) || !isRealTime(+hour, +minute, +second)) {
|
|
73
|
+
console.warn(`YaFT: ignoring "${value}", not a valid date or time`);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parsed = Date.parse(value);
|
|
78
|
+
if (Number.isNaN(parsed)) {
|
|
79
|
+
console.warn(`YaFT: ignoring "${value}", not a valid timestamp`);
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decides whether a feature is on at the instant `now`.
|
|
88
|
+
*
|
|
89
|
+
* This is the single definition of YaFT's evaluation rules. Providers call it
|
|
90
|
+
* rather than implementing the logic themselves, so every provider -- and
|
|
91
|
+
* every port that mirrors this function -- agrees on the same answer.
|
|
92
|
+
*
|
|
93
|
+
* The rules:
|
|
94
|
+
*
|
|
95
|
+
* - A missing feature is off.
|
|
96
|
+
* - Only the exact string `"true"` is on. `"TRUE"`, `"1"` and `""` are off,
|
|
97
|
+
* because the backend stores the value as a string and anything else would
|
|
98
|
+
* be a silent disagreement between backend and client.
|
|
99
|
+
* - `activeAt` and `disabledAt` are optional bounds. Unset, null or
|
|
100
|
+
* unparseable values are ignored rather than treated as an error.
|
|
101
|
+
* - The window is half-open: at exactly `activeAt` the feature is on
|
|
102
|
+
* (`now < activeAt` is off), at exactly `disabledAt` it is off
|
|
103
|
+
* (`now >= disabledAt` is off).
|
|
104
|
+
* - `activeAt` after `disabledAt` is not special-cased; it simply yields a
|
|
105
|
+
* window that is never open.
|
|
106
|
+
*/
|
|
107
|
+
export function evaluate(
|
|
108
|
+
feature: Feature | null | undefined,
|
|
109
|
+
now: number
|
|
110
|
+
): boolean {
|
|
111
|
+
if (feature === undefined || feature === null) return false;
|
|
112
|
+
|
|
113
|
+
if (feature.value !== "true") return false;
|
|
114
|
+
|
|
115
|
+
const activeAt = parseTimestamp(feature.activeAt);
|
|
116
|
+
if (activeAt !== undefined && now < activeAt) return false;
|
|
117
|
+
|
|
118
|
+
const disabledAt = parseTimestamp(feature.disabledAt);
|
|
119
|
+
if (disabledAt !== undefined && now >= disabledAt) return false;
|
|
120
|
+
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
|
|
3
|
+
import { Clock, evaluate, systemClock } from "../evaluate";
|
|
3
4
|
import { Feature, FeatureProvider } from "../FeatureToggle";
|
|
4
5
|
|
|
5
6
|
export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
|
|
@@ -7,8 +8,14 @@ export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
|
|
|
7
8
|
baseUUID: string;
|
|
8
9
|
data: Record<string, Feature> = {};
|
|
9
10
|
collectionHash = "";
|
|
11
|
+
private readonly clock: Clock;
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
/**
|
|
14
|
+
* @param clock source of the current time; override it to evaluate against a
|
|
15
|
+
* fixed instant in tests
|
|
16
|
+
*/
|
|
17
|
+
constructor(apiUrl: string, baseUUID: string, clock: Clock = systemClock) {
|
|
18
|
+
this.clock = clock;
|
|
12
19
|
this.apiUrl = apiUrl;
|
|
13
20
|
this.baseUUID = baseUUID;
|
|
14
21
|
this.getCollectionHash(`${this.apiUrl}/collectionHash/${this.baseUUID}`);
|
|
@@ -58,30 +65,6 @@ export class ApiServiceFeatureProvider implements FeatureProvider<Feature> {
|
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
isEnabled(key: string): boolean {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (feature === undefined || feature === null) return false;
|
|
64
|
-
|
|
65
|
-
// First check: value must be "true"
|
|
66
|
-
if (feature.value !== "true") return false;
|
|
67
|
-
|
|
68
|
-
// Second check: if activeAt is set and in future, not yet active
|
|
69
|
-
if (feature.activeAt && feature.activeAt !== "") {
|
|
70
|
-
const activeTime = Date.parse(feature.activeAt);
|
|
71
|
-
if (!isNaN(activeTime) && Date.now() < activeTime) {
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Third check: if disabledAt is set and in past, already disabled
|
|
77
|
-
if (feature.disabledAt && feature.disabledAt !== "") {
|
|
78
|
-
const disabledTime = Date.parse(feature.disabledAt);
|
|
79
|
-
if (!isNaN(disabledTime) && Date.now() >= disabledTime) {
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// All checks passed, feature is enabled
|
|
85
|
-
return true;
|
|
68
|
+
return evaluate(this.data[key], this.clock());
|
|
86
69
|
}
|
|
87
70
|
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { Clock, evaluate, systemClock } from "../evaluate";
|
|
1
2
|
import { Feature, FeatureProvider } from "../FeatureToggle";
|
|
2
3
|
|
|
3
4
|
export class LocalStorageFeatureProvider implements FeatureProvider<Feature> {
|
|
4
5
|
data: Record<string, Feature> = {};
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
private readonly clock: Clock;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param configPath path passed to `require()`
|
|
10
|
+
* @param clock source of the current time; override it to evaluate against a
|
|
11
|
+
* fixed instant in tests
|
|
12
|
+
*/
|
|
13
|
+
constructor(configPath: string, clock: Clock = systemClock) {
|
|
14
|
+
this.clock = clock;
|
|
7
15
|
this.getConfig(configPath);
|
|
8
16
|
}
|
|
9
17
|
|
|
@@ -18,30 +26,6 @@ export class LocalStorageFeatureProvider implements FeatureProvider<Feature> {
|
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
isEnabled(key: string): boolean {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (feature === undefined || feature === null) return false;
|
|
24
|
-
|
|
25
|
-
// First check: value must be "true"
|
|
26
|
-
if (feature.value !== "true") return false;
|
|
27
|
-
|
|
28
|
-
// Second check: if activeAt is set and in future, not yet active
|
|
29
|
-
if (feature.activeAt && feature.activeAt !== "") {
|
|
30
|
-
const activeTime = Date.parse(feature.activeAt);
|
|
31
|
-
if (!isNaN(activeTime) && Date.now() < activeTime) {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Third check: if disabledAt is set and in past, already disabled
|
|
37
|
-
if (feature.disabledAt && feature.disabledAt !== "") {
|
|
38
|
-
const disabledTime = Date.parse(feature.disabledAt);
|
|
39
|
-
if (!isNaN(disabledTime) && Date.now() >= disabledTime) {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// All checks passed, feature is enabled
|
|
45
|
-
return true;
|
|
29
|
+
return evaluate(this.data[key], this.clock());
|
|
46
30
|
}
|
|
47
31
|
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { Feature } from '../FeatureToggle';
|
|
2
|
+
import { Clock, evaluate, parseTimestamp, systemClock } from '../evaluate';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for the central evaluation rules.
|
|
6
|
+
*
|
|
7
|
+
* Every case carries its own `now`, so nothing here depends on when the suite
|
|
8
|
+
* runs. This mirrors how the conformance suite supplies a `now` per case, and
|
|
9
|
+
* is the reason the clock had to become injectable.
|
|
10
|
+
*/
|
|
11
|
+
describe('evaluate', () => {
|
|
12
|
+
const NOW = Date.parse('2026-09-18T12:00:00Z');
|
|
13
|
+
|
|
14
|
+
const feature = (overrides: Partial<Feature> = {}): Feature => ({
|
|
15
|
+
key: 'f',
|
|
16
|
+
value: 'true',
|
|
17
|
+
activeAt: '',
|
|
18
|
+
disabledAt: '',
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('missing features', () => {
|
|
23
|
+
it('treats undefined as off', () => {
|
|
24
|
+
expect(evaluate(undefined, NOW)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('treats null as off', () => {
|
|
28
|
+
expect(evaluate(null, NOW)).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('value', () => {
|
|
33
|
+
it('is on for exactly "true"', () => {
|
|
34
|
+
expect(evaluate(feature({ value: 'true' }), NOW)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// The backend stores the value as a string. Anything but "true" must read
|
|
38
|
+
// as off, or backend and client would silently disagree.
|
|
39
|
+
it.each(['false', 'TRUE', 'True', '1', '', 'yes'])(
|
|
40
|
+
'is off for %p',
|
|
41
|
+
(value) => {
|
|
42
|
+
expect(evaluate(feature({ value }), NOW)).toBe(false);
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
it('is off when the value is missing entirely', () => {
|
|
47
|
+
const withoutValue = { key: 'f', activeAt: '', disabledAt: '' } as Feature;
|
|
48
|
+
expect(evaluate(withoutValue, NOW)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('activeAt', () => {
|
|
53
|
+
it('is off before activeAt', () => {
|
|
54
|
+
expect(
|
|
55
|
+
evaluate(feature({ activeAt: '2026-09-18T13:00:00Z' }), NOW)
|
|
56
|
+
).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('is on after activeAt', () => {
|
|
60
|
+
expect(
|
|
61
|
+
evaluate(feature({ activeAt: '2026-09-18T11:00:00Z' }), NOW)
|
|
62
|
+
).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// The window is half-open: the comparison is `now < activeAt`.
|
|
66
|
+
it('is on at exactly activeAt', () => {
|
|
67
|
+
expect(
|
|
68
|
+
evaluate(feature({ activeAt: '2026-09-18T12:00:00Z' }), NOW)
|
|
69
|
+
).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('disabledAt', () => {
|
|
74
|
+
it('is on before disabledAt', () => {
|
|
75
|
+
expect(
|
|
76
|
+
evaluate(feature({ disabledAt: '2026-09-18T13:00:00Z' }), NOW)
|
|
77
|
+
).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('is off after disabledAt', () => {
|
|
81
|
+
expect(
|
|
82
|
+
evaluate(feature({ disabledAt: '2026-09-18T11:00:00Z' }), NOW)
|
|
83
|
+
).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The comparison is `now >= disabledAt`, so the boundary is off -- the
|
|
87
|
+
// opposite of the activeAt boundary.
|
|
88
|
+
it('is off at exactly disabledAt', () => {
|
|
89
|
+
expect(
|
|
90
|
+
evaluate(feature({ disabledAt: '2026-09-18T12:00:00Z' }), NOW)
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('both bounds', () => {
|
|
96
|
+
const windowed = feature({
|
|
97
|
+
activeAt: '2026-09-18T10:00:00Z',
|
|
98
|
+
disabledAt: '2026-09-18T14:00:00Z',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('is on inside the window', () => {
|
|
102
|
+
expect(evaluate(windowed, NOW)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('is off before the window', () => {
|
|
106
|
+
expect(evaluate(windowed, Date.parse('2026-09-18T09:00:00Z'))).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('is off after the window', () => {
|
|
110
|
+
expect(evaluate(windowed, Date.parse('2026-09-18T15:00:00Z'))).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Not a special case: the bounds simply never overlap.
|
|
114
|
+
it('is off everywhere when activeAt is after disabledAt', () => {
|
|
115
|
+
const inverted = feature({
|
|
116
|
+
activeAt: '2026-09-18T14:00:00Z',
|
|
117
|
+
disabledAt: '2026-09-18T10:00:00Z',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
for (const at of [
|
|
121
|
+
'2026-09-18T09:00:00Z',
|
|
122
|
+
'2026-09-18T12:00:00Z',
|
|
123
|
+
'2026-09-18T16:00:00Z',
|
|
124
|
+
]) {
|
|
125
|
+
expect(evaluate(inverted, Date.parse(at))).toBe(false);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('timezone offsets', () => {
|
|
131
|
+
it('converts an offset to the same instant as UTC', () => {
|
|
132
|
+
// 14:00+02:00 is 12:00Z, which is exactly NOW, so the activeAt boundary
|
|
133
|
+
// is inclusive and the feature is on.
|
|
134
|
+
expect(
|
|
135
|
+
evaluate(feature({ activeAt: '2026-09-18T14:00:00+02:00' }), NOW)
|
|
136
|
+
).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('respects a negative offset', () => {
|
|
140
|
+
// 08:00-05:00 is 13:00Z, one hour after NOW, so it is not active yet.
|
|
141
|
+
expect(
|
|
142
|
+
evaluate(feature({ activeAt: '2026-09-18T08:00:00-05:00' }), NOW)
|
|
143
|
+
).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('unset and invalid bounds are ignored', () => {
|
|
148
|
+
it.each([
|
|
149
|
+
['empty string', ''],
|
|
150
|
+
['null', null],
|
|
151
|
+
['undefined', undefined],
|
|
152
|
+
])('ignores %s', (_label, value) => {
|
|
153
|
+
const f = feature({
|
|
154
|
+
activeAt: value as string,
|
|
155
|
+
disabledAt: value as string,
|
|
156
|
+
});
|
|
157
|
+
expect(evaluate(f, NOW)).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Rejected on purpose: JavaScript reads a bare date as UTC midnight and an
|
|
161
|
+
// offset-less timestamp as local time, while most other languages read
|
|
162
|
+
// both as local. Accepting them would make a feature flip at a different
|
|
163
|
+
// instant depending on the port.
|
|
164
|
+
it.each([
|
|
165
|
+
['a bare date', '2026-09-18'],
|
|
166
|
+
['no offset', '2026-09-18T15:00:00'],
|
|
167
|
+
['garbage', 'not-a-date'],
|
|
168
|
+
['a unix timestamp', '1758196800'],
|
|
169
|
+
['a slash date', '2026/09/18'],
|
|
170
|
+
])('ignores %s in activeAt', (_label, value) => {
|
|
171
|
+
// Would be off if parsed as a future bound; ignoring it leaves the
|
|
172
|
+
// feature on.
|
|
173
|
+
expect(evaluate(feature({ activeAt: value }), NOW)).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Date.parse does not reject an impossible calendar date, it rolls it
|
|
177
|
+
// over: 2027-02-30 becomes 2027-03-02. The date must therefore be in the
|
|
178
|
+
// future, or the rolled-over value lands in the past and the activeAt
|
|
179
|
+
// check passes for the wrong reason.
|
|
180
|
+
it.each([
|
|
181
|
+
['a day past the end of February', '2027-02-30T00:00:00Z'],
|
|
182
|
+
['the 31st of a 30-day month', '2027-04-31T00:00:00Z'],
|
|
183
|
+
['month 13', '2027-13-01T00:00:00Z'],
|
|
184
|
+
['day zero', '2027-01-00T00:00:00Z'],
|
|
185
|
+
['February 29 in a non-leap year', '2027-02-29T00:00:00Z'],
|
|
186
|
+
['hour 24', '2027-01-01T24:00:00Z'],
|
|
187
|
+
['minute 60', '2027-01-01T00:60:00Z'],
|
|
188
|
+
])('ignores %s', (_label, value) => {
|
|
189
|
+
expect(evaluate(feature({ activeAt: value }), NOW)).toBe(true);
|
|
190
|
+
expect(parseTimestamp(value)).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('accepts February 29 in a leap year', () => {
|
|
194
|
+
expect(parseTimestamp('2028-02-29T00:00:00Z')).toBe(
|
|
195
|
+
Date.parse('2028-02-29T00:00:00Z')
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// RFC 3339 permits second 60 for a leap second, but Date.parse returns
|
|
200
|
+
// NaN for it, so the value is ignored like any other unusable bound. The
|
|
201
|
+
// range check lets it through and the NaN guard catches it; asserted here
|
|
202
|
+
// so the two stay consistent.
|
|
203
|
+
it('ignores a leap second, which Date.parse cannot represent', () => {
|
|
204
|
+
expect(parseTimestamp('2026-12-31T23:59:60Z')).toBeUndefined();
|
|
205
|
+
expect(
|
|
206
|
+
evaluate(feature({ activeAt: '2026-12-31T23:59:60Z' }), NOW)
|
|
207
|
+
).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('never throws on malformed input', () => {
|
|
211
|
+
expect(() =>
|
|
212
|
+
evaluate(feature({ activeAt: 'x', disabledAt: 'y' }), NOW)
|
|
213
|
+
).not.toThrow();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('fractional seconds', () => {
|
|
218
|
+
it('accepts them', () => {
|
|
219
|
+
expect(
|
|
220
|
+
evaluate(feature({ disabledAt: '2026-09-18T12:00:00.001Z' }), NOW)
|
|
221
|
+
).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('parseTimestamp', () => {
|
|
227
|
+
it('returns the epoch milliseconds for a valid timestamp', () => {
|
|
228
|
+
expect(parseTimestamp('2026-09-18T12:00:00Z')).toBe(
|
|
229
|
+
Date.parse('2026-09-18T12:00:00Z')
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it.each([undefined, null, ''])('returns undefined for %p', (value) => {
|
|
234
|
+
expect(parseTimestamp(value)).toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('returns undefined for a format without an offset', () => {
|
|
238
|
+
expect(parseTimestamp('2026-09-18T12:00:00')).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('accepts a lowercase t and z', () => {
|
|
242
|
+
expect(parseTimestamp('2026-09-18t12:00:00z')).toBe(
|
|
243
|
+
Date.parse('2026-09-18T12:00:00Z')
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('systemClock', () => {
|
|
249
|
+
it('reports the current time', () => {
|
|
250
|
+
const before = Date.now();
|
|
251
|
+
const reading = systemClock();
|
|
252
|
+
expect(reading).toBeGreaterThanOrEqual(before);
|
|
253
|
+
expect(reading).toBeLessThanOrEqual(Date.now());
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('is the default, so a provider works without one', () => {
|
|
257
|
+
const clock: Clock = () => 0;
|
|
258
|
+
expect(typeof clock()).toBe('number');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Clock } from '../evaluate';
|
|
2
|
+
import { Feature } from '../FeatureToggle';
|
|
3
|
+
import { ApiServiceFeatureProvider } from '../examples/ApiServiceFeatureProvider';
|
|
4
|
+
import { LocalStorageFeatureProvider } from '../examples/LocalStorageFeatureProvider';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The clock has to be injectable for the conformance suite: every case there
|
|
8
|
+
* carries its own `now`, and an adapter cannot set it if the providers read
|
|
9
|
+
* `Date.now()` directly.
|
|
10
|
+
*
|
|
11
|
+
* These tests pin that down, and pin down that both feature-shaped providers
|
|
12
|
+
* answer identically -- the point of moving the logic into evaluate().
|
|
13
|
+
*/
|
|
14
|
+
describe('injectable clock', () => {
|
|
15
|
+
const windowed: Feature = {
|
|
16
|
+
key: 'windowed',
|
|
17
|
+
value: 'true',
|
|
18
|
+
activeAt: '2026-09-18T10:00:00Z',
|
|
19
|
+
disabledAt: '2026-09-18T14:00:00Z',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const at = (iso: string): Clock => () => Date.parse(iso);
|
|
23
|
+
|
|
24
|
+
/** A provider holding `windowed`, without touching the filesystem. */
|
|
25
|
+
const localAt = (iso: string) => {
|
|
26
|
+
const provider = new LocalStorageFeatureProvider('', at(iso));
|
|
27
|
+
provider.data = { windowed };
|
|
28
|
+
return provider;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The same, for the API provider, without touching the network. */
|
|
32
|
+
const apiAt = (iso: string) => {
|
|
33
|
+
const provider = new ApiServiceFeatureProvider('', '', at(iso));
|
|
34
|
+
provider.data = { windowed };
|
|
35
|
+
return provider;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('LocalStorageFeatureProvider', () => {
|
|
39
|
+
it('is off before the window', () => {
|
|
40
|
+
expect(localAt('2026-09-18T09:59:59Z').isEnabled('windowed')).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('is on inside the window', () => {
|
|
44
|
+
expect(localAt('2026-09-18T12:00:00Z').isEnabled('windowed')).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is off after the window', () => {
|
|
48
|
+
expect(localAt('2026-09-18T14:00:00Z').isEnabled('windowed')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('ApiServiceFeatureProvider', () => {
|
|
53
|
+
it('is off before the window', () => {
|
|
54
|
+
expect(apiAt('2026-09-18T09:59:59Z').isEnabled('windowed')).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('is on inside the window', () => {
|
|
58
|
+
expect(apiAt('2026-09-18T12:00:00Z').isEnabled('windowed')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('is off after the window', () => {
|
|
62
|
+
expect(apiAt('2026-09-18T14:00:00Z').isEnabled('windowed')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Before this change each provider carried its own copy of the logic, so
|
|
67
|
+
// they could drift apart unnoticed. Now they must agree everywhere.
|
|
68
|
+
it('both feature providers agree at every instant', () => {
|
|
69
|
+
for (const iso of [
|
|
70
|
+
'2026-09-18T09:00:00Z',
|
|
71
|
+
'2026-09-18T10:00:00Z',
|
|
72
|
+
'2026-09-18T12:00:00Z',
|
|
73
|
+
'2026-09-18T13:59:59Z',
|
|
74
|
+
'2026-09-18T14:00:00Z',
|
|
75
|
+
'2026-09-18T20:00:00Z',
|
|
76
|
+
]) {
|
|
77
|
+
expect(localAt(iso).isEnabled('windowed')).toBe(
|
|
78
|
+
apiAt(iso).isEnabled('windowed')
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('the same provider answers differently as its clock advances', () => {
|
|
84
|
+
let now = Date.parse('2026-09-18T09:00:00Z');
|
|
85
|
+
const provider = new LocalStorageFeatureProvider('', () => now);
|
|
86
|
+
provider.data = { windowed };
|
|
87
|
+
|
|
88
|
+
expect(provider.isEnabled('windowed')).toBe(false);
|
|
89
|
+
|
|
90
|
+
now = Date.parse('2026-09-18T12:00:00Z');
|
|
91
|
+
expect(provider.isEnabled('windowed')).toBe(true);
|
|
92
|
+
|
|
93
|
+
now = Date.parse('2026-09-18T15:00:00Z');
|
|
94
|
+
expect(provider.isEnabled('windowed')).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('defaults to the system clock when none is given', () => {
|
|
98
|
+
const provider = new LocalStorageFeatureProvider('');
|
|
99
|
+
provider.data = {
|
|
100
|
+
open: { key: 'open', value: 'true', activeAt: '', disabledAt: '' },
|
|
101
|
+
expired: {
|
|
102
|
+
key: 'expired',
|
|
103
|
+
value: 'true',
|
|
104
|
+
activeAt: '',
|
|
105
|
+
disabledAt: '2000-01-01T00:00:00Z',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
expect(provider.isEnabled('open')).toBe(true);
|
|
110
|
+
expect(provider.isEnabled('expired')).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|