@octalmesh/seagull-core 0.0.2 → 0.1.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 +38 -0
- package/README.md +173 -39
- package/dist/index.mjs +218 -152
- package/package.json +6 -6
- package/src/config/loader.test.ts +505 -0
- package/src/config/loader.ts +31 -12
- package/src/config/publishing.test.ts +92 -0
- package/src/config/publishing.ts +9 -5
- package/src/config/resolve-config-file.test.ts +60 -0
- package/src/config/schema.test.ts +466 -0
- package/src/config/schema.ts +16 -10
- package/src/config/spec-format.test.ts +54 -0
- package/src/config/spec-format.ts +48 -0
- package/src/config/template.test.ts +168 -0
- package/src/config/types.ts +3 -3
- package/src/generator/generator.test.ts +59 -0
- package/src/generator/registry.test.ts +84 -0
- package/src/generator/types.ts +9 -14
- package/src/generators/openapi-generator-cli/openapi-generator-cli.generator.test.ts +259 -0
- package/src/generators/openapi-generator-cli/patchers/go-module.patcher.test.ts +119 -0
- package/src/generators/openapi-generator-cli/patchers/maven.patcher.test.ts +141 -0
- package/src/generators/openapi-generator-cli/patchers/npm.patcher.test.ts +132 -0
- package/src/generators/openapi-typescript/openapi-typescript.generator.test.ts +190 -0
- package/src/git/git.test.ts +234 -0
- package/src/git/git.ts +50 -4
- package/src/index.ts +8 -0
- package/src/process/exec.test.ts +103 -0
- package/src/process/exec.ts +1 -2
- package/src/process/resolve-bin.test.ts +43 -0
- package/src/readme/default-templates.test.ts +179 -0
- package/src/readme/default-templates.ts +5 -10
- package/src/readme/readme-renderer.test.ts +121 -0
- package/src/readme/readme-renderer.ts +3 -7
- package/src/redocly/redocly-sync.test.ts +190 -0
- package/src/redocly/redocly-sync.ts +3 -1
- package/src/test-support/fixtures.ts +65 -0
- package/src/version/version.test.ts +93 -0
- package/src/version/version.ts +4 -2
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { renderArtifactTag } from "./publishing";
|
|
4
|
+
import type { ResolvedArtifact } from "./types";
|
|
5
|
+
|
|
6
|
+
function makeArtifact(
|
|
7
|
+
overrides: Partial<ResolvedArtifact> = {},
|
|
8
|
+
): ResolvedArtifact {
|
|
9
|
+
return {
|
|
10
|
+
id: "ts-client",
|
|
11
|
+
tool: "openapi-generator",
|
|
12
|
+
lang: "typescript",
|
|
13
|
+
kind: "client",
|
|
14
|
+
outputDir: "/tmp/out",
|
|
15
|
+
branch: "sdk/svc-auth/ts-client",
|
|
16
|
+
publishing: {
|
|
17
|
+
branch: "sdk/svc-auth/ts-client",
|
|
18
|
+
tagTemplate: "svc-{service}-{id}-v{version}",
|
|
19
|
+
repositoryUrl: "https://github.com/OctalMesh/ows-contracts",
|
|
20
|
+
npmRegistry: "https://npm.pkg.github.com",
|
|
21
|
+
npmAccess: "public",
|
|
22
|
+
mavenRepositoryId: "github",
|
|
23
|
+
mavenRepositoryUrl:
|
|
24
|
+
"https://maven.pkg.github.com/OctalMesh/ows-contracts",
|
|
25
|
+
},
|
|
26
|
+
additionalProperties: {},
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("renderArtifactTag", () => {
|
|
32
|
+
it("renders {service}, {id}, and {version} into the tag template", () => {
|
|
33
|
+
const tag = renderArtifactTag(makeArtifact(), "auth", "1.2.3", {});
|
|
34
|
+
|
|
35
|
+
expect(tag).toBe("svc-auth-ts-client-v1.2.3");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("supports {vars.*} placeholders in the tag template, including nested trees", () => {
|
|
39
|
+
const artifact = makeArtifact({
|
|
40
|
+
publishing: {
|
|
41
|
+
...makeArtifact().publishing,
|
|
42
|
+
tagTemplate:
|
|
43
|
+
"{vars.org}-{vars.repository.repo}-{service}-{id}-v{version}",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const tag = renderArtifactTag(artifact, "catalog", "0.4.0", {
|
|
48
|
+
org: "octalmesh",
|
|
49
|
+
repository: { owner: "OctalMesh", repo: "ows-contracts" },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(tag).toBe("octalmesh-ows-contracts-catalog-ts-client-v0.4.0");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("throws when the tag template references an unresolvable placeholder", () => {
|
|
56
|
+
const artifact = makeArtifact({
|
|
57
|
+
publishing: {
|
|
58
|
+
...makeArtifact().publishing,
|
|
59
|
+
tagTemplate: "{vars.missing}-v{version}",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(() => renderArtifactTag(artifact, "auth", "1.0.0", {})).toThrow(
|
|
64
|
+
/Unknown template placeholder/,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("throws a descriptive error when the resolved tag would start with '-'", () => {
|
|
69
|
+
const artifact = makeArtifact({
|
|
70
|
+
publishing: {
|
|
71
|
+
...makeArtifact().publishing,
|
|
72
|
+
tagTemplate: "-{vars.opt}",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(() =>
|
|
77
|
+
renderArtifactTag(artifact, "auth", "1.0.0", {
|
|
78
|
+
opt: "upload-pack=evil.sh",
|
|
79
|
+
}),
|
|
80
|
+
).toThrow(
|
|
81
|
+
/Invalid git publishing\.tag for artifact "auth\/ts-client" "-upload-pack=evil\.sh": must not start with "-"/,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("uses the artifact's own id, not its branch, for {id}", () => {
|
|
86
|
+
const artifact = makeArtifact({ id: "go-client" });
|
|
87
|
+
|
|
88
|
+
const tag = renderArtifactTag(artifact, "auth", "1.0.0", {});
|
|
89
|
+
|
|
90
|
+
expect(tag).toBe("svc-auth-go-client-v1.0.0");
|
|
91
|
+
});
|
|
92
|
+
});
|
package/src/config/publishing.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertSafeRefName } from "../git/git";
|
|
1
2
|
import { buildTemplateContext, interpolate } from "./template";
|
|
2
3
|
import type { ResolvedArtifact, VarsTree } from "./types";
|
|
3
4
|
|
|
@@ -13,8 +14,6 @@ import type { ResolvedArtifact, VarsTree } from "./types";
|
|
|
13
14
|
* `{service}`.
|
|
14
15
|
* @param version - The resolved SDK version, exposed to the template as
|
|
15
16
|
* `{version}`.
|
|
16
|
-
* @param github - `{ owner, repo }`, exposed as `{github.owner}`/
|
|
17
|
-
* `{github.repo}`.
|
|
18
17
|
* @param vars - The config's `vars:` tree, exposed as `{vars.*}`.
|
|
19
18
|
* @returns The rendered tag name.
|
|
20
19
|
*/
|
|
@@ -22,16 +21,21 @@ export function renderArtifactTag(
|
|
|
22
21
|
artifact: ResolvedArtifact,
|
|
23
22
|
contractName: string,
|
|
24
23
|
version: string,
|
|
25
|
-
github: { owner: string; repo: string },
|
|
26
24
|
vars: VarsTree,
|
|
27
25
|
): string {
|
|
28
26
|
const context = buildTemplateContext({
|
|
29
27
|
service: contractName,
|
|
30
28
|
id: artifact.id,
|
|
31
29
|
version,
|
|
32
|
-
github,
|
|
33
30
|
vars,
|
|
34
31
|
});
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
const tag = interpolate(artifact.publishing.tagTemplate, context);
|
|
34
|
+
|
|
35
|
+
assertSafeRefName(
|
|
36
|
+
tag,
|
|
37
|
+
`publishing.tag for artifact "${contractName}/${artifact.id}"`,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return tag;
|
|
37
41
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { CONFIG_FILENAMES, resolveConfigPath } from "./resolve-config-file";
|
|
8
|
+
|
|
9
|
+
describe("resolveConfigPath", () => {
|
|
10
|
+
let dir: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
dir = await mkdtemp(path.join(tmpdir(), "seagull-resolve-config-"));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await rm(dir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("throws with the full candidate list when no config file exists", () => {
|
|
21
|
+
expect(() => resolveConfigPath(dir)).toThrow(
|
|
22
|
+
new RegExp(
|
|
23
|
+
`No CLI config found in ${dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
28
|
+
expect(() => resolveConfigPath(dir)).toThrow(
|
|
29
|
+
new RegExp(filename.replace(".", "\\.")),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("finds 'seagull.yaml' when present", async () => {
|
|
35
|
+
await writeFile(path.join(dir, "seagull.yaml"), "configVersion: 1");
|
|
36
|
+
|
|
37
|
+
expect(resolveConfigPath(dir)).toBe(path.join(dir, "seagull.yaml"));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("prefers earlier filenames in CONFIG_FILENAMES order over later ones", async () => {
|
|
41
|
+
await writeFile(path.join(dir, ".seagull"), "configVersion: 1");
|
|
42
|
+
await writeFile(path.join(dir, "seagull.yaml"), "configVersion: 1");
|
|
43
|
+
|
|
44
|
+
expect(resolveConfigPath(dir)).toBe(path.join(dir, ".seagull"));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("falls through to a later candidate when earlier ones are absent", async () => {
|
|
48
|
+
await writeFile(path.join(dir, "seagull.yml"), "configVersion: 1");
|
|
49
|
+
|
|
50
|
+
expect(resolveConfigPath(dir)).toBe(path.join(dir, "seagull.yml"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns an absolute path", async () => {
|
|
54
|
+
await writeFile(path.join(dir, ".seagull.yaml"), "configVersion: 1");
|
|
55
|
+
|
|
56
|
+
const resolved = resolveConfigPath(dir);
|
|
57
|
+
|
|
58
|
+
expect(path.isAbsolute(resolved)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CONFIG_SCHEMA_VERSION,
|
|
5
|
+
artifactRefSchema,
|
|
6
|
+
contractSchema,
|
|
7
|
+
generatorDefSchema,
|
|
8
|
+
publishingSchema,
|
|
9
|
+
rootConfigSchema,
|
|
10
|
+
varsTreeSchema,
|
|
11
|
+
} from "./schema";
|
|
12
|
+
|
|
13
|
+
const validPublishing = {
|
|
14
|
+
branch: "sdk/svc-{service}/{id}",
|
|
15
|
+
tag: "svc-{service}-{id}-v{version}",
|
|
16
|
+
repositoryUrl:
|
|
17
|
+
"https://github.com/{vars.repository.owner}/{vars.repository.repo}",
|
|
18
|
+
npm: { registry: "https://npm.pkg.github.com", access: "public" as const },
|
|
19
|
+
maven: {
|
|
20
|
+
repositoryId: "github",
|
|
21
|
+
repositoryUrl:
|
|
22
|
+
"https://maven.pkg.github.com/{vars.repository.owner}/{vars.repository.repo}",
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const minimalRootConfig = {
|
|
27
|
+
configVersion: 1,
|
|
28
|
+
docs: {
|
|
29
|
+
server: { host: "localhost", port: 8080 },
|
|
30
|
+
metadata: {
|
|
31
|
+
title: "t",
|
|
32
|
+
description: "d",
|
|
33
|
+
favicon: "f",
|
|
34
|
+
baseServerUrl: "https://example.com",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
publishing: validPublishing,
|
|
38
|
+
generators: {
|
|
39
|
+
"ts-server": {
|
|
40
|
+
tool: "openapi-typescript",
|
|
41
|
+
lang: "typescript",
|
|
42
|
+
kind: "server",
|
|
43
|
+
package: "@org/{service}-server",
|
|
44
|
+
additionalProperties: {},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
contracts: [
|
|
48
|
+
{
|
|
49
|
+
name: "auth",
|
|
50
|
+
title: "Auth Service API",
|
|
51
|
+
entrypoint: "specs/auth/openapi.yaml",
|
|
52
|
+
artifacts: ["ts-server"],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe("varsTreeSchema", () => {
|
|
58
|
+
it("accepts a free-form nested tree of string/number/boolean leaves", () => {
|
|
59
|
+
const result = varsTreeSchema.safeParse({
|
|
60
|
+
org: "octalmesh",
|
|
61
|
+
port: 8080,
|
|
62
|
+
enabled: true,
|
|
63
|
+
nested: { deep: { deeper: "value" } },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(result.success).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects a leaf of an unsupported type", () => {
|
|
70
|
+
const result = varsTreeSchema.safeParse({ bad: null });
|
|
71
|
+
|
|
72
|
+
expect(result.success).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("accepts an empty tree", () => {
|
|
76
|
+
expect(varsTreeSchema.safeParse({}).success).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("generatorDefSchema", () => {
|
|
81
|
+
const base = {
|
|
82
|
+
tool: "openapi-generator" as const,
|
|
83
|
+
lang: "typescript" as const,
|
|
84
|
+
kind: "client" as const,
|
|
85
|
+
generator: "typescript-fetch",
|
|
86
|
+
package: "@org/pkg",
|
|
87
|
+
additionalProperties: {},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
it("accepts a minimal valid typescript openapi-generator recipe", () => {
|
|
91
|
+
expect(generatorDefSchema.safeParse(base).success).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("requires 'generator' when tool is 'openapi-generator'", () => {
|
|
95
|
+
const rest: Record<string, unknown> = { ...base };
|
|
96
|
+
|
|
97
|
+
delete rest.generator;
|
|
98
|
+
const result = generatorDefSchema.safeParse(rest);
|
|
99
|
+
|
|
100
|
+
expect(result.success).toBe(false);
|
|
101
|
+
if (!result.success) {
|
|
102
|
+
expect(result.error.issues[0]?.message).toMatch(
|
|
103
|
+
/"generator" is required when tool is "openapi-generator"/,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does not require 'generator' when tool is 'openapi-typescript'", () => {
|
|
109
|
+
const result = generatorDefSchema.safeParse({
|
|
110
|
+
tool: "openapi-typescript",
|
|
111
|
+
lang: "typescript",
|
|
112
|
+
kind: "server",
|
|
113
|
+
package: "@org/pkg",
|
|
114
|
+
additionalProperties: {},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(result.success).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("requires 'goModule' for lang 'go'", () => {
|
|
121
|
+
const result = generatorDefSchema.safeParse({
|
|
122
|
+
...base,
|
|
123
|
+
lang: "go",
|
|
124
|
+
generator: "go",
|
|
125
|
+
goPackageName: "authclient",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(result.success).toBe(false);
|
|
129
|
+
if (!result.success) {
|
|
130
|
+
expect(
|
|
131
|
+
result.error.issues.some((issue) =>
|
|
132
|
+
issue.message.includes('"goModule" is required for lang "go"'),
|
|
133
|
+
),
|
|
134
|
+
).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("accepts lang 'go' when goModule is present", () => {
|
|
139
|
+
const result = generatorDefSchema.safeParse({
|
|
140
|
+
...base,
|
|
141
|
+
lang: "go",
|
|
142
|
+
generator: "go",
|
|
143
|
+
package: undefined,
|
|
144
|
+
goModule: "github.com/org/repo",
|
|
145
|
+
goPackageName: "authclient",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(result.success).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("requires 'maven' ({groupId, artifactId}) for lang 'java'", () => {
|
|
152
|
+
const result = generatorDefSchema.safeParse({
|
|
153
|
+
...base,
|
|
154
|
+
lang: "java",
|
|
155
|
+
generator: "java",
|
|
156
|
+
package: undefined,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(result.success).toBe(false);
|
|
160
|
+
if (!result.success) {
|
|
161
|
+
expect(
|
|
162
|
+
result.error.issues.some((issue) => issue.message.includes('"maven"')),
|
|
163
|
+
).toBe(true);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("accepts lang 'java' when maven coordinates are present", () => {
|
|
168
|
+
const result = generatorDefSchema.safeParse({
|
|
169
|
+
...base,
|
|
170
|
+
lang: "java",
|
|
171
|
+
generator: "java",
|
|
172
|
+
package: undefined,
|
|
173
|
+
maven: { groupId: "com.org.svc", artifactId: "svc-client" },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(result.success).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("requires 'package' for lang 'typescript'", () => {
|
|
180
|
+
const result = generatorDefSchema.safeParse({
|
|
181
|
+
...base,
|
|
182
|
+
package: undefined,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(result.success).toBe(false);
|
|
186
|
+
if (!result.success) {
|
|
187
|
+
expect(
|
|
188
|
+
result.error.issues.some((issue) =>
|
|
189
|
+
issue.message.includes('"package" is required for lang "typescript"'),
|
|
190
|
+
),
|
|
191
|
+
).toBe(true);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("can report multiple independent cross-field issues at once", () => {
|
|
196
|
+
const result = generatorDefSchema.safeParse({
|
|
197
|
+
tool: "openapi-generator",
|
|
198
|
+
lang: "go",
|
|
199
|
+
kind: "client",
|
|
200
|
+
additionalProperties: {},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(result.success).toBe(false);
|
|
204
|
+
if (!result.success) {
|
|
205
|
+
expect(result.error.issues.length).toBeGreaterThanOrEqual(2);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("defaults additionalProperties to an empty object when omitted", () => {
|
|
210
|
+
const result = generatorDefSchema.safeParse({
|
|
211
|
+
tool: "openapi-typescript",
|
|
212
|
+
lang: "typescript",
|
|
213
|
+
kind: "server",
|
|
214
|
+
package: "@org/pkg",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(result.success).toBe(true);
|
|
218
|
+
if (result.success) {
|
|
219
|
+
expect(result.data.additionalProperties).toEqual({});
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("rejects an empty-string 'generator' as if absent", () => {
|
|
224
|
+
const result = generatorDefSchema.safeParse({ ...base, generator: "" });
|
|
225
|
+
|
|
226
|
+
expect(result.success).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("artifactRefSchema", () => {
|
|
231
|
+
it("accepts a plain string id", () => {
|
|
232
|
+
expect(artifactRefSchema.safeParse("ts-client").success).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("rejects an empty string id", () => {
|
|
236
|
+
expect(artifactRefSchema.safeParse("").success).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("accepts the object form with generator/as/overrides", () => {
|
|
240
|
+
const result = artifactRefSchema.safeParse({
|
|
241
|
+
generator: "ts-client",
|
|
242
|
+
as: "ts-client-legacy",
|
|
243
|
+
overrides: { package: "@org/legacy-{service}" },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(result.success).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("accepts the object form with only 'generator' set", () => {
|
|
250
|
+
expect(
|
|
251
|
+
artifactRefSchema.safeParse({ generator: "ts-client" }).success,
|
|
252
|
+
).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("rejects an object form missing 'generator'", () => {
|
|
256
|
+
expect(artifactRefSchema.safeParse({ as: "x" }).success).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("allows overrides to be a partial generator def missing required fields", () => {
|
|
260
|
+
const result = artifactRefSchema.safeParse({
|
|
261
|
+
generator: "java-client",
|
|
262
|
+
overrides: { additionalProperties: { library: "restclient" } },
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(result.success).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("contractSchema", () => {
|
|
270
|
+
it("requires at least one artifact", () => {
|
|
271
|
+
const result = contractSchema.safeParse({
|
|
272
|
+
name: "auth",
|
|
273
|
+
title: "Auth",
|
|
274
|
+
entrypoint: "specs/auth/openapi.yaml",
|
|
275
|
+
artifacts: [],
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(result.success).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("accepts a contract with mixed string and object artifact refs", () => {
|
|
282
|
+
const result = contractSchema.safeParse({
|
|
283
|
+
name: "auth",
|
|
284
|
+
title: "Auth",
|
|
285
|
+
entrypoint: "specs/auth/openapi.yaml",
|
|
286
|
+
artifacts: ["ts-client", { generator: "java-client", as: "java-v2" }],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(result.success).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("publishingSchema", () => {
|
|
294
|
+
it("accepts a fully specified publishing block", () => {
|
|
295
|
+
expect(publishingSchema.safeParse(validPublishing).success).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("rejects a publishing block missing 'maven'", () => {
|
|
299
|
+
const rest: Record<string, unknown> = { ...validPublishing };
|
|
300
|
+
|
|
301
|
+
delete rest.maven;
|
|
302
|
+
|
|
303
|
+
expect(publishingSchema.safeParse(rest).success).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("rejects an npm.access value outside the enum", () => {
|
|
307
|
+
const result = publishingSchema.safeParse({
|
|
308
|
+
...validPublishing,
|
|
309
|
+
npm: { ...validPublishing.npm, access: "private" },
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(result.success).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("rootConfigSchema", () => {
|
|
317
|
+
it("accepts a minimal valid config", () => {
|
|
318
|
+
const result = rootConfigSchema.safeParse(minimalRootConfig);
|
|
319
|
+
|
|
320
|
+
expect(result.success).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("defaults 'vars' to an empty object", () => {
|
|
324
|
+
const result = rootConfigSchema.safeParse(minimalRootConfig);
|
|
325
|
+
|
|
326
|
+
expect(result.success).toBe(true);
|
|
327
|
+
if (result.success) {
|
|
328
|
+
expect(result.data.vars).toEqual({});
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("defaults 'paths' to { dist: 'dist', specFormat: ['json'] }", () => {
|
|
333
|
+
const result = rootConfigSchema.safeParse(minimalRootConfig);
|
|
334
|
+
|
|
335
|
+
expect(result.success).toBe(true);
|
|
336
|
+
if (result.success) {
|
|
337
|
+
expect(result.data.paths).toEqual({
|
|
338
|
+
dist: "dist",
|
|
339
|
+
specFormat: ["json"],
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("only accepts configVersion === CONFIG_SCHEMA_VERSION", () => {
|
|
345
|
+
expect(CONFIG_SCHEMA_VERSION).toBe(1);
|
|
346
|
+
|
|
347
|
+
const result = rootConfigSchema.safeParse({
|
|
348
|
+
...minimalRootConfig,
|
|
349
|
+
configVersion: 2,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(result.success).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("requires at least one contract", () => {
|
|
356
|
+
const result = rootConfigSchema.safeParse({
|
|
357
|
+
...minimalRootConfig,
|
|
358
|
+
contracts: [],
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(result.success).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("requires the root 'publishing' block", () => {
|
|
365
|
+
const rest: Record<string, unknown> = { ...minimalRootConfig };
|
|
366
|
+
|
|
367
|
+
delete rest.publishing;
|
|
368
|
+
const result = rootConfigSchema.safeParse(rest);
|
|
369
|
+
|
|
370
|
+
expect(result.success).toBe(false);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("rejects a contract referencing artifacts by any shape but keeps generators as a free map", () => {
|
|
374
|
+
const result = rootConfigSchema.safeParse({
|
|
375
|
+
...minimalRootConfig,
|
|
376
|
+
generators: {
|
|
377
|
+
...minimalRootConfig.generators,
|
|
378
|
+
"another-one": {
|
|
379
|
+
tool: "openapi-typescript",
|
|
380
|
+
lang: "typescript",
|
|
381
|
+
kind: "server",
|
|
382
|
+
package: "@org/other-{service}",
|
|
383
|
+
additionalProperties: {},
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(result.success).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("accepts explicit paths overrides", () => {
|
|
392
|
+
const result = rootConfigSchema.safeParse({
|
|
393
|
+
...minimalRootConfig,
|
|
394
|
+
paths: { dist: "build", specs: "specs-out", docs: "docs-out" },
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(result.success).toBe(true);
|
|
398
|
+
if (result.success) {
|
|
399
|
+
expect(result.data.paths).toEqual({
|
|
400
|
+
dist: "build",
|
|
401
|
+
specs: "specs-out",
|
|
402
|
+
docs: "docs-out",
|
|
403
|
+
specFormat: ["json"],
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("accepts paths.specFormat as a single value, normalizing it to a one-element array", () => {
|
|
409
|
+
const result = rootConfigSchema.safeParse({
|
|
410
|
+
...minimalRootConfig,
|
|
411
|
+
paths: { dist: "dist", specFormat: "yaml" },
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(result.success).toBe(true);
|
|
415
|
+
if (result.success) {
|
|
416
|
+
expect(result.data.paths.specFormat).toEqual(["yaml"]);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("accepts paths.specFormat as a list, bundling into more than one format", () => {
|
|
421
|
+
const result = rootConfigSchema.safeParse({
|
|
422
|
+
...minimalRootConfig,
|
|
423
|
+
paths: { dist: "dist", specFormat: ["json", "yaml"] },
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
expect(result.success).toBe(true);
|
|
427
|
+
if (result.success) {
|
|
428
|
+
expect(result.data.paths.specFormat).toEqual(["json", "yaml"]);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("rejects a paths.specFormat outside 'json' | 'yaml'", () => {
|
|
433
|
+
const result = rootConfigSchema.safeParse({
|
|
434
|
+
...minimalRootConfig,
|
|
435
|
+
paths: { dist: "dist", specFormat: "yml" },
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
expect(result.success).toBe(false);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("rejects an empty paths.specFormat list", () => {
|
|
442
|
+
const result = rootConfigSchema.safeParse({
|
|
443
|
+
...minimalRootConfig,
|
|
444
|
+
paths: { dist: "dist", specFormat: [] },
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expect(result.success).toBe(false);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("rejects duplicate entries in paths.specFormat", () => {
|
|
451
|
+
const result = rootConfigSchema.safeParse({
|
|
452
|
+
...minimalRootConfig,
|
|
453
|
+
paths: { dist: "dist", specFormat: ["json", "json"] },
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(result.success).toBe(false);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("rejects a config missing docs metadata", () => {
|
|
460
|
+
const rest: Record<string, unknown> = { ...minimalRootConfig };
|
|
461
|
+
|
|
462
|
+
delete rest.docs;
|
|
463
|
+
|
|
464
|
+
expect(rootConfigSchema.safeParse(rest).success).toBe(false);
|
|
465
|
+
});
|
|
466
|
+
});
|