@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
|
@@ -11,6 +11,10 @@ import type {
|
|
|
11
11
|
Commit,
|
|
12
12
|
CommitHash,
|
|
13
13
|
HistoryEntity,
|
|
14
|
+
ParsedFeature,
|
|
15
|
+
Expose,
|
|
16
|
+
Force,
|
|
17
|
+
Rule,
|
|
14
18
|
} from "@featurevisor/types";
|
|
15
19
|
|
|
16
20
|
import { Adapter, DatafileOptions } from "./adapter";
|
|
@@ -18,6 +22,18 @@ import { ProjectConfig } from "../config";
|
|
|
18
22
|
import { CustomParser } from "../parsers";
|
|
19
23
|
import { getCommit } from "../utils/git";
|
|
20
24
|
|
|
25
|
+
const FEATURE_ENVIRONMENT_ALLOWED_KEYS = ["rules", "force", "expose"];
|
|
26
|
+
|
|
27
|
+
class FeatureSplitConfigError extends Error {
|
|
28
|
+
featurevisorFilePath: string;
|
|
29
|
+
|
|
30
|
+
constructor(filePath: string, message: string) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.featurevisorFilePath = filePath;
|
|
33
|
+
this.name = "FeatureSplitConfigError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
export function getExistingStateFilePath(
|
|
22
38
|
projectConfig: ProjectConfig,
|
|
23
39
|
environment: EnvironmentKey | false,
|
|
@@ -54,6 +70,12 @@ export function getAllEntityFilePathsRecursively(directoryPath, extension) {
|
|
|
54
70
|
return entities;
|
|
55
71
|
}
|
|
56
72
|
|
|
73
|
+
function isWithinDirectory(directoryPath: string, fileDirectoryPath: string): boolean {
|
|
74
|
+
return (
|
|
75
|
+
fileDirectoryPath === directoryPath || fileDirectoryPath.startsWith(directoryPath + path.sep)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
export class FilesystemAdapter extends Adapter {
|
|
58
80
|
private parser: CustomParser;
|
|
59
81
|
|
|
@@ -91,6 +113,57 @@ export class FilesystemAdapter extends Adapter {
|
|
|
91
113
|
return path.join(basePath, `${relativeEntityPath}.${this.parser.extension}`);
|
|
92
114
|
}
|
|
93
115
|
|
|
116
|
+
getFeatureEnvironmentPath(featureKey: string, environment: string): string {
|
|
117
|
+
const relativeEntityPath = featureKey.replace(/\//g, path.sep);
|
|
118
|
+
|
|
119
|
+
return path.join(
|
|
120
|
+
this.config.environmentsDirectoryPath,
|
|
121
|
+
environment,
|
|
122
|
+
`${relativeEntityPath}.${this.parser.extension}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private assertSplitByEnvironmentIsValidFeatureBase(
|
|
127
|
+
baseFeature: ParsedFeature,
|
|
128
|
+
featurePath: string,
|
|
129
|
+
) {
|
|
130
|
+
if (
|
|
131
|
+
typeof baseFeature.rules !== "undefined" ||
|
|
132
|
+
typeof baseFeature.force !== "undefined" ||
|
|
133
|
+
typeof baseFeature.expose !== "undefined"
|
|
134
|
+
) {
|
|
135
|
+
throw new FeatureSplitConfigError(
|
|
136
|
+
featurePath,
|
|
137
|
+
`Feature "${baseFeature.key}" base file must not define rules, force, or expose when splitByEnvironment=true`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private readFeatureEnvironmentEntity(featureKey: string, environment: string) {
|
|
143
|
+
const featureEnvironmentPath = this.getFeatureEnvironmentPath(featureKey, environment);
|
|
144
|
+
|
|
145
|
+
if (!fs.existsSync(featureEnvironmentPath)) {
|
|
146
|
+
throw new FeatureSplitConfigError(
|
|
147
|
+
featureEnvironmentPath,
|
|
148
|
+
`Missing environment feature file: environments/${environment}/${featureKey}.${this.parser.extension}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const content = fs.readFileSync(featureEnvironmentPath, "utf8");
|
|
153
|
+
const parsed = this.parser.parse<Record<string, unknown>>(content, featureEnvironmentPath);
|
|
154
|
+
const keys = Object.keys(parsed);
|
|
155
|
+
const unknownKeys = keys.filter((key) => FEATURE_ENVIRONMENT_ALLOWED_KEYS.indexOf(key) === -1);
|
|
156
|
+
|
|
157
|
+
if (unknownKeys.length > 0) {
|
|
158
|
+
throw new FeatureSplitConfigError(
|
|
159
|
+
featureEnvironmentPath,
|
|
160
|
+
`Unknown key(s) in environment feature file: ${unknownKeys.join(", ")}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return parsed;
|
|
165
|
+
}
|
|
166
|
+
|
|
94
167
|
async listEntities(entityType: EntityType): Promise<string[]> {
|
|
95
168
|
const directoryPath = this.getEntityDirectoryPath(entityType);
|
|
96
169
|
const filePaths = getAllEntityFilePathsRecursively(directoryPath, this.parser.extension);
|
|
@@ -118,6 +191,50 @@ export class FilesystemAdapter extends Adapter {
|
|
|
118
191
|
}
|
|
119
192
|
|
|
120
193
|
async readEntity<T>(entityType: EntityType, entityKey: string): Promise<T> {
|
|
194
|
+
if (entityType === "feature" && this.config.splitByEnvironment) {
|
|
195
|
+
const featurePath = this.getEntityPath(entityType, entityKey);
|
|
196
|
+
const featureContent = fs.readFileSync(featurePath, "utf8");
|
|
197
|
+
const baseFeature = this.parser.parse<ParsedFeature>(featureContent, featurePath);
|
|
198
|
+
|
|
199
|
+
this.assertSplitByEnvironmentIsValidFeatureBase(baseFeature, featurePath);
|
|
200
|
+
|
|
201
|
+
if (!Array.isArray(this.config.environments)) {
|
|
202
|
+
throw new FeatureSplitConfigError(
|
|
203
|
+
featurePath,
|
|
204
|
+
"splitByEnvironment=true requires environments to be configured as an array",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const rulesByEnvironment: Record<string, Rule[]> = {};
|
|
209
|
+
const forceByEnvironment: Record<string, Force[]> = {};
|
|
210
|
+
const exposeByEnvironment: Record<string, Expose> = {};
|
|
211
|
+
|
|
212
|
+
for (const environment of this.config.environments) {
|
|
213
|
+
const envFeature = this.readFeatureEnvironmentEntity(entityKey, environment);
|
|
214
|
+
|
|
215
|
+
if (typeof envFeature.rules !== "undefined") {
|
|
216
|
+
rulesByEnvironment[environment] = envFeature.rules as Rule[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (typeof envFeature.force !== "undefined") {
|
|
220
|
+
forceByEnvironment[environment] = envFeature.force as Force[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (typeof envFeature.expose !== "undefined") {
|
|
224
|
+
exposeByEnvironment[environment] = envFeature.expose as Expose;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const mergedFeature: ParsedFeature = {
|
|
229
|
+
...baseFeature,
|
|
230
|
+
rules: Object.keys(rulesByEnvironment).length > 0 ? rulesByEnvironment : undefined,
|
|
231
|
+
force: Object.keys(forceByEnvironment).length > 0 ? forceByEnvironment : undefined,
|
|
232
|
+
expose: Object.keys(exposeByEnvironment).length > 0 ? exposeByEnvironment : undefined,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return mergedFeature as T;
|
|
236
|
+
}
|
|
237
|
+
|
|
121
238
|
const filePath = this.getEntityPath(entityType, entityKey);
|
|
122
239
|
const entityContent = fs.readFileSync(filePath, "utf8");
|
|
123
240
|
|
|
@@ -306,6 +423,16 @@ export class FilesystemAdapter extends Adapter {
|
|
|
306
423
|
|
|
307
424
|
if (entityType && entityKey) {
|
|
308
425
|
pathPatterns = [this.getEntityPath(entityType, entityKey)];
|
|
426
|
+
|
|
427
|
+
if (
|
|
428
|
+
entityType === "feature" &&
|
|
429
|
+
this.config.splitByEnvironment &&
|
|
430
|
+
Array.isArray(this.config.environments)
|
|
431
|
+
) {
|
|
432
|
+
for (const environment of this.config.environments) {
|
|
433
|
+
pathPatterns.push(this.getFeatureEnvironmentPath(entityKey, environment));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
309
436
|
} else if (entityType) {
|
|
310
437
|
if (entityType === "attribute") {
|
|
311
438
|
pathPatterns = [this.config.attributesDirectoryPath];
|
|
@@ -313,6 +440,9 @@ export class FilesystemAdapter extends Adapter {
|
|
|
313
440
|
pathPatterns = [this.config.segmentsDirectoryPath];
|
|
314
441
|
} else if (entityType === "feature") {
|
|
315
442
|
pathPatterns = [this.config.featuresDirectoryPath];
|
|
443
|
+
if (this.config.splitByEnvironment) {
|
|
444
|
+
pathPatterns.push(this.config.environmentsDirectoryPath);
|
|
445
|
+
}
|
|
316
446
|
} else if (entityType === "group") {
|
|
317
447
|
pathPatterns = [this.config.groupsDirectoryPath];
|
|
318
448
|
} else if (entityType === "schema") {
|
|
@@ -323,6 +453,7 @@ export class FilesystemAdapter extends Adapter {
|
|
|
323
453
|
} else {
|
|
324
454
|
pathPatterns = [
|
|
325
455
|
this.config.featuresDirectoryPath,
|
|
456
|
+
...(this.config.splitByEnvironment ? [this.config.environmentsDirectoryPath] : []),
|
|
326
457
|
this.config.attributesDirectoryPath,
|
|
327
458
|
this.config.segmentsDirectoryPath,
|
|
328
459
|
this.config.groupsDirectoryPath,
|
|
@@ -334,6 +465,34 @@ export class FilesystemAdapter extends Adapter {
|
|
|
334
465
|
return pathPatterns.map((p) => p.replace((this.rootDirectoryPath as string) + path.sep, ""));
|
|
335
466
|
}
|
|
336
467
|
|
|
468
|
+
async getFeatureSourcePaths(featureKey: string) {
|
|
469
|
+
const baseFilePath = this.getEntityPath("feature", featureKey);
|
|
470
|
+
const environmentFilePaths: Record<string, string> = {};
|
|
471
|
+
|
|
472
|
+
if (this.config.splitByEnvironment && Array.isArray(this.config.environments)) {
|
|
473
|
+
for (const environment of this.config.environments) {
|
|
474
|
+
environmentFilePaths[environment] = this.getFeatureEnvironmentPath(featureKey, environment);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
baseFilePath,
|
|
480
|
+
environmentFilePaths,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async getFeaturePropertySourcePath(
|
|
485
|
+
featureKey: string,
|
|
486
|
+
_property: "rules" | "force" | "expose",
|
|
487
|
+
environment?: string,
|
|
488
|
+
) {
|
|
489
|
+
if (this.config.splitByEnvironment && environment) {
|
|
490
|
+
return this.getFeatureEnvironmentPath(featureKey, environment);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return this.getEntityPath("feature", featureKey);
|
|
494
|
+
}
|
|
495
|
+
|
|
337
496
|
async listHistoryEntries(entityType?: EntityType, entityKey?: string): Promise<HistoryEntry[]> {
|
|
338
497
|
const pathPatterns = this.getPathPatterns(entityType, entityKey);
|
|
339
498
|
const rawHistory = await this.getRawHistory(pathPatterns);
|
|
@@ -361,26 +520,75 @@ export class FilesystemAdapter extends Adapter {
|
|
|
361
520
|
const absolutePath = path.join(this.rootDirectoryPath as string, relativePath);
|
|
362
521
|
const fileName = absolutePath.split(path.sep).pop() as string;
|
|
363
522
|
const relativeDir = path.dirname(absolutePath);
|
|
364
|
-
|
|
365
|
-
const key = fileName.replace(
|
|
523
|
+
const extensionWithDot = "." + this.parser.extension;
|
|
524
|
+
const key = fileName.replace(extensionWithDot, "");
|
|
366
525
|
|
|
367
526
|
let type: EntityType = "attribute";
|
|
368
|
-
if (
|
|
527
|
+
if (isWithinDirectory(this.config.attributesDirectoryPath, relativeDir)) {
|
|
369
528
|
type = "attribute";
|
|
370
|
-
} else if (
|
|
529
|
+
} else if (isWithinDirectory(this.config.segmentsDirectoryPath, relativeDir)) {
|
|
371
530
|
type = "segment";
|
|
372
|
-
} else if (
|
|
531
|
+
} else if (isWithinDirectory(this.config.featuresDirectoryPath, relativeDir)) {
|
|
373
532
|
type = "feature";
|
|
374
|
-
} else if (
|
|
533
|
+
} else if (
|
|
534
|
+
this.config.splitByEnvironment &&
|
|
535
|
+
isWithinDirectory(this.config.environmentsDirectoryPath, relativeDir)
|
|
536
|
+
) {
|
|
537
|
+
type = "feature";
|
|
538
|
+
} else if (isWithinDirectory(this.config.groupsDirectoryPath, relativeDir)) {
|
|
375
539
|
type = "group";
|
|
376
|
-
} else if (
|
|
540
|
+
} else if (isWithinDirectory(this.config.schemasDirectoryPath, relativeDir)) {
|
|
377
541
|
type = "schema";
|
|
378
|
-
} else if (
|
|
542
|
+
} else if (isWithinDirectory(this.config.testsDirectoryPath, relativeDir)) {
|
|
379
543
|
type = "test";
|
|
380
544
|
} else {
|
|
381
545
|
continue;
|
|
382
546
|
}
|
|
383
547
|
|
|
548
|
+
if (type === "feature") {
|
|
549
|
+
if (
|
|
550
|
+
this.config.splitByEnvironment &&
|
|
551
|
+
relativePath.startsWith(
|
|
552
|
+
this.config.environmentsDirectoryPath.replace(
|
|
553
|
+
(this.rootDirectoryPath as string) + path.sep,
|
|
554
|
+
"",
|
|
555
|
+
) + path.sep,
|
|
556
|
+
)
|
|
557
|
+
) {
|
|
558
|
+
const featureRelativePath = relativePath
|
|
559
|
+
.replace(
|
|
560
|
+
this.config.environmentsDirectoryPath.replace(
|
|
561
|
+
(this.rootDirectoryPath as string) + path.sep,
|
|
562
|
+
"",
|
|
563
|
+
) + path.sep,
|
|
564
|
+
"",
|
|
565
|
+
)
|
|
566
|
+
.split(path.sep)
|
|
567
|
+
.slice(1)
|
|
568
|
+
.join(path.sep)
|
|
569
|
+
.replace(extensionWithDot, "");
|
|
570
|
+
|
|
571
|
+
entities.push({
|
|
572
|
+
type,
|
|
573
|
+
key: featureRelativePath.replace(/\\/g, "/"),
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const baseRelativePath = absolutePath
|
|
580
|
+
.replace(this.config.featuresDirectoryPath + path.sep, "")
|
|
581
|
+
.replace(extensionWithDot, "")
|
|
582
|
+
.replace(/\\/g, "/");
|
|
583
|
+
|
|
584
|
+
entities.push({
|
|
585
|
+
type,
|
|
586
|
+
key: baseRelativePath,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
|
|
384
592
|
entities.push({
|
|
385
593
|
type,
|
|
386
594
|
key,
|
|
@@ -21,7 +21,9 @@ function minimalProjectConfig(): ProjectConfig {
|
|
|
21
21
|
datafileNamePattern: "",
|
|
22
22
|
revisionFileName: "",
|
|
23
23
|
siteExportDirectoryPath: "",
|
|
24
|
+
environmentsDirectoryPath: "",
|
|
24
25
|
environments: ["staging", "production"],
|
|
26
|
+
splitByEnvironment: false,
|
|
25
27
|
tags: ["all"],
|
|
26
28
|
adapter: {},
|
|
27
29
|
plugins: [],
|
|
@@ -25,7 +25,9 @@ function minimalProjectConfig(overrides: Partial<ProjectConfig> = {}): ProjectCo
|
|
|
25
25
|
datafileNamePattern: "",
|
|
26
26
|
revisionFileName: "",
|
|
27
27
|
siteExportDirectoryPath: "",
|
|
28
|
+
environmentsDirectoryPath: "",
|
|
28
29
|
environments: ["staging", "production"],
|
|
30
|
+
splitByEnvironment: false,
|
|
29
31
|
tags: ["all"],
|
|
30
32
|
adapter: {},
|
|
31
33
|
plugins: [],
|
|
@@ -166,6 +168,38 @@ describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable
|
|
|
166
168
|
);
|
|
167
169
|
});
|
|
168
170
|
|
|
171
|
+
it("rejects variable with schema reference when schema could not be loaded (missing from schemasByKey)", () => {
|
|
172
|
+
const projectConfig = minimalProjectConfig();
|
|
173
|
+
const conditionsZodSchema = getConditionsZodSchema(projectConfig, TEST_ATTRIBUTES);
|
|
174
|
+
const schemaWithEmptySchemasByKey = getFeatureZodSchema(
|
|
175
|
+
projectConfig,
|
|
176
|
+
conditionsZodSchema,
|
|
177
|
+
TEST_ATTRIBUTES,
|
|
178
|
+
TEST_SEGMENTS,
|
|
179
|
+
TEST_FEATURES,
|
|
180
|
+
["link"],
|
|
181
|
+
{},
|
|
182
|
+
);
|
|
183
|
+
const result = schemaWithEmptySchemasByKey.safeParse(
|
|
184
|
+
baseFeature({
|
|
185
|
+
variablesSchema: {
|
|
186
|
+
myLink: {
|
|
187
|
+
schema: "link",
|
|
188
|
+
defaultValue: { title: "Home", url: "/" },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
expect(result.success).toBe(false);
|
|
194
|
+
if (result.success) return;
|
|
195
|
+
const messages = (result as z.SafeParseError<unknown>).error.issues
|
|
196
|
+
.map((i) => (typeof i.message === "string" ? i.message : ""))
|
|
197
|
+
.join(" ");
|
|
198
|
+
expect(messages).toContain("could not be loaded");
|
|
199
|
+
expect(messages).toContain("link");
|
|
200
|
+
expect(messages).toContain("myLink");
|
|
201
|
+
});
|
|
202
|
+
|
|
169
203
|
it("rejects variable with schema reference when defaultValue does not match schema", () => {
|
|
170
204
|
expectParseFailure(
|
|
171
205
|
baseFeature({
|
|
@@ -646,6 +680,25 @@ describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable
|
|
|
646
680
|
"not defined in",
|
|
647
681
|
);
|
|
648
682
|
});
|
|
683
|
+
|
|
684
|
+
it("rejects rule variable key when feature has no variablesSchema", () => {
|
|
685
|
+
expectParseFailure(
|
|
686
|
+
baseFeature({
|
|
687
|
+
rules: {
|
|
688
|
+
staging: [{ key: "r1", segments: "*", percentage: 100 }],
|
|
689
|
+
production: [
|
|
690
|
+
{
|
|
691
|
+
key: "r1",
|
|
692
|
+
segments: "*",
|
|
693
|
+
percentage: 80,
|
|
694
|
+
variables: { blah123: "some value" },
|
|
695
|
+
},
|
|
696
|
+
],
|
|
697
|
+
},
|
|
698
|
+
}),
|
|
699
|
+
"not defined in",
|
|
700
|
+
);
|
|
701
|
+
});
|
|
649
702
|
});
|
|
650
703
|
|
|
651
704
|
describe("force: variables", () => {
|
|
@@ -1215,4 +1268,154 @@ describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable
|
|
|
1215
1268
|
);
|
|
1216
1269
|
});
|
|
1217
1270
|
});
|
|
1271
|
+
|
|
1272
|
+
describe("mutation notation in variables", () => {
|
|
1273
|
+
it("accepts valid dot-notation mutation keys in rules.variables", () => {
|
|
1274
|
+
expectParseSuccess(
|
|
1275
|
+
baseFeature({
|
|
1276
|
+
variablesSchema: {
|
|
1277
|
+
config: {
|
|
1278
|
+
type: "object",
|
|
1279
|
+
properties: {
|
|
1280
|
+
theme: { type: "string" },
|
|
1281
|
+
width: { type: "integer" },
|
|
1282
|
+
},
|
|
1283
|
+
defaultValue: { theme: "light", width: 100 },
|
|
1284
|
+
},
|
|
1285
|
+
},
|
|
1286
|
+
rules: {
|
|
1287
|
+
staging: [
|
|
1288
|
+
{
|
|
1289
|
+
key: "r1",
|
|
1290
|
+
segments: "*",
|
|
1291
|
+
percentage: 100,
|
|
1292
|
+
variables: { "config.theme": "dark", "config.width": 1200 },
|
|
1293
|
+
},
|
|
1294
|
+
],
|
|
1295
|
+
production: [{ key: "r1", segments: "*", percentage: 100 }],
|
|
1296
|
+
},
|
|
1297
|
+
}),
|
|
1298
|
+
);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
it("accepts valid array-index mutation keys in rules.variables", () => {
|
|
1302
|
+
expectParseSuccess(
|
|
1303
|
+
baseFeature({
|
|
1304
|
+
variablesSchema: {
|
|
1305
|
+
tags: {
|
|
1306
|
+
type: "array",
|
|
1307
|
+
items: { type: "string" },
|
|
1308
|
+
defaultValue: ["a", "b"],
|
|
1309
|
+
},
|
|
1310
|
+
},
|
|
1311
|
+
rules: {
|
|
1312
|
+
staging: [
|
|
1313
|
+
{ key: "r1", segments: "*", percentage: 100, variables: { "tags[0]": "first" } },
|
|
1314
|
+
],
|
|
1315
|
+
production: [{ key: "r1", segments: "*", percentage: 100 }],
|
|
1316
|
+
},
|
|
1317
|
+
}),
|
|
1318
|
+
);
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
it("rejects mutation key when root variable is not in variablesSchema", () => {
|
|
1322
|
+
expectErrorSurfaces(
|
|
1323
|
+
baseFeature({
|
|
1324
|
+
variablesSchema: {
|
|
1325
|
+
title: { type: "string", defaultValue: "Default" },
|
|
1326
|
+
},
|
|
1327
|
+
rules: {
|
|
1328
|
+
staging: [
|
|
1329
|
+
{ key: "r1", segments: "*", percentage: 100, variables: { "unknownVar.foo": "x" } },
|
|
1330
|
+
],
|
|
1331
|
+
production: [{ key: "r1", segments: "*", percentage: 100 }],
|
|
1332
|
+
},
|
|
1333
|
+
}),
|
|
1334
|
+
{
|
|
1335
|
+
pathContains: ["rules", "variables", "unknownVar.foo"],
|
|
1336
|
+
messageContains: "not defined in",
|
|
1337
|
+
},
|
|
1338
|
+
);
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
it("rejects invalid path (property not in schema)", () => {
|
|
1342
|
+
expectErrorSurfaces(
|
|
1343
|
+
baseFeature({
|
|
1344
|
+
variablesSchema: {
|
|
1345
|
+
config: {
|
|
1346
|
+
type: "object",
|
|
1347
|
+
properties: { theme: { type: "string" } },
|
|
1348
|
+
defaultValue: { theme: "light" },
|
|
1349
|
+
},
|
|
1350
|
+
},
|
|
1351
|
+
rules: {
|
|
1352
|
+
staging: [
|
|
1353
|
+
{
|
|
1354
|
+
key: "r1",
|
|
1355
|
+
segments: "*",
|
|
1356
|
+
percentage: 100,
|
|
1357
|
+
variables: { "config.unknownProp": "x" },
|
|
1358
|
+
},
|
|
1359
|
+
],
|
|
1360
|
+
production: [{ key: "r1", segments: "*", percentage: 100 }],
|
|
1361
|
+
},
|
|
1362
|
+
}),
|
|
1363
|
+
{ pathContains: ["rules", "variables"], messageContains: "path does not exist" },
|
|
1364
|
+
);
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
it("rejects :append on non-array (object property)", () => {
|
|
1368
|
+
expectErrorSurfaces(
|
|
1369
|
+
baseFeature({
|
|
1370
|
+
variablesSchema: {
|
|
1371
|
+
config: {
|
|
1372
|
+
type: "object",
|
|
1373
|
+
properties: { theme: { type: "string" } },
|
|
1374
|
+
defaultValue: { theme: "light" },
|
|
1375
|
+
},
|
|
1376
|
+
},
|
|
1377
|
+
rules: {
|
|
1378
|
+
staging: [
|
|
1379
|
+
{ key: "r1", segments: "*", percentage: 100, variables: { "config:append": "x" } },
|
|
1380
|
+
],
|
|
1381
|
+
production: [{ key: "r1", segments: "*", percentage: 100 }],
|
|
1382
|
+
},
|
|
1383
|
+
}),
|
|
1384
|
+
{ pathContains: ["rules", "variables"], messageContains: "only allowed on array" },
|
|
1385
|
+
);
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
it("rejects :remove on required object property", () => {
|
|
1389
|
+
expectErrorSurfaces(
|
|
1390
|
+
baseFeature({
|
|
1391
|
+
variablesSchema: {
|
|
1392
|
+
config: {
|
|
1393
|
+
type: "object",
|
|
1394
|
+
properties: {
|
|
1395
|
+
theme: { type: "string" },
|
|
1396
|
+
compact: { type: "boolean" },
|
|
1397
|
+
},
|
|
1398
|
+
required: ["compact"],
|
|
1399
|
+
defaultValue: { theme: "light", compact: true },
|
|
1400
|
+
},
|
|
1401
|
+
},
|
|
1402
|
+
rules: {
|
|
1403
|
+
staging: [
|
|
1404
|
+
{
|
|
1405
|
+
key: "r1",
|
|
1406
|
+
segments: "*",
|
|
1407
|
+
percentage: 100,
|
|
1408
|
+
variables: { "config.compact:remove": null },
|
|
1409
|
+
},
|
|
1410
|
+
],
|
|
1411
|
+
production: [{ key: "r1", segments: "*", percentage: 100 }],
|
|
1412
|
+
},
|
|
1413
|
+
}),
|
|
1414
|
+
{
|
|
1415
|
+
pathContains: ["rules", "variables"],
|
|
1416
|
+
messageContains: "Cannot remove required property",
|
|
1417
|
+
},
|
|
1418
|
+
);
|
|
1419
|
+
});
|
|
1420
|
+
});
|
|
1218
1421
|
});
|