@kirrosh/zond 0.14.0 → 0.17.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +132 -112
  2. package/README.md +3 -10
  3. package/package.json +4 -4
  4. package/src/cli/commands/ci-init.ts +12 -1
  5. package/src/cli/commands/coverage.ts +21 -1
  6. package/src/cli/commands/db.ts +121 -0
  7. package/src/cli/commands/describe.ts +60 -0
  8. package/src/cli/commands/export.ts +144 -0
  9. package/src/cli/commands/generate.ts +158 -0
  10. package/src/cli/commands/guide.ts +127 -0
  11. package/src/cli/commands/init.ts +57 -0
  12. package/src/cli/commands/request.ts +57 -0
  13. package/src/cli/commands/run.ts +74 -14
  14. package/src/cli/commands/serve.ts +62 -3
  15. package/src/cli/commands/sync.ts +240 -0
  16. package/src/cli/commands/validate.ts +18 -2
  17. package/src/cli/index.ts +258 -17
  18. package/src/cli/json-envelope.ts +19 -0
  19. package/src/core/diagnostics/db-analysis.ts +423 -0
  20. package/src/core/diagnostics/failure-hints.ts +40 -0
  21. package/src/core/exporter/postman.ts +963 -0
  22. package/src/core/generator/data-factory.ts +55 -9
  23. package/src/core/generator/describe.ts +250 -0
  24. package/src/core/generator/guide-builder.ts +20 -0
  25. package/src/core/generator/index.ts +1 -1
  26. package/src/core/generator/openapi-reader.ts +6 -0
  27. package/src/core/generator/serializer.ts +17 -2
  28. package/src/core/generator/suite-generator.ts +291 -29
  29. package/src/core/generator/types.ts +1 -0
  30. package/src/core/meta/meta-store.ts +78 -0
  31. package/src/core/meta/types.ts +21 -0
  32. package/src/core/parser/schema.ts +12 -2
  33. package/src/core/parser/types.ts +12 -1
  34. package/src/core/parser/variables.ts +3 -0
  35. package/src/core/parser/yaml-parser.ts +2 -1
  36. package/src/core/runner/assertions.ts +44 -20
  37. package/src/core/runner/execute-run.ts +31 -8
  38. package/src/core/runner/executor.ts +35 -8
  39. package/src/core/runner/http-client.ts +1 -1
  40. package/src/core/runner/send-request.ts +94 -0
  41. package/src/core/runner/types.ts +2 -0
  42. package/src/core/sync/spec-differ.ts +38 -0
  43. package/src/db/queries.ts +4 -2
  44. package/src/db/schema.ts +11 -3
  45. package/src/web/views/suites-tab.ts +1 -1
  46. package/src/cli/commands/mcp.ts +0 -16
  47. package/src/mcp/descriptions.ts +0 -71
  48. package/src/mcp/server.ts +0 -45
  49. package/src/mcp/tools/ci-init.ts +0 -54
  50. package/src/mcp/tools/coverage-analysis.ts +0 -141
  51. package/src/mcp/tools/describe-endpoint.ts +0 -242
  52. package/src/mcp/tools/generate-and-save.ts +0 -202
  53. package/src/mcp/tools/manage-server.ts +0 -86
  54. package/src/mcp/tools/query-db.ts +0 -300
  55. package/src/mcp/tools/run-tests.ts +0 -115
  56. package/src/mcp/tools/save-test-suite.ts +0 -218
  57. package/src/mcp/tools/send-request.ts +0 -97
  58. package/src/mcp/tools/set-work-dir.ts +0 -35
  59. package/src/mcp/tools/setup-api.ts +0 -88
