@featurevisor/core 2.20.0 → 2.22.1
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 +19 -0
- package/coverage/clover.xml +1286 -515
- package/coverage/coverage-final.json +17 -16
- package/coverage/lcov-report/builder/allocator.ts.html +1 -1
- package/coverage/lcov-report/builder/buildDatafile.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/convertToV1.ts.html +1 -1
- package/coverage/lcov-report/builder/getFeatureRanges.ts.html +1 -1
- package/coverage/lcov-report/builder/hashes.ts.html +1 -1
- package/coverage/lcov-report/builder/index.html +1 -1
- package/coverage/lcov-report/builder/mutateVariables.ts.html +1 -1
- package/coverage/lcov-report/builder/mutator.ts.html +1 -1
- 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 +1 -1
- package/coverage/lcov-report/config/index.ts.html +1 -1
- package/coverage/lcov-report/config/projectConfig.ts.html +42 -42
- package/coverage/lcov-report/datasource/adapter.ts.html +2 -2
- package/coverage/lcov-report/datasource/datasource.ts.html +43 -43
- package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +48 -48
- package/coverage/lcov-report/datasource/index.html +19 -19
- package/coverage/lcov-report/datasource/index.ts.html +4 -4
- package/coverage/lcov-report/generate-code/index.html +116 -0
- package/coverage/lcov-report/generate-code/typescript.ts.html +3004 -0
- package/coverage/lcov-report/index.html +44 -29
- package/coverage/lcov-report/linter/attributeSchema.ts.html +1815 -30
- 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 +1606 -157
- package/coverage/lcov-report/linter/featureSchema.ts.html +56 -56
- package/coverage/lcov-report/linter/groupSchema.ts.html +1 -1
- package/coverage/lcov-report/linter/index.html +39 -39
- package/coverage/lcov-report/linter/lintProject.ts.html +189 -78
- package/coverage/lcov-report/linter/mutationNotation.ts.html +1 -1
- package/coverage/lcov-report/linter/printError.ts.html +1 -1
- package/coverage/lcov-report/linter/schema.ts.html +59 -59
- package/coverage/lcov-report/linter/segmentSchema.ts.html +3 -3
- package/coverage/lcov-report/linter/testSchema.ts.html +6 -6
- 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/extractKeys.ts.html +1 -1
- package/coverage/lcov-report/utils/git.ts.html +3 -3
- package/coverage/lcov-report/utils/index.html +1 -1
- package/coverage/lcov-report/utils/index.ts.html +1 -1
- package/coverage/lcov-report/utils/pretty.ts.html +1 -1
- package/coverage/lcov.info +2624 -926
- package/lib/generate-code/typescript.js +57 -29
- package/lib/generate-code/typescript.js.map +1 -1
- package/lib/generate-code/typescript.spec.d.ts +1 -0
- package/lib/generate-code/typescript.spec.js +78 -0
- package/lib/generate-code/typescript.spec.js.map +1 -0
- package/lib/linter/attributeSchema.d.ts +352 -20
- package/lib/linter/attributeSchema.js +426 -16
- package/lib/linter/attributeSchema.js.map +1 -1
- package/lib/linter/attributeSchema.spec.d.ts +1 -0
- package/lib/linter/attributeSchema.spec.js +266 -0
- package/lib/linter/attributeSchema.spec.js.map +1 -0
- package/lib/linter/conditionSchema.d.ts +2 -1
- package/lib/linter/conditionSchema.js +370 -100
- package/lib/linter/conditionSchema.js.map +1 -1
- package/lib/linter/conditionSchema.spec.js +200 -153
- package/lib/linter/conditionSchema.spec.js.map +1 -1
- package/lib/linter/featureSchema.spec.js +114 -3
- package/lib/linter/featureSchema.spec.js.map +1 -1
- package/lib/linter/lintProject.js +45 -14
- package/lib/linter/lintProject.js.map +1 -1
- package/lib/linter/segmentSchema.spec.js +71 -1
- package/lib/linter/segmentSchema.spec.js.map +1 -1
- package/package.json +5 -5
- package/src/generate-code/typescript.spec.ts +108 -0
- package/src/generate-code/typescript.ts +80 -34
- package/src/linter/attributeSchema.spec.ts +342 -0
- package/src/linter/attributeSchema.ts +613 -18
- package/src/linter/conditionSchema.spec.ts +231 -189
- package/src/linter/conditionSchema.ts +582 -99
- package/src/linter/featureSchema.spec.ts +129 -4
- package/src/linter/lintProject.ts +55 -18
- package/src/linter/segmentSchema.spec.ts +82 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@featurevisor/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.22.1",
|
|
4
4
|
"description": "Core package of Featurevisor for Node.js usage",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -40,14 +40,14 @@
|
|
|
40
40
|
},
|
|
41
41
|
"license": "MIT",
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@featurevisor/sdk": "2.
|
|
44
|
-
"@featurevisor/site": "2.
|
|
45
|
-
"@featurevisor/types": "2.
|
|
43
|
+
"@featurevisor/sdk": "2.21.0",
|
|
44
|
+
"@featurevisor/site": "2.22.1",
|
|
45
|
+
"@featurevisor/types": "2.21.0",
|
|
46
46
|
"axios": "^1.3.4",
|
|
47
47
|
"tar": "^7.5.7",
|
|
48
48
|
"yaml": "^1.10.2",
|
|
49
49
|
"yargs": "^17.7.2",
|
|
50
50
|
"zod": "^3.22.4"
|
|
51
51
|
},
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "e5b94a013197e1dfc94667470194db5cea7b4014"
|
|
53
53
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
import { getProjectConfig } from "../config/projectConfig";
|
|
6
|
+
import { Datasource } from "../datasource";
|
|
7
|
+
import { generateTypeScriptCodeForProject } from "./typescript";
|
|
8
|
+
|
|
9
|
+
function createTempProjectFromExample1() {
|
|
10
|
+
const fixturePath = path.resolve(__dirname, "../../../../examples/example-1");
|
|
11
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-codegen-"));
|
|
12
|
+
|
|
13
|
+
fs.cpSync(fixturePath, tempRoot, { recursive: true });
|
|
14
|
+
|
|
15
|
+
return tempRoot;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("generate-code/typescript", () => {
|
|
19
|
+
let tempProjectPath: string;
|
|
20
|
+
let outputPath: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tempProjectPath = createTempProjectFromExample1();
|
|
24
|
+
outputPath = path.join(tempProjectPath, "generated");
|
|
25
|
+
fs.mkdirSync(outputPath, { recursive: true });
|
|
26
|
+
|
|
27
|
+
fs.writeFileSync(
|
|
28
|
+
path.join(tempProjectPath, "attributes", "account.yml"),
|
|
29
|
+
[
|
|
30
|
+
"description: Account",
|
|
31
|
+
"type: object",
|
|
32
|
+
"required:",
|
|
33
|
+
" - plan",
|
|
34
|
+
"properties:",
|
|
35
|
+
" plan:",
|
|
36
|
+
" type: string",
|
|
37
|
+
" enum:",
|
|
38
|
+
" - free",
|
|
39
|
+
" - pro",
|
|
40
|
+
" locale:",
|
|
41
|
+
" type: string",
|
|
42
|
+
].join("\n"),
|
|
43
|
+
"utf8",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
fs.writeFileSync(
|
|
47
|
+
path.join(tempProjectPath, "attributes", "permissions.yml"),
|
|
48
|
+
[
|
|
49
|
+
"description: Permissions",
|
|
50
|
+
"type: array",
|
|
51
|
+
"items:",
|
|
52
|
+
" type: string",
|
|
53
|
+
" enum:",
|
|
54
|
+
" - read",
|
|
55
|
+
" - write",
|
|
56
|
+
" - admin",
|
|
57
|
+
].join("\n"),
|
|
58
|
+
"utf8",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
fs.rmSync(tempProjectPath, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("generates schema-aware context types for attributes", async () => {
|
|
67
|
+
const projectConfig = getProjectConfig(tempProjectPath);
|
|
68
|
+
const datasource = new Datasource(projectConfig, tempProjectPath);
|
|
69
|
+
|
|
70
|
+
await generateTypeScriptCodeForProject(
|
|
71
|
+
{
|
|
72
|
+
rootDirectoryPath: tempProjectPath,
|
|
73
|
+
projectConfig,
|
|
74
|
+
datasource,
|
|
75
|
+
options: {},
|
|
76
|
+
} as any,
|
|
77
|
+
outputPath,
|
|
78
|
+
{},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const attributesContent = fs.readFileSync(path.join(outputPath, "attributes.ts"), "utf8");
|
|
82
|
+
const contextContent = fs.readFileSync(path.join(outputPath, "context.ts"), "utf8");
|
|
83
|
+
const indexContent = fs.readFileSync(path.join(outputPath, "index.ts"), "utf8");
|
|
84
|
+
|
|
85
|
+
expect(attributesContent).toContain(
|
|
86
|
+
'export type AccountAttribute = { plan: "free" | "pro"; locale?: string };',
|
|
87
|
+
);
|
|
88
|
+
expect(attributesContent).toContain(
|
|
89
|
+
'export type PermissionsAttribute = ("read" | "write" | "admin")[];',
|
|
90
|
+
);
|
|
91
|
+
expect(attributesContent).toContain("export type VersionAttribute = string | number;");
|
|
92
|
+
expect(attributesContent).toContain(
|
|
93
|
+
"export type BrowserAttribute = { name?: string; version?: string };",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(contextContent).toContain("import type {");
|
|
97
|
+
expect(contextContent).toContain("AccountAttribute,");
|
|
98
|
+
expect(contextContent).toContain("PermissionsAttribute,");
|
|
99
|
+
expect(contextContent).toContain("VersionAttribute,");
|
|
100
|
+
expect(contextContent).toContain('} from "./attributes";');
|
|
101
|
+
expect(contextContent).toContain("account?: AccountAttribute;");
|
|
102
|
+
expect(contextContent).toContain("permissions?: PermissionsAttribute;");
|
|
103
|
+
expect(contextContent).toContain("version?: VersionAttribute;");
|
|
104
|
+
expect(contextContent).toContain("browser?: BrowserAttribute;");
|
|
105
|
+
|
|
106
|
+
expect(indexContent).toContain('export * from "./attributes";');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -28,6 +28,8 @@ function convertFeaturevisorTypeToTypeScriptType(featurevisorType: string): stri
|
|
|
28
28
|
return "number";
|
|
29
29
|
case "date":
|
|
30
30
|
return "Date | string";
|
|
31
|
+
case "semver":
|
|
32
|
+
return "string";
|
|
31
33
|
case "array":
|
|
32
34
|
return "string[]";
|
|
33
35
|
case "object":
|
|
@@ -170,7 +172,7 @@ function schemaToTypeScriptType(
|
|
|
170
172
|
resolved.enum && Array.isArray(resolved.enum) && resolved.enum.length > 0
|
|
171
173
|
? enumToUnionType(resolved.enum)
|
|
172
174
|
: null;
|
|
173
|
-
const type = resolved.type;
|
|
175
|
+
const type = resolved.type as string | undefined;
|
|
174
176
|
if (!type) {
|
|
175
177
|
return literalFromConst ?? unionFromEnum ?? "unknown";
|
|
176
178
|
}
|
|
@@ -178,7 +180,10 @@ function schemaToTypeScriptType(
|
|
|
178
180
|
case "boolean":
|
|
179
181
|
return literalFromConst ?? unionFromEnum ?? "boolean";
|
|
180
182
|
case "string":
|
|
183
|
+
case "semver":
|
|
181
184
|
return literalFromConst ?? unionFromEnum ?? "string";
|
|
185
|
+
case "date":
|
|
186
|
+
return literalFromConst ?? unionFromEnum ?? "Date | string";
|
|
182
187
|
case "integer":
|
|
183
188
|
case "double":
|
|
184
189
|
return literalFromConst ?? unionFromEnum ?? "number";
|
|
@@ -554,6 +559,29 @@ function generateSchemasFileContent(
|
|
|
554
559
|
return lines.join("\n") + "\n";
|
|
555
560
|
}
|
|
556
561
|
|
|
562
|
+
function generateAttributesFileContent(
|
|
563
|
+
attributes: Array<Attribute & { key: string; typescriptType: string }>,
|
|
564
|
+
schemaTypeNames: Record<string, string>,
|
|
565
|
+
): string {
|
|
566
|
+
const schemaTypesUsed = new Set<string>();
|
|
567
|
+
|
|
568
|
+
for (const attribute of attributes) {
|
|
569
|
+
Object.values(schemaTypeNames).forEach((typeName) => {
|
|
570
|
+
if (attribute.typescriptType.includes(typeName)) {
|
|
571
|
+
schemaTypesUsed.add(typeName);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const importLine = formatTypeImport(Array.from(schemaTypesUsed).sort(), "./schemas");
|
|
577
|
+
const lines = attributes.map((attribute) => {
|
|
578
|
+
const typeName = `${getPascalCase(attribute.key)}Attribute`;
|
|
579
|
+
return `export type ${typeName} = ${attribute.typescriptType};`;
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
return `${importLine}${lines.join("\n")}\n`;
|
|
583
|
+
}
|
|
584
|
+
|
|
557
585
|
// Indentation for generated namespace content (2 spaces per level)
|
|
558
586
|
const INDENT_NS = " ";
|
|
559
587
|
const INDENT_NS_BODY = " ";
|
|
@@ -588,6 +616,32 @@ export async function generateTypeScriptCodeForProject(
|
|
|
588
616
|
|
|
589
617
|
console.log("\nGenerating TypeScript code...\n");
|
|
590
618
|
|
|
619
|
+
// Load schemas for resolving variable schema references
|
|
620
|
+
const schemaListKeys = await datasource.listSchemas();
|
|
621
|
+
const schemasByKey: Record<string, Schema> = {};
|
|
622
|
+
for (const key of schemaListKeys) {
|
|
623
|
+
try {
|
|
624
|
+
schemasByKey[key] = await datasource.readSchema(key);
|
|
625
|
+
} catch {
|
|
626
|
+
// Schema file may be invalid; skip for code gen
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const schemaKeys = Object.keys(schemasByKey);
|
|
630
|
+
const hasSchemasFile = schemaKeys.length > 0;
|
|
631
|
+
if (hasSchemasFile) {
|
|
632
|
+
const schemasContent = generateSchemasFileContent(schemaKeys, schemasByKey);
|
|
633
|
+
const schemasFilePath = path.join(outputPath, "schemas.ts");
|
|
634
|
+
fs.writeFileSync(schemasFilePath, schemasContent);
|
|
635
|
+
console.log(
|
|
636
|
+
`Schemas type file written at: ${getRelativePath(rootDirectoryPath, schemasFilePath)}`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const schemaTypeNames: Record<string, string> = {};
|
|
641
|
+
for (const k of schemaKeys) {
|
|
642
|
+
schemaTypeNames[k] = getPascalCase(k) + "Schema";
|
|
643
|
+
}
|
|
644
|
+
|
|
591
645
|
// instance
|
|
592
646
|
const instanceFilePath = path.join(outputPath, "instance.ts");
|
|
593
647
|
fs.writeFileSync(instanceFilePath, instanceSnippet);
|
|
@@ -595,7 +649,8 @@ export async function generateTypeScriptCodeForProject(
|
|
|
595
649
|
|
|
596
650
|
// attributes
|
|
597
651
|
const attributeFiles = await datasource.listAttributes();
|
|
598
|
-
const attributes: (Attribute & { typescriptType })[] = [];
|
|
652
|
+
const attributes: (Attribute & { typescriptType: string })[] = [];
|
|
653
|
+
const attributeTypeNames: Record<string, string> = {};
|
|
599
654
|
|
|
600
655
|
for (const attributeKey of attributeFiles) {
|
|
601
656
|
const parsedAttribute = await datasource.readAttribute(attributeKey);
|
|
@@ -604,24 +659,40 @@ export async function generateTypeScriptCodeForProject(
|
|
|
604
659
|
continue;
|
|
605
660
|
}
|
|
606
661
|
|
|
662
|
+
const typescriptType = schemaToTypeScriptType(
|
|
663
|
+
parsedAttribute as Schema,
|
|
664
|
+
schemasByKey,
|
|
665
|
+
hasSchemasFile ? schemaTypeNames : undefined,
|
|
666
|
+
);
|
|
667
|
+
|
|
607
668
|
attributes.push({
|
|
608
|
-
|
|
669
|
+
...parsedAttribute,
|
|
609
670
|
key: attributeKey,
|
|
610
|
-
|
|
611
|
-
typescriptType: convertFeaturevisorTypeToTypeScriptType(parsedAttribute.type),
|
|
671
|
+
typescriptType,
|
|
612
672
|
});
|
|
673
|
+
attributeTypeNames[attributeKey] = `${getPascalCase(attributeKey)}Attribute`;
|
|
613
674
|
}
|
|
614
675
|
|
|
676
|
+
const attributesFileContent = generateAttributesFileContent(
|
|
677
|
+
attributes as Array<Attribute & { key: string; typescriptType: string }>,
|
|
678
|
+
hasSchemasFile ? schemaTypeNames : {},
|
|
679
|
+
);
|
|
680
|
+
const attributesFilePath = path.join(outputPath, "attributes.ts");
|
|
681
|
+
fs.writeFileSync(attributesFilePath, attributesFileContent);
|
|
682
|
+
console.log(
|
|
683
|
+
`Attributes type file written at: ${getRelativePath(rootDirectoryPath, attributesFilePath)}`,
|
|
684
|
+
);
|
|
685
|
+
|
|
615
686
|
// context
|
|
687
|
+
const contextImport = formatTypeImport(Object.values(attributeTypeNames).sort(), "./attributes");
|
|
616
688
|
const attributeProperties = attributes
|
|
617
689
|
.map((attribute) => {
|
|
618
|
-
return ` ${attribute.key}?: ${attribute.
|
|
690
|
+
return ` ${formatObjectKey(attribute.key)}?: ${attributeTypeNames[attribute.key]};`;
|
|
619
691
|
})
|
|
620
692
|
.join("\n");
|
|
621
693
|
const contextContent = `
|
|
622
694
|
import type { AttributeKey, AttributeValue } from "@featurevisor/sdk";
|
|
623
|
-
|
|
624
|
-
export interface Context {
|
|
695
|
+
${contextImport}export interface Context {
|
|
625
696
|
${attributeProperties}
|
|
626
697
|
[key: AttributeKey]: AttributeValue;
|
|
627
698
|
}
|
|
@@ -636,32 +707,6 @@ ${attributeProperties}
|
|
|
636
707
|
// features
|
|
637
708
|
const featureFiles = await datasource.listFeatures();
|
|
638
709
|
|
|
639
|
-
// Load schemas for resolving variable schema references
|
|
640
|
-
const schemaListKeys = await datasource.listSchemas();
|
|
641
|
-
const schemasByKey: Record<string, Schema> = {};
|
|
642
|
-
for (const key of schemaListKeys) {
|
|
643
|
-
try {
|
|
644
|
-
schemasByKey[key] = await datasource.readSchema(key);
|
|
645
|
-
} catch {
|
|
646
|
-
// Schema file may be invalid; skip for code gen
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
const schemaKeys = Object.keys(schemasByKey);
|
|
650
|
-
const hasSchemasFile = schemaKeys.length > 0;
|
|
651
|
-
if (hasSchemasFile) {
|
|
652
|
-
const schemasContent = generateSchemasFileContent(schemaKeys, schemasByKey);
|
|
653
|
-
const schemasFilePath = path.join(outputPath, "schemas.ts");
|
|
654
|
-
fs.writeFileSync(schemasFilePath, schemasContent);
|
|
655
|
-
console.log(
|
|
656
|
-
`Schemas type file written at: ${getRelativePath(rootDirectoryPath, schemasFilePath)}`,
|
|
657
|
-
);
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
const schemaTypeNames: Record<string, string> = {};
|
|
661
|
-
for (const k of schemaKeys) {
|
|
662
|
-
schemaTypeNames[k] = getPascalCase(k) + "Schema";
|
|
663
|
-
}
|
|
664
|
-
|
|
665
710
|
const parsedFeatures: {
|
|
666
711
|
featureKey: string;
|
|
667
712
|
parsedFeature: ParsedFeature;
|
|
@@ -911,6 +956,7 @@ export function useVariable<F extends FeatureKey, V extends VariableKey<F>>(
|
|
|
911
956
|
// index
|
|
912
957
|
const indexContent =
|
|
913
958
|
[
|
|
959
|
+
`export * from "./attributes";`,
|
|
914
960
|
`export * from "./context";`,
|
|
915
961
|
`export * from "./instance";`,
|
|
916
962
|
...(hasSchemasFile ? [`export * from "./schemas";`] : []),
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { getAttributeZodSchema } from "./attributeSchema";
|
|
4
|
+
|
|
5
|
+
function parseAttribute(input: unknown): z.SafeParseReturnType<unknown, unknown> {
|
|
6
|
+
return getAttributeZodSchema().safeParse(input);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function expectAttributeSuccess(input: unknown): void {
|
|
10
|
+
const result = parseAttribute(input);
|
|
11
|
+
expect(result.success).toBe(true);
|
|
12
|
+
if (!result.success) {
|
|
13
|
+
const error = (result as z.SafeParseError<unknown>).error;
|
|
14
|
+
const msg = error.issues.map((issue) => issue.message).join("; ");
|
|
15
|
+
throw new Error(`Expected attribute to pass: ${msg}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function expectAttributeFailure(input: unknown, messageSubstring?: string): z.ZodError {
|
|
20
|
+
const result = parseAttribute(input);
|
|
21
|
+
expect(result.success).toBe(false);
|
|
22
|
+
if (result.success) {
|
|
23
|
+
throw new Error("Expected attribute to fail");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const error = (result as z.SafeParseError<unknown>).error;
|
|
27
|
+
|
|
28
|
+
if (messageSubstring) {
|
|
29
|
+
const messages = error.issues.map((issue) => issue.message).join(" ");
|
|
30
|
+
expect(messages).toContain(messageSubstring);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("attributeSchema.ts :: getAttributeZodSchema", () => {
|
|
37
|
+
it("accepts flat object attributes without additional schema details", () => {
|
|
38
|
+
expectAttributeSuccess({
|
|
39
|
+
description: "Traits",
|
|
40
|
+
type: "object",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("accepts flat object properties and typed arrays", () => {
|
|
45
|
+
expectAttributeSuccess({
|
|
46
|
+
description: "Account",
|
|
47
|
+
type: "object",
|
|
48
|
+
required: ["plan"],
|
|
49
|
+
properties: {
|
|
50
|
+
plan: {
|
|
51
|
+
type: "string",
|
|
52
|
+
enum: ["free", "pro"],
|
|
53
|
+
},
|
|
54
|
+
locale: {
|
|
55
|
+
type: "string",
|
|
56
|
+
},
|
|
57
|
+
permissions: {
|
|
58
|
+
type: "array",
|
|
59
|
+
items: {
|
|
60
|
+
type: "string",
|
|
61
|
+
enum: ["read", "write", "admin"],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("accepts array attributes without items when they stay flat", () => {
|
|
69
|
+
expectAttributeSuccess({
|
|
70
|
+
description: "Tags",
|
|
71
|
+
type: "array",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("accepts string-array enums on array attributes", () => {
|
|
76
|
+
expectAttributeSuccess({
|
|
77
|
+
description: "Permissions",
|
|
78
|
+
type: "array",
|
|
79
|
+
items: {
|
|
80
|
+
type: "string",
|
|
81
|
+
},
|
|
82
|
+
enum: [["read"], ["write", "admin"]],
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("accepts top-level oneOf attributes without type", () => {
|
|
87
|
+
expectAttributeSuccess({
|
|
88
|
+
description: "Version number of the app",
|
|
89
|
+
oneOf: [{ type: "string" }, { type: "double" }],
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("accepts top-level oneOf attributes with branch-specific enums", () => {
|
|
94
|
+
expectAttributeSuccess({
|
|
95
|
+
description: "Plan or score",
|
|
96
|
+
oneOf: [
|
|
97
|
+
{ type: "string", enum: ["free", "pro"] },
|
|
98
|
+
{ type: "double", minimum: 0 },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("requires items when array schema adds extra constraints", () => {
|
|
104
|
+
expectAttributeFailure(
|
|
105
|
+
{
|
|
106
|
+
description: "Permissions",
|
|
107
|
+
type: "array",
|
|
108
|
+
enum: [["read"], ["write"]],
|
|
109
|
+
},
|
|
110
|
+
"`items` is required",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects attributes without top-level type or oneOf", () => {
|
|
115
|
+
expectAttributeFailure(
|
|
116
|
+
{
|
|
117
|
+
description: "Country",
|
|
118
|
+
},
|
|
119
|
+
"either `type` or `oneOf`",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("rejects attributes with both top-level type and oneOf", () => {
|
|
124
|
+
expectAttributeFailure(
|
|
125
|
+
{
|
|
126
|
+
description: "Version number of the app",
|
|
127
|
+
type: "string",
|
|
128
|
+
oneOf: [{ type: "string" }, { type: "double" }],
|
|
129
|
+
},
|
|
130
|
+
"cannot have both `type` and `oneOf`",
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rejects top-level oneOf attributes when a branch enum does not match its type", () => {
|
|
135
|
+
expectAttributeFailure(
|
|
136
|
+
{
|
|
137
|
+
description: "Plan or score",
|
|
138
|
+
oneOf: [{ type: "string", enum: ["free", 1] }, { type: "double" }],
|
|
139
|
+
},
|
|
140
|
+
'does not match type "string"',
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("rejects nested object properties inside object attributes", () => {
|
|
145
|
+
expectAttributeFailure(
|
|
146
|
+
{
|
|
147
|
+
description: "Account",
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
metadata: {
|
|
151
|
+
type: "object",
|
|
152
|
+
properties: {
|
|
153
|
+
locale: {
|
|
154
|
+
type: "string",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
"must stay flat",
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("rejects object additionalProperties that allow nested objects", () => {
|
|
165
|
+
expectAttributeFailure(
|
|
166
|
+
{
|
|
167
|
+
description: "Labels",
|
|
168
|
+
type: "object",
|
|
169
|
+
additionalProperties: {
|
|
170
|
+
type: "object",
|
|
171
|
+
properties: {
|
|
172
|
+
locale: {
|
|
173
|
+
type: "string",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
"must stay flat",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("rejects object properties whose oneOf allows nested objects", () => {
|
|
183
|
+
expectAttributeFailure(
|
|
184
|
+
{
|
|
185
|
+
description: "Account",
|
|
186
|
+
type: "object",
|
|
187
|
+
properties: {
|
|
188
|
+
metadata: {
|
|
189
|
+
oneOf: [
|
|
190
|
+
{ type: "string" },
|
|
191
|
+
{
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: {
|
|
194
|
+
locale: {
|
|
195
|
+
type: "string",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
"must stay flat",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("rejects object additionalProperties whose oneOf allows nested objects", () => {
|
|
208
|
+
expectAttributeFailure(
|
|
209
|
+
{
|
|
210
|
+
description: "Labels",
|
|
211
|
+
type: "object",
|
|
212
|
+
additionalProperties: {
|
|
213
|
+
oneOf: [
|
|
214
|
+
{ type: "string" },
|
|
215
|
+
{
|
|
216
|
+
type: "object",
|
|
217
|
+
properties: {
|
|
218
|
+
locale: {
|
|
219
|
+
type: "string",
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
"must stay flat",
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("rejects non-string item types for array attributes", () => {
|
|
231
|
+
expectAttributeFailure(
|
|
232
|
+
{
|
|
233
|
+
description: "Scores",
|
|
234
|
+
type: "array",
|
|
235
|
+
items: {
|
|
236
|
+
type: "integer",
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
"items.type",
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("rejects non-string enum values for array attributes", () => {
|
|
244
|
+
expectAttributeFailure(
|
|
245
|
+
{
|
|
246
|
+
description: "Scores",
|
|
247
|
+
type: "array",
|
|
248
|
+
enum: [[1, 2]],
|
|
249
|
+
},
|
|
250
|
+
"array of strings",
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("rejects non-string const values for array attributes", () => {
|
|
255
|
+
expectAttributeFailure(
|
|
256
|
+
{
|
|
257
|
+
description: "Scores",
|
|
258
|
+
type: "array",
|
|
259
|
+
items: {
|
|
260
|
+
type: "string",
|
|
261
|
+
},
|
|
262
|
+
const: [1, 2],
|
|
263
|
+
},
|
|
264
|
+
"array of strings",
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("rejects required keys that are not declared in properties", () => {
|
|
269
|
+
expectAttributeFailure(
|
|
270
|
+
{
|
|
271
|
+
description: "Account",
|
|
272
|
+
type: "object",
|
|
273
|
+
required: ["plan", "tier"],
|
|
274
|
+
properties: {
|
|
275
|
+
plan: {
|
|
276
|
+
type: "string",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
"Unknown required field",
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("accepts required keys on flat object attributes without declared properties", () => {
|
|
285
|
+
expectAttributeSuccess({
|
|
286
|
+
description: "Traits",
|
|
287
|
+
type: "object",
|
|
288
|
+
required: ["plan"],
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("rejects enum values that do not match semver/date string-like types", () => {
|
|
293
|
+
expectAttributeFailure(
|
|
294
|
+
{
|
|
295
|
+
description: "Browser version",
|
|
296
|
+
type: "semver",
|
|
297
|
+
enum: ["1.0.0", 2],
|
|
298
|
+
},
|
|
299
|
+
'does not match type "semver"',
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("rejects minimum values greater than maximum for numeric attributes", () => {
|
|
304
|
+
expectAttributeFailure(
|
|
305
|
+
{
|
|
306
|
+
description: "Score",
|
|
307
|
+
type: "double",
|
|
308
|
+
minimum: 10,
|
|
309
|
+
maximum: 5,
|
|
310
|
+
},
|
|
311
|
+
"minimum",
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("rejects minLength values greater than maxLength for string attributes", () => {
|
|
316
|
+
expectAttributeFailure(
|
|
317
|
+
{
|
|
318
|
+
description: "Country",
|
|
319
|
+
type: "string",
|
|
320
|
+
minLength: 5,
|
|
321
|
+
maxLength: 2,
|
|
322
|
+
},
|
|
323
|
+
"minLength",
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("rejects invalid string patterns nested under object properties", () => {
|
|
328
|
+
expectAttributeFailure(
|
|
329
|
+
{
|
|
330
|
+
description: "Profile",
|
|
331
|
+
type: "object",
|
|
332
|
+
properties: {
|
|
333
|
+
email: {
|
|
334
|
+
type: "string",
|
|
335
|
+
pattern: "[",
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
"valid ECMA-262",
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
});
|