@featurevisor/core 2.13.0 → 2.15.0
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/CHANGELOG.md +22 -0
- package/coverage/clover.xml +1499 -1028
- package/coverage/coverage-final.json +21 -17
- package/coverage/lcov-report/builder/allocator.ts.html +1 -1
- package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
- package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
- package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
- package/coverage/lcov-report/builder/index.html +38 -8
- package/coverage/lcov-report/builder/mutateVariables.ts.html +400 -0
- package/coverage/lcov-report/builder/mutator.ts.html +796 -0
- package/coverage/lcov-report/builder/revision.ts.html +1 -1
- package/coverage/lcov-report/builder/traffic.ts.html +1 -1
- package/coverage/lcov-report/config/index.html +30 -15
- package/coverage/lcov-report/config/index.ts.html +88 -0
- package/coverage/lcov-report/config/projectConfig.ts.html +79 -46
- package/coverage/lcov-report/datasource/adapter.ts.html +74 -11
- package/coverage/lcov-report/datasource/datasource.ts.html +77 -26
- package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +693 -69
- package/coverage/lcov-report/datasource/index.html +35 -35
- package/coverage/lcov-report/datasource/index.ts.html +1 -1
- package/coverage/lcov-report/index.html +53 -53
- package/coverage/lcov-report/linter/attributeSchema.ts.html +4 -4
- package/coverage/lcov-report/linter/checkCircularDependency.ts.html +1 -1
- package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +1 -1
- package/coverage/lcov-report/linter/conditionSchema.ts.html +7 -7
- package/coverage/lcov-report/linter/featureSchema.ts.html +848 -389
- package/coverage/lcov-report/linter/groupSchema.ts.html +3 -3
- package/coverage/lcov-report/linter/index.html +50 -35
- package/coverage/lcov-report/linter/lintProject.ts.html +437 -92
- package/coverage/lcov-report/linter/mutationNotation.ts.html +1309 -0
- package/coverage/lcov-report/linter/printError.ts.html +9 -9
- package/coverage/lcov-report/linter/schema.ts.html +69 -69
- package/coverage/lcov-report/linter/segmentSchema.ts.html +3 -3
- package/coverage/lcov-report/linter/testSchema.ts.html +10 -10
- package/coverage/lcov-report/list/index.html +1 -1
- package/coverage/lcov-report/list/matrix.ts.html +1 -1
- package/coverage/lcov-report/parsers/index.html +1 -1
- package/coverage/lcov-report/parsers/index.ts.html +4 -4
- package/coverage/lcov-report/parsers/json.ts.html +2 -2
- package/coverage/lcov-report/parsers/yml.ts.html +6 -6
- package/coverage/lcov-report/tester/cliFormat.ts.html +1 -1
- package/coverage/lcov-report/tester/helpers.ts.html +1 -1
- package/coverage/lcov-report/tester/index.html +1 -1
- package/coverage/lcov-report/utils/git.ts.html +118 -16
- package/coverage/lcov-report/utils/index.html +14 -14
- package/coverage/lcov.info +2918 -1932
- package/lib/builder/buildDatafile.js +8 -3
- package/lib/builder/buildDatafile.js.map +1 -1
- package/lib/builder/mutateVariables.d.ts +14 -0
- package/lib/builder/mutateVariables.js +90 -0
- package/lib/builder/mutateVariables.js.map +1 -0
- package/lib/builder/mutateVariables.spec.d.ts +1 -0
- package/lib/builder/mutateVariables.spec.js +1045 -0
- package/lib/builder/mutateVariables.spec.js.map +1 -0
- package/lib/builder/mutator.d.ts +20 -0
- package/lib/builder/mutator.js +223 -0
- package/lib/builder/mutator.js.map +1 -0
- package/lib/builder/mutator.spec.d.ts +1 -0
- package/lib/builder/mutator.spec.js +368 -0
- package/lib/builder/mutator.spec.js.map +1 -0
- package/lib/config/projectConfig.d.ts +3 -0
- package/lib/config/projectConfig.js +7 -1
- package/lib/config/projectConfig.js.map +1 -1
- package/lib/config/projectConfig.spec.d.ts +1 -0
- package/lib/config/projectConfig.spec.js +24 -0
- package/lib/config/projectConfig.spec.js.map +1 -0
- package/lib/datasource/adapter.d.ts +7 -0
- package/lib/datasource/adapter.js +7 -0
- package/lib/datasource/adapter.js.map +1 -1
- package/lib/datasource/datasource.d.ts +3 -1
- package/lib/datasource/datasource.js +6 -0
- package/lib/datasource/datasource.js.map +1 -1
- package/lib/datasource/filesystemAdapter.d.ts +8 -0
- package/lib/datasource/filesystemAdapter.js +134 -7
- package/lib/datasource/filesystemAdapter.js.map +1 -1
- package/lib/datasource/filesystemAdapter.spec.d.ts +1 -0
- package/lib/datasource/filesystemAdapter.spec.js +88 -0
- package/lib/datasource/filesystemAdapter.spec.js.map +1 -0
- package/lib/linter/conditionSchema.spec.js +2 -0
- package/lib/linter/conditionSchema.spec.js.map +1 -1
- package/lib/linter/featureSchema.d.ts +7 -7
- package/lib/linter/featureSchema.js +176 -103
- package/lib/linter/featureSchema.js.map +1 -1
- package/lib/linter/featureSchema.spec.js +167 -0
- package/lib/linter/featureSchema.spec.js.map +1 -1
- package/lib/linter/lintProject.d.ts +2 -0
- package/lib/linter/lintProject.js +85 -8
- package/lib/linter/lintProject.js.map +1 -1
- package/lib/linter/lintProject.spec.js +37 -0
- package/lib/linter/lintProject.spec.js.map +1 -1
- package/lib/linter/mutationNotation.d.ts +47 -0
- package/lib/linter/mutationNotation.js +381 -0
- package/lib/linter/mutationNotation.js.map +1 -0
- package/lib/linter/mutationNotation.spec.d.ts +1 -0
- package/lib/linter/mutationNotation.spec.js +549 -0
- package/lib/linter/mutationNotation.spec.js.map +1 -0
- package/lib/linter/segmentSchema.spec.js +2 -0
- package/lib/linter/segmentSchema.spec.js.map +1 -1
- package/lib/utils/git.js +32 -7
- package/lib/utils/git.js.map +1 -1
- package/package.json +2 -2
- package/src/builder/buildDatafile.ts +29 -3
- package/src/builder/mutateVariables.spec.ts +1134 -0
- package/src/builder/mutateVariables.ts +105 -0
- package/src/builder/mutator.spec.ts +413 -0
- package/src/builder/mutator.ts +237 -0
- package/src/config/projectConfig.spec.ts +31 -0
- package/src/config/projectConfig.ts +11 -0
- package/src/datasource/adapter.ts +21 -0
- package/src/datasource/datasource.ts +18 -1
- package/src/datasource/filesystemAdapter.spec.ts +153 -0
- package/src/datasource/filesystemAdapter.ts +216 -8
- package/src/linter/conditionSchema.spec.ts +2 -0
- package/src/linter/featureSchema.spec.ts +203 -0
- package/src/linter/featureSchema.ts +320 -167
- package/src/linter/lintProject.spec.ts +74 -0
- package/src/linter/lintProject.ts +123 -8
- package/src/linter/mutationNotation.spec.ts +642 -0
- package/src/linter/mutationNotation.ts +408 -0
- package/src/linter/segmentSchema.spec.ts +2 -0
- package/src/utils/git.ts +41 -7
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { VariableSchema, VariableValue } from "@featurevisor/types";
|
|
2
|
+
|
|
3
|
+
export type PathPart =
|
|
4
|
+
| { key: string }
|
|
5
|
+
| { key: string; index: number }
|
|
6
|
+
| { key: string; selector: { prop: string; value: string } };
|
|
7
|
+
|
|
8
|
+
export type MutationOperation = "set" | "append" | "prepend" | "after" | "before" | "remove";
|
|
9
|
+
|
|
10
|
+
export interface ParsedNotation {
|
|
11
|
+
segments: PathPart[];
|
|
12
|
+
operation: MutationOperation;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseNotation(notation: string): ParsedNotation {
|
|
16
|
+
let rest = notation.trim();
|
|
17
|
+
const operationMatch = rest.match(/:((?:append|prepend|after|before|remove))$/);
|
|
18
|
+
const operation: MutationOperation = operationMatch
|
|
19
|
+
? (operationMatch[1] as MutationOperation)
|
|
20
|
+
: "set";
|
|
21
|
+
if (operationMatch) {
|
|
22
|
+
rest = rest.slice(0, -operationMatch[0].length);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const segments: PathPart[] = [];
|
|
26
|
+
let i = 0;
|
|
27
|
+
while (i < rest.length) {
|
|
28
|
+
let key = "";
|
|
29
|
+
while (i < rest.length && rest[i] !== "." && rest[i] !== "[") {
|
|
30
|
+
key += rest[i];
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
key = key.trim();
|
|
34
|
+
if (key) {
|
|
35
|
+
if (rest[i] === "[") {
|
|
36
|
+
i++;
|
|
37
|
+
const bracketStart = i;
|
|
38
|
+
while (i < rest.length && rest[i] !== "]") i++;
|
|
39
|
+
const bracketContent = rest.slice(bracketStart, i);
|
|
40
|
+
i++;
|
|
41
|
+
const eq = bracketContent.indexOf("=");
|
|
42
|
+
if (eq >= 0) {
|
|
43
|
+
const prop = bracketContent.slice(0, eq).trim();
|
|
44
|
+
let val = bracketContent.slice(eq + 1).trim();
|
|
45
|
+
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
|
46
|
+
else if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
|
|
47
|
+
segments.push({ key, selector: { prop, value: val } });
|
|
48
|
+
} else {
|
|
49
|
+
const index = parseInt(bracketContent.trim(), 10);
|
|
50
|
+
segments.push({ key, index });
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
segments.push({ key });
|
|
54
|
+
}
|
|
55
|
+
} else if (rest[i] === "[") {
|
|
56
|
+
i++;
|
|
57
|
+
const bracketStart = i;
|
|
58
|
+
while (i < rest.length && rest[i] !== "]") i++;
|
|
59
|
+
const bracketContent = rest.slice(bracketStart, i);
|
|
60
|
+
i++;
|
|
61
|
+
const eq = bracketContent.indexOf("=");
|
|
62
|
+
if (eq >= 0) {
|
|
63
|
+
const prop = bracketContent.slice(0, eq).trim();
|
|
64
|
+
let val = bracketContent.slice(eq + 1).trim();
|
|
65
|
+
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
|
66
|
+
else if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1);
|
|
67
|
+
segments.push({ key: "", selector: { prop, value: val } });
|
|
68
|
+
} else {
|
|
69
|
+
const index = parseInt(bracketContent.trim(), 10);
|
|
70
|
+
segments.push({ key: "", index });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (rest[i] === ".") i++;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { segments, operation };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getAtSegment(obj: VariableValue, seg: PathPart): VariableValue {
|
|
80
|
+
if (obj === null || obj === undefined) return undefined;
|
|
81
|
+
const o = obj as Record<string, unknown>;
|
|
82
|
+
if ("index" in seg) {
|
|
83
|
+
const arr =
|
|
84
|
+
seg.key === ""
|
|
85
|
+
? Array.isArray(obj)
|
|
86
|
+
? obj
|
|
87
|
+
: undefined
|
|
88
|
+
: Array.isArray(obj)
|
|
89
|
+
? obj
|
|
90
|
+
: (o[seg.key] as unknown[]);
|
|
91
|
+
if (!Array.isArray(arr)) return undefined;
|
|
92
|
+
return arr[seg.index] as VariableValue;
|
|
93
|
+
}
|
|
94
|
+
if ("selector" in seg) {
|
|
95
|
+
const arr =
|
|
96
|
+
seg.key === ""
|
|
97
|
+
? Array.isArray(obj)
|
|
98
|
+
? obj
|
|
99
|
+
: undefined
|
|
100
|
+
: Array.isArray(obj)
|
|
101
|
+
? obj
|
|
102
|
+
: (o[seg.key] as unknown[]);
|
|
103
|
+
if (!Array.isArray(arr)) return undefined;
|
|
104
|
+
const { prop, value } = seg.selector;
|
|
105
|
+
const found = arr.find((item) => {
|
|
106
|
+
if (item === null || typeof item !== "object") return false;
|
|
107
|
+
const v = (item as Record<string, unknown>)[prop];
|
|
108
|
+
return String(v) === value;
|
|
109
|
+
});
|
|
110
|
+
return found as VariableValue;
|
|
111
|
+
}
|
|
112
|
+
return o[seg.key] as VariableValue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function setAtSegment(
|
|
116
|
+
obj: Record<string, unknown> | unknown[],
|
|
117
|
+
seg: PathPart,
|
|
118
|
+
_value: VariableValue,
|
|
119
|
+
op: MutationOperation,
|
|
120
|
+
setValue: VariableValue | undefined,
|
|
121
|
+
): void {
|
|
122
|
+
if ("index" in seg) {
|
|
123
|
+
const i = seg.index;
|
|
124
|
+
const arr =
|
|
125
|
+
seg.key === ""
|
|
126
|
+
? Array.isArray(obj)
|
|
127
|
+
? obj
|
|
128
|
+
: undefined
|
|
129
|
+
: Array.isArray(obj)
|
|
130
|
+
? obj
|
|
131
|
+
: ((obj as Record<string, unknown>)[seg.key] as unknown[]);
|
|
132
|
+
if (!Array.isArray(arr)) return;
|
|
133
|
+
if (op === "remove") {
|
|
134
|
+
arr.splice(i, 1);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (op === "set") {
|
|
138
|
+
arr[i] = setValue;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if ("selector" in seg) {
|
|
144
|
+
const arr =
|
|
145
|
+
seg.key === ""
|
|
146
|
+
? Array.isArray(obj)
|
|
147
|
+
? obj
|
|
148
|
+
: undefined
|
|
149
|
+
: Array.isArray(obj)
|
|
150
|
+
? obj
|
|
151
|
+
: ((obj as Record<string, unknown>)[seg.key] as unknown[]);
|
|
152
|
+
if (!Array.isArray(arr)) return;
|
|
153
|
+
const { prop, value: selVal } = seg.selector;
|
|
154
|
+
const numVal = /^\d+$/.test(selVal) ? parseInt(selVal, 10) : null;
|
|
155
|
+
const idx = arr.findIndex((item) => {
|
|
156
|
+
if (item === null || typeof item !== "object") return false;
|
|
157
|
+
const v = (item as Record<string, unknown>)[prop];
|
|
158
|
+
return String(v) === selVal || (numVal !== null && v === numVal);
|
|
159
|
+
});
|
|
160
|
+
if (idx < 0) return;
|
|
161
|
+
if (op === "remove") {
|
|
162
|
+
arr.splice(idx, 1);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (op === "after" && setValue !== undefined) {
|
|
166
|
+
arr.splice(idx + 1, 0, setValue);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (op === "before" && setValue !== undefined) {
|
|
170
|
+
arr.splice(idx, 0, setValue);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const key = seg.key;
|
|
176
|
+
const o = obj as Record<string, unknown>;
|
|
177
|
+
if (op === "remove") {
|
|
178
|
+
delete o[key];
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (op === "append" && setValue !== undefined) {
|
|
182
|
+
const arr = (o[key] ?? []) as unknown[];
|
|
183
|
+
arr.push(setValue);
|
|
184
|
+
o[key] = arr;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (op === "prepend" && setValue !== undefined) {
|
|
188
|
+
const arr = (o[key] ?? []) as unknown[];
|
|
189
|
+
arr.unshift(setValue);
|
|
190
|
+
o[key] = arr;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (op === "set") {
|
|
194
|
+
o[key] = setValue as unknown;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function mutate(
|
|
199
|
+
_schema: VariableSchema,
|
|
200
|
+
value: VariableValue,
|
|
201
|
+
notation: string,
|
|
202
|
+
setValue: VariableValue | undefined,
|
|
203
|
+
): VariableValue {
|
|
204
|
+
if (value === null || value === undefined) return value;
|
|
205
|
+
const result = JSON.parse(JSON.stringify(value)) as VariableValue;
|
|
206
|
+
|
|
207
|
+
const { segments, operation } = parseNotation(notation);
|
|
208
|
+
if (segments.length === 0) {
|
|
209
|
+
if (Array.isArray(result) && setValue !== undefined) {
|
|
210
|
+
const arr = result as VariableValue[];
|
|
211
|
+
if (operation === "append") {
|
|
212
|
+
arr.push(setValue);
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
if (operation === "prepend") {
|
|
216
|
+
arr.unshift(setValue);
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const last = segments[segments.length - 1];
|
|
224
|
+
const parentSegments = segments.slice(0, -1);
|
|
225
|
+
|
|
226
|
+
let container: Record<string, unknown> | unknown[] = result as
|
|
227
|
+
| Record<string, unknown>
|
|
228
|
+
| unknown[];
|
|
229
|
+
for (const seg of parentSegments) {
|
|
230
|
+
const next = getAtSegment(container as VariableValue, seg);
|
|
231
|
+
if (next === undefined) return result;
|
|
232
|
+
container = next as Record<string, unknown> | unknown[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
setAtSegment(container, last, undefined, operation, setValue);
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
import { getProjectConfig, ENVIRONMENTS_DIRECTORY_NAME } from "./projectConfig";
|
|
6
|
+
|
|
7
|
+
function createTempProject(configBody: string) {
|
|
8
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-project-config-"));
|
|
9
|
+
fs.writeFileSync(path.join(tempRoot, "featurevisor.config.js"), configBody, "utf8");
|
|
10
|
+
return tempRoot;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("core: projectConfig", () => {
|
|
14
|
+
it("sets splitByEnvironment=false and environmentsDirectoryPath by default", () => {
|
|
15
|
+
const root = createTempProject("module.exports = {};");
|
|
16
|
+
const config = getProjectConfig(root);
|
|
17
|
+
|
|
18
|
+
expect(config.splitByEnvironment).toBe(false);
|
|
19
|
+
expect(config.environmentsDirectoryPath).toBe(path.join(root, ENVIRONMENTS_DIRECTORY_NAME));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("throws when splitByEnvironment=true and environments=false", () => {
|
|
23
|
+
const root = createTempProject(
|
|
24
|
+
"module.exports = { splitByEnvironment: true, environments: false };",
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
expect(() => getProjectConfig(root)).toThrow(
|
|
28
|
+
"Invalid configuration: splitByEnvironment=true requires environments to be an array",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -18,6 +18,7 @@ export const DATAFILES_DIRECTORY_NAME = "datafiles";
|
|
|
18
18
|
export const DATAFILE_NAME_PATTERN = "featurevisor-%s.json";
|
|
19
19
|
export const REVISION_FILE_NAME = "REVISION";
|
|
20
20
|
export const SITE_EXPORT_DIRECTORY_NAME = "out";
|
|
21
|
+
export const ENVIRONMENTS_DIRECTORY_NAME = "environments";
|
|
21
22
|
|
|
22
23
|
export const CONFIG_MODULE_NAME = "featurevisor.config.js";
|
|
23
24
|
export const ROOT_DIR_PLACEHOLDER = "<rootDir>";
|
|
@@ -52,8 +53,10 @@ export interface ProjectConfig {
|
|
|
52
53
|
datafileNamePattern: string;
|
|
53
54
|
revisionFileName: string;
|
|
54
55
|
siteExportDirectoryPath: string;
|
|
56
|
+
environmentsDirectoryPath: string;
|
|
55
57
|
|
|
56
58
|
environments: string[] | false;
|
|
59
|
+
splitByEnvironment: boolean;
|
|
57
60
|
tags: string[];
|
|
58
61
|
scopes?: Scope[];
|
|
59
62
|
|
|
@@ -91,6 +94,7 @@ export function getProjectConfig(rootDirectoryPath: string): ProjectConfig {
|
|
|
91
94
|
adapter: FilesystemAdapter,
|
|
92
95
|
|
|
93
96
|
featuresDirectoryPath: path.join(rootDirectoryPath, FEATURES_DIRECTORY_NAME),
|
|
97
|
+
environmentsDirectoryPath: path.join(rootDirectoryPath, ENVIRONMENTS_DIRECTORY_NAME),
|
|
94
98
|
segmentsDirectoryPath: path.join(rootDirectoryPath, SEGMENTS_DIRECTORY_NAME),
|
|
95
99
|
attributesDirectoryPath: path.join(rootDirectoryPath, ATTRIBUTES_DIRECTORY_NAME),
|
|
96
100
|
groupsDirectoryPath: path.join(rootDirectoryPath, GROUPS_DIRECTORY_NAME),
|
|
@@ -104,6 +108,7 @@ export function getProjectConfig(rootDirectoryPath: string): ProjectConfig {
|
|
|
104
108
|
|
|
105
109
|
enforceCatchAllRule: false,
|
|
106
110
|
plugins: [],
|
|
111
|
+
splitByEnvironment: false,
|
|
107
112
|
|
|
108
113
|
maxVariableStringLength: undefined,
|
|
109
114
|
maxVariableArrayStringifiedLength: undefined,
|
|
@@ -136,6 +141,12 @@ export function getProjectConfig(rootDirectoryPath: string): ProjectConfig {
|
|
|
136
141
|
finalConfig.parser = parsers[finalConfig.parser];
|
|
137
142
|
}
|
|
138
143
|
|
|
144
|
+
if (finalConfig.splitByEnvironment && finalConfig.environments === false) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
"Invalid configuration: splitByEnvironment=true requires environments to be an array",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
139
150
|
return finalConfig as ProjectConfig;
|
|
140
151
|
}
|
|
141
152
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
1
2
|
import type {
|
|
2
3
|
DatafileContent,
|
|
3
4
|
EnvironmentKey,
|
|
@@ -17,6 +18,13 @@ export interface DatafileOptions {
|
|
|
17
18
|
datafilesDir?: string;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
export type FeatureEnvironmentProperty = "rules" | "force" | "expose";
|
|
22
|
+
|
|
23
|
+
export interface FeatureSourcePaths {
|
|
24
|
+
baseFilePath: string;
|
|
25
|
+
environmentFilePaths: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
export abstract class Adapter {
|
|
21
29
|
// entities
|
|
22
30
|
abstract listEntities(entityType: EntityType): Promise<string[]>;
|
|
@@ -47,4 +55,17 @@ export abstract class Adapter {
|
|
|
47
55
|
entityType?: EntityType,
|
|
48
56
|
entityKey?: string,
|
|
49
57
|
): Promise<Commit>;
|
|
58
|
+
|
|
59
|
+
// feature source helpers (used by lint/history attribution)
|
|
60
|
+
async getFeatureSourcePaths(_featureKey: string): Promise<FeatureSourcePaths | undefined> {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async getFeaturePropertySourcePath(
|
|
65
|
+
_featureKey: string,
|
|
66
|
+
_property: FeatureEnvironmentProperty,
|
|
67
|
+
_environment?: string,
|
|
68
|
+
): Promise<string | undefined> {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
50
71
|
}
|
|
@@ -18,7 +18,12 @@ import type {
|
|
|
18
18
|
import { ProjectConfig } from "../config";
|
|
19
19
|
import { CustomParser } from "../parsers";
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
Adapter,
|
|
23
|
+
DatafileOptions,
|
|
24
|
+
FeatureEnvironmentProperty,
|
|
25
|
+
FeatureSourcePaths,
|
|
26
|
+
} from "./adapter";
|
|
22
27
|
|
|
23
28
|
export class Datasource {
|
|
24
29
|
private adapter: Adapter;
|
|
@@ -93,6 +98,18 @@ export class Datasource {
|
|
|
93
98
|
return this.adapter.deleteEntity("feature", featureKey);
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
getFeatureSourcePaths(featureKey: FeatureKey): Promise<FeatureSourcePaths | undefined> {
|
|
102
|
+
return this.adapter.getFeatureSourcePaths(featureKey);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getFeaturePropertySourcePath(
|
|
106
|
+
featureKey: FeatureKey,
|
|
107
|
+
property: FeatureEnvironmentProperty,
|
|
108
|
+
environment?: string,
|
|
109
|
+
): Promise<string | undefined> {
|
|
110
|
+
return this.adapter.getFeaturePropertySourcePath(featureKey, property, environment);
|
|
111
|
+
}
|
|
112
|
+
|
|
96
113
|
async getRequiredFeaturesChain(
|
|
97
114
|
featureKey: FeatureKey,
|
|
98
115
|
chain = new Set<FeatureKey>(),
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
import { getProjectConfig } from "../config";
|
|
6
|
+
import { Datasource } from "./datasource";
|
|
7
|
+
|
|
8
|
+
function writeFile(filePath: string, content: string) {
|
|
9
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
10
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createSplitProject(configBody: string) {
|
|
14
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-fs-adapter-"));
|
|
15
|
+
writeFile(path.join(root, "featurevisor.config.js"), configBody);
|
|
16
|
+
|
|
17
|
+
return root;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("core: filesystemAdapter (splitByEnvironment)", () => {
|
|
21
|
+
it("merges environment files into readFeature output", async () => {
|
|
22
|
+
const root = createSplitProject(
|
|
23
|
+
"module.exports = { environments: ['staging', 'production'], splitByEnvironment: true };",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
writeFile(
|
|
27
|
+
path.join(root, "features", "checkout.yml"),
|
|
28
|
+
[
|
|
29
|
+
"key: checkout",
|
|
30
|
+
"description: Checkout feature",
|
|
31
|
+
"tags:",
|
|
32
|
+
" - all",
|
|
33
|
+
"bucketBy: userId",
|
|
34
|
+
].join("\n"),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
writeFile(
|
|
38
|
+
path.join(root, "environments", "staging", "checkout.yml"),
|
|
39
|
+
["rules:", " - key: everyone", " segments: '*'", " percentage: 100"].join("\n"),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
writeFile(
|
|
43
|
+
path.join(root, "environments", "production", "checkout.yml"),
|
|
44
|
+
[
|
|
45
|
+
"rules:",
|
|
46
|
+
" - key: everyone",
|
|
47
|
+
" segments: '*'",
|
|
48
|
+
" percentage: 0",
|
|
49
|
+
"expose: false",
|
|
50
|
+
].join("\n"),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const config = getProjectConfig(root);
|
|
54
|
+
const datasource = new Datasource(config, root);
|
|
55
|
+
const feature = await datasource.readFeature("checkout");
|
|
56
|
+
const rulesByEnvironment = feature.rules as Record<string, any[]>;
|
|
57
|
+
const exposeByEnvironment = feature.expose as Record<string, unknown>;
|
|
58
|
+
|
|
59
|
+
expect(rulesByEnvironment?.staging?.[0]?.percentage).toBe(100);
|
|
60
|
+
expect(rulesByEnvironment?.production?.[0]?.percentage).toBe(0);
|
|
61
|
+
expect(exposeByEnvironment?.production).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("throws when any environment feature file is missing", async () => {
|
|
65
|
+
const root = createSplitProject(
|
|
66
|
+
"module.exports = { environments: ['staging', 'production'], splitByEnvironment: true };",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
writeFile(
|
|
70
|
+
path.join(root, "features", "myFeature.yml"),
|
|
71
|
+
["key: myFeature", "description: My feature", "tags:", " - all", "bucketBy: userId"].join(
|
|
72
|
+
"\n",
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
writeFile(
|
|
77
|
+
path.join(root, "environments", "staging", "myFeature.yml"),
|
|
78
|
+
["rules:", " - key: everyone", " segments: '*'", " percentage: 100"].join("\n"),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const config = getProjectConfig(root);
|
|
82
|
+
const datasource = new Datasource(config, root);
|
|
83
|
+
|
|
84
|
+
await expect(datasource.readFeature("myFeature")).rejects.toMatchObject({
|
|
85
|
+
featurevisorFilePath: expect.stringContaining(
|
|
86
|
+
path.join("environments", "production", "myFeature.yml"),
|
|
87
|
+
),
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("throws when base feature file still defines rules in split mode", async () => {
|
|
92
|
+
const root = createSplitProject(
|
|
93
|
+
"module.exports = { environments: ['staging'], splitByEnvironment: true };",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
writeFile(
|
|
97
|
+
path.join(root, "features", "foo.yml"),
|
|
98
|
+
[
|
|
99
|
+
"key: foo",
|
|
100
|
+
"description: Foo",
|
|
101
|
+
"tags:",
|
|
102
|
+
" - all",
|
|
103
|
+
"bucketBy: userId",
|
|
104
|
+
"rules:",
|
|
105
|
+
" staging:",
|
|
106
|
+
" - key: everyone",
|
|
107
|
+
" segments: '*'",
|
|
108
|
+
" percentage: 100",
|
|
109
|
+
].join("\n"),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
writeFile(
|
|
113
|
+
path.join(root, "environments", "staging", "foo.yml"),
|
|
114
|
+
["rules:", " - key: everyone", " segments: '*'", " percentage: 100"].join("\n"),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const config = getProjectConfig(root);
|
|
118
|
+
const datasource = new Datasource(config, root);
|
|
119
|
+
|
|
120
|
+
await expect(datasource.readFeature("foo")).rejects.toThrow(
|
|
121
|
+
"base file must not define rules, force, or expose when splitByEnvironment=true",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("throws when environment file has unknown keys", async () => {
|
|
126
|
+
const root = createSplitProject(
|
|
127
|
+
"module.exports = { environments: ['staging'], splitByEnvironment: true };",
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
writeFile(
|
|
131
|
+
path.join(root, "features", "foo.yml"),
|
|
132
|
+
["key: foo", "description: Foo", "tags:", " - all", "bucketBy: userId"].join("\n"),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
writeFile(
|
|
136
|
+
path.join(root, "environments", "staging", "foo.yml"),
|
|
137
|
+
[
|
|
138
|
+
"rules:",
|
|
139
|
+
" - key: everyone",
|
|
140
|
+
" segments: '*'",
|
|
141
|
+
" percentage: 100",
|
|
142
|
+
"extraKey: not allowed",
|
|
143
|
+
].join("\n"),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const config = getProjectConfig(root);
|
|
147
|
+
const datasource = new Datasource(config, root);
|
|
148
|
+
|
|
149
|
+
await expect(datasource.readFeature("foo")).rejects.toThrow(
|
|
150
|
+
"Unknown key(s) in environment feature file: extraKey",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
});
|