@kirrosh/zond 0.13.0 → 0.16.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 +7 -0
- package/README.md +1 -1
- package/package.json +4 -7
- package/src/cli/commands/ci-init.ts +12 -1
- package/src/cli/commands/coverage.ts +21 -1
- package/src/cli/commands/db.ts +121 -0
- package/src/cli/commands/describe.ts +60 -0
- package/src/cli/commands/generate.ts +127 -0
- package/src/cli/commands/guide.ts +127 -0
- package/src/cli/commands/init.ts +50 -77
- package/src/cli/commands/request.ts +57 -0
- package/src/cli/commands/run.ts +53 -10
- package/src/cli/commands/serve.ts +62 -3
- package/src/cli/commands/validate.ts +18 -2
- package/src/cli/index.ts +213 -215
- package/src/cli/json-envelope.ts +19 -0
- package/src/core/diagnostics/db-analysis.ts +351 -0
- package/src/core/diagnostics/failure-hints.ts +1 -0
- package/src/core/generator/data-factory.ts +19 -8
- package/src/core/generator/describe.ts +250 -0
- package/src/core/generator/guide-builder.ts +20 -0
- package/src/core/generator/index.ts +0 -3
- package/src/core/generator/suite-generator.ts +133 -20
- package/src/core/runner/executor.ts +1 -0
- package/src/core/runner/send-request.ts +94 -0
- package/src/core/runner/types.ts +1 -0
- package/src/db/queries.ts +4 -2
- package/src/db/schema.ts +11 -3
- package/src/mcp/descriptions.ts +0 -24
- package/src/mcp/server.ts +1 -8
- package/src/mcp/tools/describe-endpoint.ts +3 -218
- package/src/mcp/tools/query-db.ts +6 -222
- package/src/mcp/tools/run-tests.ts +1 -0
- package/src/mcp/tools/send-request.ts +15 -61
- package/src/web/views/suites-tab.ts +1 -1
- package/src/cli/commands/add-api.ts +0 -53
- package/src/cli/commands/ai-generate.ts +0 -106
- package/src/cli/commands/chat.ts +0 -43
- package/src/cli/commands/collections.ts +0 -41
- package/src/cli/commands/compare.ts +0 -129
- package/src/cli/commands/doctor.ts +0 -127
- package/src/cli/commands/runs.ts +0 -108
- package/src/cli/commands/update.ts +0 -142
- package/src/core/agent/agent-loop.ts +0 -116
- package/src/core/agent/context-manager.ts +0 -41
- package/src/core/agent/system-prompt.ts +0 -27
- package/src/core/agent/tools/diagnose-failure.ts +0 -51
- package/src/core/agent/tools/index.ts +0 -42
- package/src/core/agent/tools/query-results.ts +0 -40
- package/src/core/agent/tools/run-tests.ts +0 -38
- package/src/core/agent/tools/send-request.ts +0 -44
- package/src/core/agent/types.ts +0 -22
- package/src/core/generator/ai/ai-generator.ts +0 -61
- package/src/core/generator/ai/llm-client.ts +0 -159
- package/src/core/generator/ai/output-parser.ts +0 -307
- package/src/core/generator/ai/prompt-builder.ts +0 -153
- package/src/core/generator/ai/types.ts +0 -56
- package/src/mcp/tools/generate-and-save.ts +0 -202
- package/src/mcp/tools/save-test-suite.ts +0 -218
- package/src/mcp/tools/set-work-dir.ts +0 -35
- package/src/tui/chat-ui.ts +0 -150
package/CHANGELOG.md
CHANGED
|
@@ -48,6 +48,13 @@ All notable changes to this project will be documented in this file.
|
|
|
48
48
|
|
|
49
49
|
## [Unreleased]
|
|
50
50
|
|
|
51
|
+
### Removed
|
|
52
|
+
|
|
53
|
+
- **AI subsystem** — removed `ai-generate` CLI, `chat` CLI, AI agent loop, LLM client, TUI chat UI, and all AI SDK dependencies (`ai`, `@ai-sdk/openai`, `@ai-sdk/anthropic`)
|
|
54
|
+
- **CLI commands** — removed `add-api`, `init`, `collections`, `runs`, `compare`, `doctor`, `update` (available via MCP tools or unnecessary)
|
|
55
|
+
- **Directories** — removed `generated/`, `examples/`, `self-tests/`, `apis/`, `docs/archive/`
|
|
56
|
+
- **Files** — removed `seed-demo.ts`, `BACKLOG.md`, `docs/agent.md`
|
|
57
|
+
|
|
51
58
|
### Added
|
|
52
59
|
|
|
53
60
|
- **Extended YAML test format** — 12 new assertion operators, flow control, and data transforms:
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Zond reads your OpenAPI spec and gives your AI agent everything it needs to test
|
|
|
17
17
|
|
|
18
18
|
Then say: _"Safely cover the API from openapi.json with tests"_
|
|
19
19
|
|
|
20
|
-
You get
|
|
20
|
+
You get auto-validation hooks, CLI tools, and 8 MCP tools — all in one package.
|
|
21
21
|
|
|
22
22
|
<details>
|
|
23
23
|
<summary>Other installation methods (MCP, CLI, binary)</summary>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kirrosh/zond",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "API testing platform — define tests in YAML, run from CLI or WebUI, generate from OpenAPI specs",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "index.ts",
|
|
@@ -26,13 +26,13 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"zond": "bun run src/cli/index.ts",
|
|
28
28
|
"test": "bun run test:unit && bun run test:mocked",
|
|
29
|
-
"test:unit": "bun test tests/db/ tests/parser/ tests/runner/ tests/generator/ tests/core/ tests/cli/args.test.ts tests/cli/
|
|
29
|
+
"test:unit": "bun test tests/db/ tests/parser/ tests/runner/ tests/generator/ tests/core/ tests/cli/args.test.ts tests/cli/ci-init.test.ts tests/cli/commands.test.ts tests/cli/safe-run.test.ts tests/cli/json-envelope.test.ts tests/cli/describe.test.ts tests/cli/db.test.ts tests/cli/request.test.ts tests/cli/init.test.ts tests/cli/guide.test.ts tests/integration/ tests/web/ tests/mcp/tools.test.ts tests/reporter/ tests/version-sync.test.ts",
|
|
30
30
|
"test:mocked": "bun run scripts/run-mocked-tests.ts",
|
|
31
|
-
"test:ai": "bun test tests/ai/",
|
|
32
31
|
"check": "tsc --noEmit --project tsconfig.json",
|
|
33
32
|
"build": "bun build --compile src/cli/index.ts --outfile zond",
|
|
34
33
|
"version:sync": "bun run scripts/sync-version.ts",
|
|
35
|
-
"postversion": "bun run scripts/sync-version.ts && git add .claude-plugin/plugin.json
|
|
34
|
+
"postversion": "bun run scripts/sync-version.ts && git add .claude-plugin/plugin.json",
|
|
35
|
+
"bench:api": "bun benchmarks/api/server.ts"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/bun": "latest"
|
|
@@ -42,12 +42,9 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@humanwhocodes/momoa": "^2.0.3",
|
|
45
|
-
"@ai-sdk/anthropic": "^2",
|
|
46
|
-
"@ai-sdk/openai": "^2",
|
|
47
45
|
"@hono/zod-openapi": "^1.2.2",
|
|
48
46
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
49
47
|
"@readme/openapi-parser": "^5.5.0",
|
|
50
|
-
"ai": "^6",
|
|
51
48
|
"hono": "^4.12.2",
|
|
52
49
|
"openapi-types": "^12.1.3",
|
|
53
50
|
"zod": "^4.3.6"
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { resolve, dirname } from "path";
|
|
2
2
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
3
3
|
import { printSuccess, printError } from "../output.ts";
|
|
4
|
+
import { jsonOk, printJson } from "../json-envelope.ts";
|
|
4
5
|
|
|
5
6
|
export interface CiInitOptions {
|
|
6
7
|
platform?: "github" | "gitlab";
|
|
7
8
|
force: boolean;
|
|
8
9
|
dir?: string;
|
|
10
|
+
json?: boolean;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const GH_ACTIONS_TEMPLATE = `name: API Tests
|
|
@@ -155,7 +157,16 @@ export async function ciInitCommand(options: CiInitOptions): Promise<number> {
|
|
|
155
157
|
created = writeIfMissing(targetPath, GITLAB_CI_TEMPLATE, options.force);
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
if (
|
|
160
|
+
if (options.json) {
|
|
161
|
+
const targetPath = platform === "github"
|
|
162
|
+
? resolve(cwd, ".github/workflows/api-tests.yml")
|
|
163
|
+
: resolve(cwd, ".gitlab-ci.yml");
|
|
164
|
+
printJson(jsonOk("ci init", {
|
|
165
|
+
platform,
|
|
166
|
+
filePath: targetPath,
|
|
167
|
+
created,
|
|
168
|
+
}));
|
|
169
|
+
} else if (created) {
|
|
159
170
|
printSuccess("CI workflow created. Commit and push to activate.");
|
|
160
171
|
}
|
|
161
172
|
|
|
@@ -2,12 +2,14 @@ import { readOpenApiSpec, extractEndpoints, scanCoveredEndpoints, filterUncovere
|
|
|
2
2
|
import { getDb } from "../../db/schema.ts";
|
|
3
3
|
import { getResultsByRunId, getRunById } from "../../db/queries.ts";
|
|
4
4
|
import { printError, printSuccess } from "../output.ts";
|
|
5
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
5
6
|
|
|
6
7
|
export interface CoverageOptions {
|
|
7
8
|
spec: string;
|
|
8
9
|
tests: string;
|
|
9
10
|
failOnCoverage?: number;
|
|
10
11
|
runId?: number;
|
|
12
|
+
json?: boolean;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
const RESET = "\x1b[0m";
|
|
@@ -145,12 +147,30 @@ export async function coverageCommand(options: CoverageOptions): Promise<number>
|
|
|
145
147
|
}
|
|
146
148
|
}
|
|
147
149
|
|
|
150
|
+
if (options.json) {
|
|
151
|
+
const coveredEndpoints = allEndpoints.filter(ep => !uncovered.includes(ep)).map(ep => `${ep.method} ${ep.path}`);
|
|
152
|
+
const uncoveredEndpoints = uncovered.map(ep => `${ep.method} ${ep.path}`);
|
|
153
|
+
printJson(jsonOk("coverage", {
|
|
154
|
+
covered: coveredCount,
|
|
155
|
+
uncovered: uncovered.length,
|
|
156
|
+
total: allEndpoints.length,
|
|
157
|
+
percentage,
|
|
158
|
+
coveredEndpoints,
|
|
159
|
+
uncoveredEndpoints,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
148
163
|
if (options.failOnCoverage !== undefined) {
|
|
149
164
|
return percentage < options.failOnCoverage ? 1 : 0;
|
|
150
165
|
}
|
|
151
166
|
return uncovered.length > 0 ? 1 : 0;
|
|
152
167
|
} catch (err) {
|
|
153
|
-
|
|
168
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
169
|
+
if (options.json) {
|
|
170
|
+
printJson(jsonError("coverage", [message]));
|
|
171
|
+
} else {
|
|
172
|
+
printError(message);
|
|
173
|
+
}
|
|
154
174
|
return 2;
|
|
155
175
|
}
|
|
156
176
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { getCollections, getRuns, getRunDetail, diagnoseRun, compareRuns } from "../../core/diagnostics/db-analysis.ts";
|
|
2
|
+
import { printError } from "../output.ts";
|
|
3
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
4
|
+
|
|
5
|
+
export interface DbOptions {
|
|
6
|
+
subcommand: string;
|
|
7
|
+
positional: string[];
|
|
8
|
+
limit?: number;
|
|
9
|
+
verbose?: boolean;
|
|
10
|
+
dbPath?: string;
|
|
11
|
+
json?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function dbCommand(options: DbOptions): Promise<number> {
|
|
15
|
+
const { subcommand, positional, json } = options;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
switch (subcommand) {
|
|
19
|
+
case "collections": {
|
|
20
|
+
const collections = getCollections(options.dbPath);
|
|
21
|
+
if (json) {
|
|
22
|
+
printJson(jsonOk("db collections", { collections }));
|
|
23
|
+
} else {
|
|
24
|
+
if (collections.length === 0) {
|
|
25
|
+
console.log("No collections found.");
|
|
26
|
+
} else {
|
|
27
|
+
for (const c of collections) {
|
|
28
|
+
console.log(`[${(c as any).id}] ${(c as any).name} — ${(c as any).test_path ?? "no test path"}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
case "runs": {
|
|
36
|
+
const runs = getRuns(options.limit ?? 10, options.dbPath);
|
|
37
|
+
if (json) {
|
|
38
|
+
printJson(jsonOk("db runs", { runs }));
|
|
39
|
+
} else {
|
|
40
|
+
if (runs.length === 0) {
|
|
41
|
+
console.log("No runs found.");
|
|
42
|
+
} else {
|
|
43
|
+
for (const r of runs) {
|
|
44
|
+
const run = r as any;
|
|
45
|
+
const status = run.failed > 0 ? "FAIL" : "PASS";
|
|
46
|
+
console.log(`#${run.id} ${status} ${run.passed}/${run.total} passed (${run.started_at})`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case "run": {
|
|
54
|
+
const id = parseInt(positional[0] ?? "", 10);
|
|
55
|
+
if (isNaN(id)) {
|
|
56
|
+
const msg = "Missing run ID. Usage: zond db run <id>";
|
|
57
|
+
if (json) printJson(jsonError("db run", [msg]));
|
|
58
|
+
else printError(msg);
|
|
59
|
+
return 2;
|
|
60
|
+
}
|
|
61
|
+
const detail = getRunDetail(id, options.verbose, options.dbPath);
|
|
62
|
+
if (json) {
|
|
63
|
+
printJson(jsonOk("db run", detail));
|
|
64
|
+
} else {
|
|
65
|
+
console.log(JSON.stringify(detail, null, 2));
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
case "diagnose": {
|
|
71
|
+
const id = parseInt(positional[0] ?? "", 10);
|
|
72
|
+
if (isNaN(id)) {
|
|
73
|
+
const msg = "Missing run ID. Usage: zond db diagnose <id>";
|
|
74
|
+
if (json) printJson(jsonError("db diagnose", [msg]));
|
|
75
|
+
else printError(msg);
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
const result = diagnoseRun(id, options.verbose, options.dbPath);
|
|
79
|
+
if (json) {
|
|
80
|
+
printJson(jsonOk("db diagnose", result));
|
|
81
|
+
} else {
|
|
82
|
+
console.log(JSON.stringify(result, null, 2));
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
case "compare": {
|
|
88
|
+
const idA = parseInt(positional[0] ?? "", 10);
|
|
89
|
+
const idB = parseInt(positional[1] ?? "", 10);
|
|
90
|
+
if (isNaN(idA) || isNaN(idB)) {
|
|
91
|
+
const msg = "Missing run IDs. Usage: zond db compare <idA> <idB>";
|
|
92
|
+
if (json) printJson(jsonError("db compare", [msg]));
|
|
93
|
+
else printError(msg);
|
|
94
|
+
return 2;
|
|
95
|
+
}
|
|
96
|
+
const result = compareRuns(idA, idB, options.dbPath);
|
|
97
|
+
if (json) {
|
|
98
|
+
printJson(jsonOk("db compare", result));
|
|
99
|
+
} else {
|
|
100
|
+
console.log(JSON.stringify(result, null, 2));
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
default: {
|
|
106
|
+
const msg = `Unknown db subcommand: ${subcommand}. Available: collections, runs, run, diagnose, compare`;
|
|
107
|
+
if (json) printJson(jsonError("db", [msg]));
|
|
108
|
+
else printError(msg);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
114
|
+
if (json) {
|
|
115
|
+
printJson(jsonError(`db ${subcommand}`, [message]));
|
|
116
|
+
} else {
|
|
117
|
+
printError(message);
|
|
118
|
+
}
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describeEndpoint, describeCompact } from "../../core/generator/describe.ts";
|
|
2
|
+
import { printError } from "../output.ts";
|
|
3
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
4
|
+
|
|
5
|
+
export interface DescribeOptions {
|
|
6
|
+
specPath: string;
|
|
7
|
+
method?: string;
|
|
8
|
+
path?: string;
|
|
9
|
+
compact?: boolean;
|
|
10
|
+
json?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function describeCommand(options: DescribeOptions): Promise<number> {
|
|
14
|
+
try {
|
|
15
|
+
if (options.compact) {
|
|
16
|
+
const endpoints = await describeCompact(options.specPath);
|
|
17
|
+
|
|
18
|
+
if (options.json) {
|
|
19
|
+
printJson(jsonOk("describe", { endpoints }));
|
|
20
|
+
} else {
|
|
21
|
+
for (const ep of endpoints) {
|
|
22
|
+
const parts = [ep.method.padEnd(7), ep.path];
|
|
23
|
+
if (ep.operationId) parts.push(`(${ep.operationId})`);
|
|
24
|
+
if (ep.summary) parts.push(`— ${ep.summary}`);
|
|
25
|
+
if (ep.deprecated) parts.push("[deprecated]");
|
|
26
|
+
console.log(parts.join(" "));
|
|
27
|
+
}
|
|
28
|
+
console.log(`\n${endpoints.length} endpoint(s)`);
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!options.method || !options.path) {
|
|
34
|
+
const msg = "Missing --method and --path. Use --compact for all endpoints, or specify --method and --path for one.";
|
|
35
|
+
if (options.json) {
|
|
36
|
+
printJson(jsonError("describe", [msg]));
|
|
37
|
+
} else {
|
|
38
|
+
printError(msg);
|
|
39
|
+
}
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result = await describeEndpoint(options.specPath, options.method, options.path);
|
|
44
|
+
|
|
45
|
+
if (options.json) {
|
|
46
|
+
printJson(jsonOk("describe", result));
|
|
47
|
+
} else {
|
|
48
|
+
console.log(JSON.stringify(result, null, 2));
|
|
49
|
+
}
|
|
50
|
+
return 0;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
53
|
+
if (options.json) {
|
|
54
|
+
printJson(jsonError("describe", [message]));
|
|
55
|
+
} else {
|
|
56
|
+
printError(message);
|
|
57
|
+
}
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { join, dirname } from "path";
|
|
2
|
+
import { mkdir } from "fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
readOpenApiSpec,
|
|
5
|
+
extractEndpoints,
|
|
6
|
+
extractSecuritySchemes,
|
|
7
|
+
scanCoveredEndpoints,
|
|
8
|
+
filterUncoveredEndpoints,
|
|
9
|
+
serializeSuite,
|
|
10
|
+
} from "../../core/generator/index.ts";
|
|
11
|
+
import { generateSuites } from "../../core/generator/suite-generator.ts";
|
|
12
|
+
import { filterByTag } from "../../core/generator/chunker.ts";
|
|
13
|
+
import { parse } from "../../core/parser/yaml-parser.ts";
|
|
14
|
+
import { printError, printSuccess } from "../output.ts";
|
|
15
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
16
|
+
|
|
17
|
+
export interface GenerateOptions {
|
|
18
|
+
specPath: string;
|
|
19
|
+
output: string;
|
|
20
|
+
tag?: string;
|
|
21
|
+
uncoveredOnly?: boolean;
|
|
22
|
+
json?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function generateCommand(options: GenerateOptions): Promise<number> {
|
|
26
|
+
try {
|
|
27
|
+
const doc = await readOpenApiSpec(options.specPath);
|
|
28
|
+
let endpoints = extractEndpoints(doc);
|
|
29
|
+
const securitySchemes = extractSecuritySchemes(doc);
|
|
30
|
+
const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
|
|
31
|
+
const warnings: string[] = [];
|
|
32
|
+
|
|
33
|
+
// Filter to uncovered only
|
|
34
|
+
if (options.uncoveredOnly) {
|
|
35
|
+
const covered = await scanCoveredEndpoints(options.output);
|
|
36
|
+
const before = endpoints.length;
|
|
37
|
+
endpoints = filterUncoveredEndpoints(endpoints, covered);
|
|
38
|
+
const coveredCount = before - endpoints.length;
|
|
39
|
+
if (coveredCount > 0) {
|
|
40
|
+
warnings.push(`Skipped ${coveredCount} already-covered endpoints`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Filter by tag
|
|
45
|
+
if (options.tag) {
|
|
46
|
+
endpoints = filterByTag(endpoints, options.tag);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (endpoints.length === 0) {
|
|
50
|
+
if (options.json) {
|
|
51
|
+
printJson(jsonOk("generate", { files: [], message: "No endpoints to generate tests for" }, warnings));
|
|
52
|
+
} else {
|
|
53
|
+
console.log("No endpoints to generate tests for.");
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Generate suites
|
|
59
|
+
const suites = generateSuites({ endpoints, securitySchemes });
|
|
60
|
+
|
|
61
|
+
// Ensure output directory exists
|
|
62
|
+
await mkdir(options.output, { recursive: true });
|
|
63
|
+
|
|
64
|
+
// Write suite files
|
|
65
|
+
const createdFiles: Array<{ file: string; suite: string; tests: number }> = [];
|
|
66
|
+
|
|
67
|
+
for (const suite of suites) {
|
|
68
|
+
const yaml = serializeSuite(suite);
|
|
69
|
+
const fileName = `${suite.fileStem ?? suite.name}.yaml`;
|
|
70
|
+
const filePath = join(options.output, fileName);
|
|
71
|
+
await Bun.write(filePath, yaml);
|
|
72
|
+
createdFiles.push({ file: filePath, suite: suite.name, tests: suite.tests.length });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Create .env.yaml with base_url if it doesn't exist
|
|
76
|
+
const envPath = join(options.output, ".env.yaml");
|
|
77
|
+
const envFile = Bun.file(envPath);
|
|
78
|
+
if (!(await envFile.exists()) && baseUrl) {
|
|
79
|
+
await Bun.write(envPath, `base_url: ${baseUrl}\n`);
|
|
80
|
+
warnings.push(`Created ${envPath} with base_url from spec`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Validate generated files
|
|
84
|
+
const validationErrors: string[] = [];
|
|
85
|
+
try {
|
|
86
|
+
await parse(options.output);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
validationErrors.push(err instanceof Error ? err.message : String(err));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (validationErrors.length > 0) {
|
|
92
|
+
warnings.push(`Validation warnings: ${validationErrors.join("; ")}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Output
|
|
96
|
+
const totalTests = createdFiles.reduce((sum, f) => sum + f.tests, 0);
|
|
97
|
+
|
|
98
|
+
if (options.json) {
|
|
99
|
+
printJson(jsonOk("generate", {
|
|
100
|
+
files: createdFiles,
|
|
101
|
+
totalSuites: suites.length,
|
|
102
|
+
totalTests,
|
|
103
|
+
outputDir: options.output,
|
|
104
|
+
}, warnings));
|
|
105
|
+
} else {
|
|
106
|
+
printSuccess(`Generated ${suites.length} suite(s) with ${totalTests} test(s) in ${options.output}`);
|
|
107
|
+
for (const f of createdFiles) {
|
|
108
|
+
console.log(` ${f.file} (${f.tests} tests)`);
|
|
109
|
+
}
|
|
110
|
+
if (warnings.length > 0) {
|
|
111
|
+
for (const w of warnings) {
|
|
112
|
+
console.log(` ⚠ ${w}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return 0;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
120
|
+
if (options.json) {
|
|
121
|
+
printJson(jsonError("generate", [message]));
|
|
122
|
+
} else {
|
|
123
|
+
printError(message);
|
|
124
|
+
}
|
|
125
|
+
return 2;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readOpenApiSpec,
|
|
3
|
+
extractEndpoints,
|
|
4
|
+
extractSecuritySchemes,
|
|
5
|
+
scanCoveredEndpoints,
|
|
6
|
+
filterUncoveredEndpoints,
|
|
7
|
+
} from "../../core/generator/index.ts";
|
|
8
|
+
import { compressEndpointsWithSchemas, buildGenerationGuide } from "../../core/generator/guide-builder.ts";
|
|
9
|
+
import { planChunks, filterByTag } from "../../core/generator/chunker.ts";
|
|
10
|
+
import { findCollectionBySpec } from "../../db/queries.ts";
|
|
11
|
+
import { printError } from "../output.ts";
|
|
12
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
13
|
+
|
|
14
|
+
export interface GuideOptions {
|
|
15
|
+
specPath: string;
|
|
16
|
+
testsDir?: string;
|
|
17
|
+
tag?: string;
|
|
18
|
+
json?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function guideCommand(options: GuideOptions): Promise<number> {
|
|
22
|
+
try {
|
|
23
|
+
const doc = await readOpenApiSpec(options.specPath);
|
|
24
|
+
let endpoints = extractEndpoints(doc);
|
|
25
|
+
const securitySchemes = extractSecuritySchemes(doc);
|
|
26
|
+
const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
|
|
27
|
+
const title = (doc as any).info?.title as string | undefined;
|
|
28
|
+
|
|
29
|
+
let outputDir = options.testsDir;
|
|
30
|
+
if (!outputDir) {
|
|
31
|
+
try {
|
|
32
|
+
const collection = findCollectionBySpec(options.specPath);
|
|
33
|
+
outputDir = collection?.test_path ?? "./tests/";
|
|
34
|
+
} catch {
|
|
35
|
+
outputDir = "./tests/";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let coverageInfo: { covered: number; total: number; percentage: number } | undefined;
|
|
40
|
+
if (options.testsDir) {
|
|
41
|
+
const totalBefore = endpoints.length;
|
|
42
|
+
const covered = await scanCoveredEndpoints(options.testsDir);
|
|
43
|
+
const uncovered = filterUncoveredEndpoints(endpoints, covered);
|
|
44
|
+
const coveredCount = totalBefore - uncovered.length;
|
|
45
|
+
const percentage = totalBefore > 0 ? Math.round((coveredCount / totalBefore) * 100) : 100;
|
|
46
|
+
coverageInfo = { covered: coveredCount, total: totalBefore, percentage };
|
|
47
|
+
endpoints = uncovered;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (endpoints.length === 0) {
|
|
51
|
+
if (options.json) {
|
|
52
|
+
printJson(jsonOk("guide", { fullyCovered: true, ...coverageInfo }));
|
|
53
|
+
} else {
|
|
54
|
+
console.log("All endpoints are covered.");
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (options.tag) {
|
|
60
|
+
endpoints = filterByTag(endpoints, options.tag);
|
|
61
|
+
if (endpoints.length === 0) {
|
|
62
|
+
const msg = `No endpoints found for tag "${options.tag}"`;
|
|
63
|
+
if (options.json) printJson(jsonError("guide", [msg]));
|
|
64
|
+
else printError(msg);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const plan = planChunks(endpoints);
|
|
70
|
+
|
|
71
|
+
if (plan.needsChunking && !options.tag) {
|
|
72
|
+
if (options.json) {
|
|
73
|
+
printJson(jsonOk("guide", {
|
|
74
|
+
mode: "plan",
|
|
75
|
+
title: title ?? "API",
|
|
76
|
+
totalEndpoints: plan.totalEndpoints,
|
|
77
|
+
chunks: plan.chunks,
|
|
78
|
+
...(coverageInfo ? { coverage: coverageInfo } : {}),
|
|
79
|
+
}));
|
|
80
|
+
} else {
|
|
81
|
+
console.log(`API has ${plan.totalEndpoints} endpoints across ${plan.chunks.length} tags.`);
|
|
82
|
+
console.log("Generate per-tag with --tag <name>:\n");
|
|
83
|
+
for (const chunk of plan.chunks) {
|
|
84
|
+
console.log(` --tag ${chunk.tag} (${chunk.count} endpoints)`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const coverageHeader = coverageInfo
|
|
91
|
+
? `## Coverage: ${coverageInfo.covered}/${coverageInfo.total} endpoints covered (${coverageInfo.percentage}%). Generating tests for ${endpoints.length} uncovered endpoints:`
|
|
92
|
+
: undefined;
|
|
93
|
+
|
|
94
|
+
const apiContext = compressEndpointsWithSchemas(endpoints, securitySchemes);
|
|
95
|
+
const guide = buildGenerationGuide({
|
|
96
|
+
title: options.tag ? `${title ?? "API"} — tag: ${options.tag}` : (title ?? "API"),
|
|
97
|
+
baseUrl,
|
|
98
|
+
apiContext,
|
|
99
|
+
outputDir,
|
|
100
|
+
securitySchemes,
|
|
101
|
+
endpointCount: endpoints.length,
|
|
102
|
+
coverageHeader,
|
|
103
|
+
includeFormat: true,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (options.json) {
|
|
107
|
+
printJson(jsonOk("guide", {
|
|
108
|
+
title: title ?? "API",
|
|
109
|
+
endpointCount: endpoints.length,
|
|
110
|
+
outputDir,
|
|
111
|
+
guide,
|
|
112
|
+
...(coverageInfo ? { coverage: coverageInfo } : {}),
|
|
113
|
+
}));
|
|
114
|
+
} else {
|
|
115
|
+
console.log(guide);
|
|
116
|
+
}
|
|
117
|
+
return 0;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
120
|
+
if (options.json) {
|
|
121
|
+
printJson(jsonError("guide", [message]));
|
|
122
|
+
} else {
|
|
123
|
+
printError(message);
|
|
124
|
+
}
|
|
125
|
+
return 2;
|
|
126
|
+
}
|
|
127
|
+
}
|