@@ -1,54 +0,0 @@
1
- import { z } from "zod";
2
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { ciInitCommand } from "../../cli/commands/ci-init.ts";
4
- import { TOOL_DESCRIPTIONS } from "../descriptions.js";
5
-
6
- export function registerCiInitTool(server: McpServer) {
7
- server.registerTool("ci_init", {
8
- description: TOOL_DESCRIPTIONS.ci_init,
9
- inputSchema: {
10
- platform: z.optional(z.enum(["github", "gitlab"]))
11
- .describe("CI platform. If omitted, auto-detects from project structure (defaults to GitHub)"),
12
- force: z.optional(z.boolean())
13
- .describe("Overwrite existing CI config (default: false)"),
14
- dir: z.optional(z.string())
15
- .describe("Project root directory where CI config will be created (default: current working directory)"),
16
- },
17
- }, async ({ platform, force, dir }) => {
18
- // Capture stdout to return as result
19
- const logs: string[] = [];
20
- const origWrite = process.stdout.write;
21
- process.stdout.write = ((chunk: string | Uint8Array) => {
22
- logs.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
23
- return true;
24
- }) as typeof process.stdout.write;
25
-
26
- try {
27
- const code = await ciInitCommand({
28
- platform,
29
- force: force ?? false,
30
- dir,
31
- });
32
-
33
- process.stdout.write = origWrite;
34
-
35
- const output = logs.join("").trim();
36
- if (code !== 0) {
37
- return {
38
- content: [{ type: "text" as const, text: JSON.stringify({ error: output || "ci init failed", exitCode: code }, null, 2) }],
39
- isError: true,
40
- };
41
- }
42
-
43
- return {
44
- content: [{ type: "text" as const, text: JSON.stringify({ message: output, exitCode: 0 }, null, 2) }],
45
- };
46
- } catch (err) {
47
- process.stdout.write = origWrite;
48
- return {
49
- content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
50
- isError: true,
51
- };
52
- }
53
- });
54
- }
@@ -1,141 +0,0 @@
1
- import { z } from "zod";
2
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { readOpenApiSpec, extractEndpoints, scanCoveredEndpoints, filterUncoveredEndpoints, normalizePath, specPathToRegex, analyzeEndpoints } from "../../core/generator/index.ts";
4
- import { getDb } from "../../db/schema.ts";
5
- import { getResultsByRunId, getRunById } from "../../db/queries.ts";
6
- import { TOOL_DESCRIPTIONS } from "../descriptions.js";
7
-
8
- function extractPathFromUrl(url: string): string | null {
9
- try {
10
- return new URL(url).pathname;
11
- } catch {
12
- // If not a full URL, treat as path directly
13
- return url.startsWith("/") ? url : null;
14
- }
15
- }
16
-
17
- export function registerCoverageAnalysisTool(server: McpServer, dbPath?: string) {
18
- server.registerTool("coverage_analysis", {
19
- description: TOOL_DESCRIPTIONS.coverage_analysis,
20
- inputSchema: {
21
- specPath: z.string().describe("Path to OpenAPI spec file (JSON or YAML)"),
22
- testsDir: z.string().describe("Path to directory with test YAML files"),
23
- failThreshold: z.optional(z.number().min(0).max(100)).describe("Return isError when coverage % is below this threshold (0–100)"),
24
- runId: z.optional(z.number().int()).describe("Run ID to cross-reference test results for pass/fail/5xx breakdown"),
25
- },
26
- }, async ({ specPath, testsDir, failThreshold, runId }) => {
27
- try {
28
- const doc = await readOpenApiSpec(specPath);
29
- const allEndpoints = extractEndpoints(doc);
30
-
31
- if (allEndpoints.length === 0) {
32
- return {
33
- content: [{ type: "text" as const, text: JSON.stringify({ error: "No endpoints found in the spec" }, null, 2) }],
34
- isError: true,
35
- };
36
- }
37
-
38
- const covered = await scanCoveredEndpoints(testsDir);
39
- const uncovered = filterUncoveredEndpoints(allEndpoints, covered);
40
- const coveredCount = allEndpoints.length - uncovered.length;
41
- const percentage = Math.round((coveredCount / allEndpoints.length) * 100);
42
-
43
- // Static warnings
44
- const warnings = analyzeEndpoints(allEndpoints);
45
-
46
- const result: Record<string, unknown> = {
47
- totalEndpoints: allEndpoints.length,
48
- covered: coveredCount,
49
- uncovered: uncovered.length,
50
- percentage,
51
- uncoveredEndpoints: uncovered.map(ep => ({
52
- method: ep.method,
53
- path: ep.path,
54
- summary: ep.summary,
55
- tags: ep.tags,
56
- })),
57
- coveredEndpoints: covered.map(ep => ({
58
- method: ep.method,
59
- path: ep.path,
60
- file: ep.file,
61
- })),
62
- };
63
-
64
- if (warnings.length > 0) {
65
- result.warnings = warnings;
66
- }
67
-
68
- // Enriched breakdown when runId is provided
69
- if (runId != null) {
70
- getDb(dbPath);
71
- const run = getRunById(runId);
72
- if (!run) {
73
- return {
74
- content: [{ type: "text" as const, text: JSON.stringify({ error: `Run ${runId} not found` }, null, 2) }],
75
- isError: true,
76
- };
77
- }
78
-
79
- const results = getResultsByRunId(runId);
80
-
81
- // Build a map: spec endpoint → status classification
82
- const endpointStatus = new Map<string, "passing" | "api_error" | "test_failed">();
83
-
84
- for (const r of results) {
85
- if (!r.request_url || !r.request_method) continue;
86
- const urlPath = extractPathFromUrl(r.request_url);
87
- if (!urlPath) continue;
88
- const normalizedUrl = normalizePath(urlPath);
89
-
90
- // Find matching spec endpoint
91
- for (const ep of allEndpoints) {
92
- const regex = specPathToRegex(ep.path);
93
- if (r.request_method === ep.method && regex.test(normalizedUrl)) {
94
- const key = `${ep.method} ${ep.path}`;
95
- const existing = endpointStatus.get(key);
96
-
97
- // Worst status wins: api_error > test_failed > passing
98
- if (r.response_status !== null && r.response_status >= 500) {
99
- endpointStatus.set(key, "api_error");
100
- } else if (r.status === "fail" || r.status === "error") {
101
- if (existing !== "api_error") {
102
- endpointStatus.set(key, "test_failed");
103
- }
104
- } else if (!existing) {
105
- endpointStatus.set(key, "passing");
106
- }
107
- break;
108
- }
109
- }
110
- }
111
-
112
- let passing = 0;
113
- let apiError = 0;
114
- let testFailed = 0;
115
- for (const status of endpointStatus.values()) {
116
- if (status === "passing") passing++;
117
- else if (status === "api_error") apiError++;
118
- else if (status === "test_failed") testFailed++;
119
- }
120
-
121
- result.enriched = {
122
- passing,
123
- api_error: apiError,
124
- test_failed: testFailed,
125
- not_covered: uncovered.length,
126
- };
127
- }
128
-
129
- const belowThreshold = failThreshold !== undefined && percentage < failThreshold;
130
- return {
131
- content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
132
- ...(belowThreshold ? { isError: true } : {}),
133
- };
134
- } catch (err) {
135
- return {
136
- content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
137
- isError: true,
138
- };
139
- }
140
- });
141
- }
@@ -1,242 +0,0 @@
1
- import { z } from "zod";
2
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import type { OpenAPIV3 } from "openapi-types";
4
- import { readOpenApiSpec } from "../../core/generator/index.ts";
5
- import { decycleSchema } from "../../core/generator/schema-utils.ts";
6
- import { TOOL_DESCRIPTIONS } from "../descriptions.js";
7
-
8
- function generateTestSnippet(params: {
9
- method: string;
10
- path: string;
11
- operationId?: string;
12
- pathParams: string[];
13
- queryParams: Array<{ name: string; required?: boolean }>;
14
- requestBody?: { required?: boolean; schema?: OpenAPIV3.SchemaObject };
15
- hasSecurity: boolean;
16
- successStatus: string;
17
- }): string {
18
- const { method, path, operationId, pathParams, queryParams, requestBody, hasSecurity, successStatus } = params;
19
-
20
- // Build URL with path params as {{paramName}}
21
- const urlPath = path.replace(/\{([^}]+)\}/g, (_, name) => `{{${name}}}`);
22
- const url = `{{base_url}}${urlPath}`;
23
-
24
- const lines: string[] = [];
25
- const testName = operationId ?? `${method} ${path}`;
26
- lines.push(`- name: "${testName}"`);
27
- lines.push(` ${method}: "${url}"`);
28
-
29
- if (hasSecurity) {
30
- lines.push(` headers:`);
31
- lines.push(` Authorization: "Bearer {{auth_token}}"`);
32
- }
33
-
34
- // Required query params
35
- const requiredQuery = queryParams.filter(p => p.required);
36
- if (requiredQuery.length > 0) {
37
- lines.push(` query:`);
38
- for (const p of requiredQuery) {
39
- lines.push(` ${p.name}: "{{${p.name}}}"`);
40
- }
41
- }
42
-
43
- // Request body for POST/PUT/PATCH
44
- if (requestBody && ["POST", "PUT", "PATCH"].includes(method)) {
45
- const schema = requestBody.schema as OpenAPIV3.SchemaObject | undefined;
46
- const required = Array.isArray(schema?.required) ? schema.required : [];
47
- const properties = schema?.properties as Record<string, OpenAPIV3.SchemaObject> | undefined;
48
- if (properties && Object.keys(properties).length > 0) {
49
- lines.push(` json:`);
50
- for (const [propName, propSchema] of Object.entries(properties)) {
51
- if (!required.includes(propName)) continue;
52
- const type = (propSchema as OpenAPIV3.SchemaObject).type ?? "string";
53
- const placeholder = type === "integer" || type === "number" ? 0 : type === "boolean" ? false : `"{{${propName}}}"`;
54
- lines.push(` ${propName}: ${placeholder}`);
55
- }
56
- }
57
- }
58
-
59
- lines.push(` expect:`);
60
- lines.push(` status: ${successStatus}`);
61
-
62
- return lines.join("\n");
63
- }
64
-
65
- export function registerDescribeEndpointTool(server: McpServer) {
66
- server.registerTool("describe_endpoint", {
67
- description: TOOL_DESCRIPTIONS.describe_endpoint,
68
- inputSchema: {
69
- specPath: z.string().describe("Path to OpenAPI spec file (JSON or YAML) or HTTP URL"),
70
- method: z.string().describe('HTTP method, e.g. "GET", "POST", "PUT"'),
71
- path: z.string().describe('Endpoint path, e.g. "/pets/{petId}"'),
72
- },
73
- }, async ({ specPath, method, path: endpointPath }) => {
74
- try {
75
- const doc = await readOpenApiSpec(specPath) as OpenAPIV3.Document;
76
-
77
- // Normalize inputs
78
- const methodLower = method.toLowerCase() as OpenAPIV3.HttpMethods;
79
- const normalizedPath = endpointPath.replace(/\/+$/, "") || "/";
80
-
81
- // Find operation — try exact match first, then case-insensitive path match
82
- let operation: OpenAPIV3.OperationObject | undefined;
83
- let resolvedPath = normalizedPath;
84
-
85
- const paths = doc.paths ?? {};
86
-
87
- if (paths[normalizedPath]?.[methodLower]) {
88
- operation = paths[normalizedPath][methodLower] as OpenAPIV3.OperationObject;
89
- } else {
90
- // Case-insensitive fallback
91
- const lowerTarget = normalizedPath.toLowerCase();
92
- for (const [p, pathItem] of Object.entries(paths)) {
93
- if (p.toLowerCase() === lowerTarget && pathItem?.[methodLower]) {
94
- operation = pathItem[methodLower] as OpenAPIV3.OperationObject;
95
- resolvedPath = p;
96
- break;
97
- }
98
- }
99
- }
100
-
101
- if (!operation) {
102
- const available = Object.entries(paths).flatMap(([p, pathItem]) =>
103
- Object.keys(pathItem ?? {})
104
- .filter(k => ["get","post","put","patch","delete","head","options","trace"].includes(k))
105
- .map(k => `${k.toUpperCase()} ${p}`)
106
- ).sort();
107
- return {
108
- content: [{
109
- type: "text" as const,
110
- text: JSON.stringify({
111
- error: `Endpoint ${method.toUpperCase()} ${endpointPath} not found in spec`,
112
- availableEndpoints: available,
113
- }, null, 2),
114
- }],
115
- isError: true,
116
- };
117
- }
118
-
119
- const pathItem = paths[resolvedPath] ?? {};
120
-
121
- // Merge path-level and operation-level parameters (operation overrides by name+in)
122
- const pathLevelParams = (pathItem.parameters ?? []) as OpenAPIV3.ParameterObject[];
123
- const opLevelParams = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[];
124
-
125
- const paramMap = new Map<string, OpenAPIV3.ParameterObject>();
126
- for (const p of pathLevelParams) paramMap.set(`${p.in}:${p.name}`, p);
127
- for (const p of opLevelParams) paramMap.set(`${p.in}:${p.name}`, p); // operation overrides
128
-
129
- // Group by "in"
130
- const grouped: Record<string, object[]> = { path: [], query: [], header: [], cookie: [] };
131
- for (const p of paramMap.values()) {
132
- const loc = p.in in grouped ? p.in : "query";
133
- const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
134
- grouped[loc]!.push({
135
- name: p.name,
136
- required: p.required ?? false,
137
- ...(schema?.type ? { type: schema.type } : {}),
138
- ...(schema?.format ? { format: schema.format } : {}),
139
- ...(schema?.enum ? { enum: schema.enum } : {}),
140
- ...(schema?.default !== undefined ? { default: schema.default } : {}),
141
- ...(p.description ? { description: p.description } : {}),
142
- });
143
- }
144
-
145
- // Request body
146
- let requestBody: object | undefined;
147
- if (operation.requestBody) {
148
- const rb = operation.requestBody as OpenAPIV3.RequestBodyObject;
149
- const contentTypes = Object.keys(rb.content ?? {});
150
- const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
151
- const mediaObj = preferredCt ? rb.content[preferredCt] : undefined;
152
- requestBody = {
153
- required: rb.required ?? false,
154
- ...(preferredCt ? { contentType: preferredCt } : {}),
155
- ...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
156
- ...(rb.description ? { description: rb.description } : {}),
157
- };
158
- }
159
-
160
- // Responses
161
- const responses: Record<string, object> = {};
162
- for (const [statusCode, respObj] of Object.entries(operation.responses ?? {})) {
163
- const resp = respObj as OpenAPIV3.ResponseObject;
164
- const contentTypes = Object.keys(resp.content ?? {});
165
- const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
166
- const mediaObj = preferredCt ? resp.content?.[preferredCt] : undefined;
167
-
168
- // Response headers
169
- const headers: Record<string, object> = {};
170
- for (const [hName, hObj] of Object.entries(resp.headers ?? {})) {
171
- const h = hObj as OpenAPIV3.HeaderObject;
172
- headers[hName] = {
173
- ...(h.description ? { description: h.description } : {}),
174
- ...(h.schema ? { schema: h.schema } : {}),
175
- };
176
- }
177
-
178
- responses[statusCode] = {
179
- description: resp.description,
180
- headers,
181
- ...(preferredCt ? { contentType: preferredCt } : {}),
182
- ...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
183
- };
184
- }
185
-
186
- // Security — merge doc-level and operation-level
187
- const docSecurity = (doc.security ?? []) as OpenAPIV3.SecurityRequirementObject[];
188
- const opSecurity = (operation.security ?? docSecurity) as OpenAPIV3.SecurityRequirementObject[];
189
- const securityNames = [...new Set(opSecurity.flatMap(req => Object.keys(req)))];
190
-
191
- // Derive success status (first 2xx, or first response code)
192
- const responseCodes = Object.keys(operation.responses ?? {});
193
- const successStatus = responseCodes.find(c => c.startsWith("2")) ?? responseCodes[0] ?? "200";
194
-
195
- // Build testSnippet
196
- const pathParamNames = [...paramMap.values()]
197
- .filter(p => p.in === "path")
198
- .map(p => p.name);
199
- const queryParamsList = [...paramMap.values()]
200
- .filter(p => p.in === "query")
201
- .map(p => ({ name: p.name, required: p.required }));
202
- const reqBodyForSnippet = requestBody
203
- ? { required: (operation.requestBody as OpenAPIV3.RequestBodyObject)?.required, schema: (requestBody as any).schema }
204
- : undefined;
205
-
206
- const testSnippet = generateTestSnippet({
207
- method: method.toUpperCase(),
208
- path: resolvedPath,
209
- operationId: operation.operationId,
210
- pathParams: pathParamNames,
211
- queryParams: queryParamsList,
212
- requestBody: reqBodyForSnippet,
213
- hasSecurity: securityNames.length > 0,
214
- successStatus,
215
- });
216
-
217
- const result = {
218
- method: method.toUpperCase(),
219
- path: resolvedPath,
220
- ...(operation.operationId ? { operationId: operation.operationId } : {}),
221
- ...(operation.summary ? { summary: operation.summary } : {}),
222
- ...(operation.description ? { description: operation.description } : {}),
223
- ...(operation.tags?.length ? { tags: operation.tags } : {}),
224
- deprecated: operation.deprecated ?? false,
225
- security: securityNames,
226
- parameters: grouped,
227
- ...(requestBody ? { requestBody } : {}),
228
- responses,
229
- testSnippet,
230
- };
231
-
232
- return {
233
- content: [{ type: "text" as const, text: JSON.stringify(decycleSchema(result), null, 2) }],
234
- };
235
- } catch (err) {
236
- return {
237
- content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
238
- isError: true,
239
- };
240
- }
241
- });
242
- }
@@ -1,202 +0,0 @@
1
- import { z } from "zod";
2
- import { join } from "node:path";
3
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
- import {
5
- readOpenApiSpec,
6
- extractEndpoints,
7
- extractSecuritySchemes,
8
- scanCoveredEndpoints,
9
- filterUncoveredEndpoints,
10
- serializeSuite,
11
- generateSuites,
12
- findUnresolvedVars,
13
- } from "../../core/generator/index.ts";
14
- import { loadEnvironment } from "../../core/parser/variables.ts";
15
- import { compressEndpointsWithSchemas, buildGenerationGuide } from "../../core/generator/guide-builder.ts";
16
- import { findCollectionBySpec } from "../../db/queries.ts";
17
- import { planChunks, filterByTag } from "../../core/generator/chunker.ts";
18
- import { TOOL_DESCRIPTIONS } from "../descriptions.js";
19
- import { validateAndSave } from "./save-test-suite.ts";
20
-
21
- export function registerGenerateAndSaveTool(server: McpServer) {
22
- server.registerTool("generate_and_save", {
23
- description: TOOL_DESCRIPTIONS.generate_and_save,
24
- inputSchema: {
25
- specPath: z.string().describe("Path or URL to OpenAPI spec file"),
26
- outputDir: z.optional(z.string()).describe("Directory for saving test files (default: ./tests/)"),
27
- tag: z.optional(z.string()).describe("Generate tests only for this tag's endpoints"),
28
- methodFilter: z.optional(z.array(z.string())).describe("Only include endpoints with these HTTP methods (e.g. [\"GET\"] for smoke tests)"),
29
- testsDir: z.optional(z.string()).describe("Path to existing tests directory — filters to uncovered endpoints only"),
30
- overwrite: z.optional(z.boolean()).describe("Hint for save_test_suites overwrite behavior (default: false)"),
31
- includeFormat: z.optional(z.boolean()).describe("Include YAML format reference (default: true, set false for subsequent tag chunks)"),
32
- mode: z.optional(z.enum(["generate", "guide"])).describe(
33
- "'generate' creates and saves YAML test files deterministically (default), 'guide' returns text for LLM-crafted tests"
34
- ),
35
- },
36
- }, async ({ specPath, outputDir, tag, methodFilter, testsDir, overwrite, includeFormat, mode }) => {
37
- try {
38
- const doc = await readOpenApiSpec(specPath);
39
- let endpoints = extractEndpoints(doc);
40
- const securitySchemes = extractSecuritySchemes(doc);
41
- const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
42
- const title = (doc as any).info?.title as string | undefined;
43
- let effectiveOutputDir = outputDir;
44
- if (!effectiveOutputDir) {
45
- const collection = findCollectionBySpec(specPath);
46
- effectiveOutputDir = collection?.test_path ?? "./tests/";
47
- }
48
- const effectiveMode = mode ?? "generate";
49
-
50
- // Apply method filter
51
- if (methodFilter && methodFilter.length > 0) {
52
- const methods = methodFilter.map(m => m.toUpperCase());
53
- endpoints = endpoints.filter(ep => methods.includes(ep.method.toUpperCase()));
54
- }
55
-
56
- // Coverage filtering
57
- let coverageInfo: { covered: number; total: number; percentage: number } | undefined;
58
- if (testsDir) {
59
- const totalBefore = endpoints.length;
60
- const covered = await scanCoveredEndpoints(testsDir);
61
- const uncovered = filterUncoveredEndpoints(endpoints, covered);
62
- const coveredCount = totalBefore - uncovered.length;
63
- const percentage = totalBefore > 0 ? Math.round((coveredCount / totalBefore) * 100) : 100;
64
- coverageInfo = { covered: coveredCount, total: totalBefore, percentage };
65
- endpoints = uncovered;
66
- }
67
-
68
- if (endpoints.length === 0) {
69
- const msg = testsDir
70
- ? { fullyCovered: true, ...coverageInfo }
71
- : { error: "No endpoints found in the spec" };
72
- return {
73
- content: [{ type: "text" as const, text: JSON.stringify(msg, null, 2) }],
74
- isError: !testsDir,
75
- };
76
- }
77
-
78
- // Tag filtering
79
- if (tag) {
80
- endpoints = filterByTag(endpoints, tag);
81
- if (endpoints.length === 0) {
82
- return {
83
- content: [{ type: "text" as const, text: JSON.stringify({ error: `No endpoints found for tag "${tag}"` }, null, 2) }],
84
- isError: true,
85
- };
86
- }
87
- }
88
-
89
- const plan = planChunks(endpoints);
90
-
91
- // Plan mode: large API without specific tag
92
- if (plan.needsChunking && !tag) {
93
- const result: Record<string, unknown> = {
94
- mode: "plan",
95
- title: title ?? "API",
96
- totalEndpoints: plan.totalEndpoints,
97
- chunks: plan.chunks,
98
- instruction:
99
- `This API has ${plan.totalEndpoints} endpoints across ${plan.chunks.length} tags. ` +
100
- `Call generate_and_save with tag parameter for each chunk sequentially. ` +
101
- (effectiveMode === "guide"
102
- ? `Pass includeFormat: false for subsequent chunks to save tokens. `
103
- : "") +
104
- `Example: generate_and_save(specPath: '${specPath}', tag: '${plan.chunks[0]!.tag}'` +
105
- (effectiveMode === "guide" ? `, mode: 'guide'` : "") + `)`,
106
- };
107
- if (coverageInfo) {
108
- result.coverage = coverageInfo;
109
- }
110
- return {
111
- content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
112
- };
113
- }
114
-
115
- // ── Generate mode: deterministic YAML generation ──
116
- if (effectiveMode === "generate") {
117
- const suites = generateSuites({ endpoints, securitySchemes });
118
-
119
- const files: Array<{
120
- saved: boolean;
121
- filePath: string;
122
- tests: number;
123
- error?: string;
124
- }> = [];
125
-
126
- for (const suite of suites) {
127
- const yaml = serializeSuite(suite);
128
- const fileName = (suite.fileStem ?? suite.name) + ".yaml";
129
- const filePath = join(effectiveOutputDir, fileName);
130
-
131
- const { result: saveResult } = await validateAndSave(filePath, yaml, overwrite ?? false);
132
- files.push({
133
- saved: saveResult.saved,
134
- filePath: saveResult.filePath ?? filePath,
135
- tests: suite.tests.length,
136
- ...(saveResult.error ? { error: saveResult.error } : {}),
137
- });
138
- }
139
-
140
- const warnings: string[] = [];
141
- const env = await loadEnvironment(undefined, effectiveOutputDir);
142
- const envKeys = new Set(Object.keys(env));
143
- for (const suite of suites) {
144
- const unresolved = findUnresolvedVars(suite, envKeys);
145
- if (unresolved.length > 0)
146
- warnings.push(`${suite.fileStem ?? suite.name}.yaml: unresolved [${unresolved.join(", ")}]`);
147
- }
148
-
149
- const response: Record<string, unknown> = {
150
- mode: "generate",
151
- suitesGenerated: suites.length,
152
- files,
153
- ...(warnings.length > 0 ? { warnings } : {}),
154
- hint: files.some(f => !f.saved)
155
- ? "Some files were not saved (already exist?). Use overwrite: true to replace."
156
- : "Files saved. Run run_tests to verify. Use mode: 'guide' for LLM-crafted tests with more detail.",
157
- };
158
- if (coverageInfo) {
159
- response.coverage = coverageInfo;
160
- }
161
-
162
- return {
163
- content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }],
164
- };
165
- }
166
-
167
- // ── Guide mode: text-based generation guide ──
168
- const coverageHeader = coverageInfo
169
- ? `## Coverage: ${coverageInfo.covered}/${coverageInfo.total} endpoints covered (${coverageInfo.percentage}%). Generating tests for ${endpoints.length} uncovered endpoints:`
170
- : undefined;
171
-
172
- const apiContext = compressEndpointsWithSchemas(endpoints, securitySchemes);
173
- const guide = buildGenerationGuide({
174
- title: tag ? `${title ?? "API"} — tag: ${tag}` : (title ?? "API"),
175
- baseUrl,
176
- apiContext,
177
- outputDir: effectiveOutputDir,
178
- securitySchemes,
179
- endpointCount: endpoints.length,
180
- coverageHeader,
181
- includeFormat: includeFormat ?? true,
182
- });
183
-
184
- const saveInstructions = `
185
- ---
186
- ## Save Instructions
187
- - Output directory: ${effectiveOutputDir}
188
- - Use \`save_test_suites\` to save all generated files in one call
189
- - Overwrite: ${overwrite ? "true" : "false (set overwrite: true in save_test_suites to replace existing files)"}
190
- - After saving, run \`run_tests\` to verify`;
191
-
192
- return {
193
- content: [{ type: "text" as const, text: guide + saveInstructions }],
194
- };
195
- } catch (err) {
196
- return {
197
- content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
198
- isError: true,
199
- };
200
- }
201
- });
202
- }