@kirrosh/zond 0.14.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.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -3
  3. package/src/cli/commands/ci-init.ts +12 -1
  4. package/src/cli/commands/coverage.ts +21 -1
  5. package/src/cli/commands/db.ts +121 -0
  6. package/src/cli/commands/describe.ts +60 -0
  7. package/src/cli/commands/generate.ts +127 -0
  8. package/src/cli/commands/guide.ts +127 -0
  9. package/src/cli/commands/init.ts +57 -0
  10. package/src/cli/commands/request.ts +57 -0
  11. package/src/cli/commands/run.ts +53 -10
  12. package/src/cli/commands/serve.ts +62 -3
  13. package/src/cli/commands/validate.ts +18 -2
  14. package/src/cli/index.ts +204 -7
  15. package/src/cli/json-envelope.ts +19 -0
  16. package/src/core/diagnostics/db-analysis.ts +351 -0
  17. package/src/core/diagnostics/failure-hints.ts +1 -0
  18. package/src/core/generator/data-factory.ts +19 -8
  19. package/src/core/generator/describe.ts +250 -0
  20. package/src/core/generator/guide-builder.ts +20 -0
  21. package/src/core/generator/suite-generator.ts +133 -20
  22. package/src/core/runner/executor.ts +1 -0
  23. package/src/core/runner/send-request.ts +94 -0
  24. package/src/core/runner/types.ts +1 -0
  25. package/src/db/queries.ts +4 -2
  26. package/src/db/schema.ts +11 -3
  27. package/src/mcp/descriptions.ts +0 -24
  28. package/src/mcp/server.ts +1 -8
  29. package/src/mcp/tools/describe-endpoint.ts +3 -218
  30. package/src/mcp/tools/query-db.ts +6 -222
  31. package/src/mcp/tools/run-tests.ts +1 -0
  32. package/src/mcp/tools/send-request.ts +15 -61
  33. package/src/web/views/suites-tab.ts +1 -1
  34. package/src/mcp/tools/generate-and-save.ts +0 -202
  35. package/src/mcp/tools/save-test-suite.ts +0 -218
  36. package/src/mcp/tools/set-work-dir.ts +0 -35
@@ -1,63 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { getDb } from "../../db/schema.ts";
4
- import { listCollections, listRuns, getRunById, getResultsByRunId, getCollectionById } from "../../db/queries.ts";
5
- import { join } from "node:path";
6
3
  import { TOOL_DESCRIPTIONS } from "../descriptions.js";
7
- import { statusHint, classifyFailure, envHint, envCategory, schemaHint } from "../../core/diagnostics/failure-hints.ts";
8
-
9
- function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean): string | undefined {
10
- if (!raw) return undefined;
11
- if (verbose || raw.length < 500) return raw;
12
- const lines = raw.split(/\r?\n/);
13
- // First line is the error message itself
14
- const msgLines = [lines[0]!];
15
- // Grab up to 3 stack-trace lines (indented or starting with "at ")
16
- let traceCount = 0;
17
- for (let i = 1; i < lines.length && traceCount < 3; i++) {
18
- const line = lines[i]!;
19
- if (/^\s+/.test(line) || /^\s*at\s/.test(line)) {
20
- msgLines.push(line);
21
- traceCount++;
22
- }
23
- }
24
- const remaining = lines.length - msgLines.length;
25
- if (remaining > 0) {
26
- msgLines.push(`...[truncated ${remaining} lines]`);
27
- }
28
- return msgLines.join("\n");
29
- }
30
-
31
- function parseBodySafe(raw: string | null | undefined): unknown {
32
- if (!raw) return undefined;
33
- const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "…[truncated]" : raw;
34
- try {
35
- return JSON.parse(raw);
36
- } catch {
37
- return truncated;
38
- }
39
- }
40
-
41
- const USEFUL_HEADERS = new Set([
42
- "content-type", "content-length", "location", "retry-after",
43
- "www-authenticate", "allow",
44
- ]);
45
- const USEFUL_PREFIXES = ["x-", "ratelimit"];
46
-
47
- function filterHeaders(raw: string | null | undefined): Record<string, string> | undefined {
48
- if (!raw) return undefined;
49
- try {
50
- const h = JSON.parse(raw) as Record<string, string>;
51
- const out: Record<string, string> = {};
52
- for (const [k, v] of Object.entries(h)) {
53
- const l = k.toLowerCase();
54
- if (USEFUL_HEADERS.has(l) || USEFUL_PREFIXES.some(p => l.startsWith(p))) {
55
- out[k] = v;
56
- }
57
- }
58
- return Object.keys(out).length > 0 ? out : undefined;
59
- } catch { return undefined; }
60
- }
4
+ import { getCollections, getRuns, getRunDetail, diagnoseRun, compareRuns } from "../../core/diagnostics/db-analysis.ts";
61
5
 
62
6
  export function registerQueryDbTool(server: McpServer, dbPath?: string) {
63
7
  server.registerTool("query_db", {
@@ -76,18 +20,16 @@ export function registerQueryDbTool(server: McpServer, dbPath?: string) {
76
20
  },
77
21
  }, async ({ action, runId, runIdB, limit, verbose }) => {
78
22
  try {
79
- getDb(dbPath);
80
-
81
23
  switch (action) {
82
24
  case "list_collections": {
83
- const collections = listCollections();
25
+ const collections = getCollections(dbPath);
84
26
  return {
85
27
  content: [{ type: "text" as const, text: JSON.stringify(collections, null, 2) }],
86
28
  };
87
29
  }
88
30
 
89
31
  case "list_runs": {
90
- const runs = listRuns(limit ?? 20);
32
+ const runs = getRuns(limit ?? 20, dbPath);
91
33
  return {
92
34
  content: [{ type: "text" as const, text: JSON.stringify(runs, null, 2) }],
93
35
  };
@@ -100,39 +42,7 @@ export function registerQueryDbTool(server: McpServer, dbPath?: string) {
100
42
  isError: true,
101
43
  };
102
44
  }
103
- const run = getRunById(runId);
104
- if (!run) {
105
- return {
106
- content: [{ type: "text" as const, text: JSON.stringify({ error: `Run ${runId} not found` }, null, 2) }],
107
- isError: true,
108
- };
109
- }
110
- const results = getResultsByRunId(runId);
111
- const detail = {
112
- run: {
113
- id: run.id,
114
- started_at: run.started_at,
115
- finished_at: run.finished_at,
116
- total: run.total,
117
- passed: run.passed,
118
- failed: run.failed,
119
- skipped: run.skipped,
120
- trigger: run.trigger,
121
- environment: run.environment,
122
- duration_ms: run.duration_ms,
123
- },
124
- results: results.map(r => ({
125
- suite_name: r.suite_name,
126
- test_name: r.test_name,
127
- status: r.status,
128
- duration_ms: r.duration_ms,
129
- request_method: r.request_method,
130
- request_url: r.request_url,
131
- response_status: r.response_status,
132
- error_message: truncateErrorMessage(r.error_message, verbose),
133
- assertions: r.assertions,
134
- })),
135
- };
45
+ const detail = getRunDetail(runId, verbose, dbPath);
136
46
  return {
137
47
  content: [{ type: "text" as const, text: JSON.stringify(detail, null, 2) }],
138
48
  };
@@ -145,81 +55,7 @@ export function registerQueryDbTool(server: McpServer, dbPath?: string) {
145
55
  isError: true,
146
56
  };
147
57
  }
148
- const diagRun = getRunById(runId);
149
- if (!diagRun) {
150
- return {
151
- content: [{ type: "text" as const, text: JSON.stringify({ error: `Run ${runId} not found` }, null, 2) }],
152
- isError: true,
153
- };
154
- }
155
-
156
- // Resolve env file path from collection for actionable hints
157
- let envFilePath: string | undefined;
158
- if (diagRun.collection_id) {
159
- const collection = getCollectionById(diagRun.collection_id);
160
- if (collection?.base_dir) {
161
- envFilePath = join(collection.base_dir, ".env.yaml").replace(/\\/g, "/");
162
- }
163
- }
164
-
165
- const allResults = getResultsByRunId(runId);
166
- const failures = allResults
167
- .filter(r => r.status === "fail" || r.status === "error")
168
- .map(r => {
169
- // env issues take priority over generic status hints
170
- const hint = envHint(r.request_url, r.error_message, envFilePath) ?? statusHint(r.response_status);
171
- const failure_type = classifyFailure(r.status, r.response_status);
172
- const sHint = schemaHint(failure_type, r.response_status);
173
- return {
174
- suite_name: r.suite_name,
175
- test_name: r.test_name,
176
- status: r.status,
177
- failure_type,
178
- error_message: truncateErrorMessage(r.error_message, verbose),
179
- request_method: r.request_method,
180
- request_url: r.request_url,
181
- response_status: r.response_status,
182
- ...(hint ? { hint } : {}),
183
- ...(sHint ? { schema_hint: sHint } : {}),
184
- response_body: parseBodySafe(r.response_body),
185
- response_headers: filterHeaders(r.response_headers),
186
- assertions: r.assertions,
187
- duration_ms: r.duration_ms,
188
- };
189
- });
190
-
191
- // Top-level env_issue when all failures have the same env problem category
192
- const categories = new Set(failures.map(f => envCategory(f.hint)).filter(Boolean));
193
- const sharedEnvHint = categories.size === 1
194
- ? categories.has("base_url_missing")
195
- ? `All failures: base_url is not set — add base_url to ${envFilePath ?? ".env.yaml"}`
196
- : categories.has("unresolved_variable")
197
- ? `All failures: some variables are not substituted — check variable names in ${envFilePath ?? ".env.yaml"}`
198
- : [...failures.map(f => f.hint).filter(Boolean)][0]
199
- : undefined;
200
-
201
- const apiErrors = failures.filter(f => f.failure_type === "api_error").length;
202
- const assertionFailures = failures.filter(f => f.failure_type === "assertion_failed").length;
203
- const networkErrors = failures.filter(f => f.failure_type === "network_error").length;
204
-
205
- const result = {
206
- run: {
207
- id: diagRun.id,
208
- started_at: diagRun.started_at,
209
- environment: diagRun.environment,
210
- duration_ms: diagRun.duration_ms,
211
- },
212
- summary: {
213
- total: diagRun.total,
214
- passed: diagRun.passed,
215
- failed: diagRun.failed,
216
- api_errors: apiErrors,
217
- assertion_failures: assertionFailures,
218
- network_errors: networkErrors,
219
- },
220
- ...(sharedEnvHint ? { env_issue: sharedEnvHint } : {}),
221
- failures,
222
- };
58
+ const result = diagnoseRun(runId, verbose, dbPath);
223
59
  return {
224
60
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
225
61
  };
@@ -232,59 +68,7 @@ export function registerQueryDbTool(server: McpServer, dbPath?: string) {
232
68
  isError: true,
233
69
  };
234
70
  }
235
- const runARecord = getRunById(runId);
236
- const runBRecord = getRunById(runIdB);
237
- if (!runARecord) {
238
- return {
239
- content: [{ type: "text" as const, text: JSON.stringify({ error: `Run #${runId} not found` }, null, 2) }],
240
- isError: true,
241
- };
242
- }
243
- if (!runBRecord) {
244
- return {
245
- content: [{ type: "text" as const, text: JSON.stringify({ error: `Run #${runIdB} not found` }, null, 2) }],
246
- isError: true,
247
- };
248
- }
249
-
250
- const resultsA = getResultsByRunId(runId);
251
- const resultsB = getResultsByRunId(runIdB);
252
-
253
- const mapA = new Map<string, string>();
254
- const mapB = new Map<string, string>();
255
- for (const r of resultsA) mapA.set(`${r.suite_name}::${r.test_name}`, r.status);
256
- for (const r of resultsB) mapB.set(`${r.suite_name}::${r.test_name}`, r.status);
257
-
258
- const regressions: Array<{ suite: string; test: string; before: string; after: string }> = [];
259
- const fixes: Array<{ suite: string; test: string; before: string; after: string }> = [];
260
- let unchanged = 0;
261
- let newTests = 0;
262
- let removedTests = 0;
263
-
264
- for (const [key, statusB] of mapB) {
265
- const statusA = mapA.get(key);
266
- if (statusA === undefined) { newTests++; continue; }
267
- const [suite, test] = key.split("::") as [string, string];
268
- const wasPass = statusA === "pass";
269
- const isPass = statusB === "pass";
270
- const wasFail = statusA === "fail" || statusA === "error";
271
- const isFail = statusB === "fail" || statusB === "error";
272
- if (wasPass && isFail) regressions.push({ suite, test, before: statusA, after: statusB });
273
- else if (wasFail && isPass) fixes.push({ suite, test, before: statusA, after: statusB });
274
- else unchanged++;
275
- }
276
- for (const key of mapA.keys()) {
277
- if (!mapB.has(key)) removedTests++;
278
- }
279
-
280
- const compareResult = {
281
- runA: { id: runId, started_at: runARecord.started_at },
282
- runB: { id: runIdB, started_at: runBRecord.started_at },
283
- summary: { regressions: regressions.length, fixes: fixes.length, unchanged, newTests, removedTests },
284
- regressions,
285
- fixes,
286
- hasRegressions: regressions.length > 0,
287
- };
71
+ const compareResult = compareRuns(runId, runIdB, dbPath);
288
72
  return {
289
73
  content: [{ type: "text" as const, text: JSON.stringify(compareResult, null, 2) }],
290
74
  };
@@ -56,6 +56,7 @@ export function registerRunTestsTool(server: McpServer, dbPath?: string) {
56
56
  r.steps.filter(s => s.status === "fail" || s.status === "error").map(s => ({
57
57
  suite: r.suite_name,
58
58
  test: s.name,
59
+ ...(r.suite_file ? { file: r.suite_file } : {}),
59
60
  status: s.status,
60
61
  error: s.error,
61
62
  assertions: s.assertions.filter(a => !a.passed).map(a => ({
@@ -1,29 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { executeRequest } from "../../core/runner/http-client.ts";
4
- import { loadEnvironment, substituteString, substituteDeep } from "../../core/parser/variables.ts";
5
- import { getDb } from "../../db/schema.ts";
6
- import { findCollectionByNameOrId } from "../../db/queries.ts";
3
+ import { sendAdHocRequest } from "../../core/runner/send-request.ts";
7
4
  import { TOOL_DESCRIPTIONS } from "../descriptions.js";
8
5
 
9
- function extractByPath(obj: unknown, path: string): unknown {
10
- const segments = path.replace(/\[(\d+)\]/g, '.$1').split('.').filter(Boolean);
11
- let current: unknown = obj;
12
- for (const seg of segments) {
13
- if (current === null || current === undefined) return undefined;
14
- if (Array.isArray(current)) {
15
- const idx = parseInt(seg, 10);
16
- if (isNaN(idx)) return undefined;
17
- current = current[idx];
18
- } else if (typeof current === 'object') {
19
- current = (current as Record<string, unknown>)[seg];
20
- } else {
21
- return undefined;
22
- }
23
- }
24
- return current;
25
- }
26
-
27
6
  export function registerSendRequestTool(server: McpServer, dbPath?: string) {
28
7
  server.registerTool("send_request", {
29
8
  description: TOOL_DESCRIPTIONS.send_request,
@@ -40,48 +19,23 @@ export function registerSendRequestTool(server: McpServer, dbPath?: string) {
40
19
  },
41
20
  }, async ({ method, url, headers, body, timeout, envName, collectionName, jsonPath, maxResponseChars }) => {
42
21
  try {
43
- let searchDir = process.cwd();
44
- if (collectionName) {
45
- getDb(dbPath);
46
- const col = findCollectionByNameOrId(collectionName);
47
- if (col?.base_dir) searchDir = col.base_dir;
48
- }
49
- const vars = await loadEnvironment(envName, searchDir);
50
-
51
- const resolvedUrl = substituteString(url, vars) as string;
52
- const parsedHeaders = headers ? JSON.parse(headers) as Record<string, string> : {};
53
- const resolvedHeaders = Object.keys(parsedHeaders).length > 0 ? substituteDeep(parsedHeaders, vars) : {};
54
- const resolvedBody = body ? substituteString(body, vars) as string : undefined;
55
-
56
- const response = await executeRequest(
57
- {
58
- method,
59
- url: resolvedUrl,
60
- headers: resolvedHeaders,
61
- body: resolvedBody,
62
- },
63
- timeout ? { timeout } : undefined,
64
- );
65
-
66
- let responseBody: unknown = response.body_parsed ?? response.body;
67
-
68
- // Apply jsonPath filter
69
- if (jsonPath && responseBody !== undefined) {
70
- responseBody = extractByPath(responseBody, jsonPath);
71
- }
72
-
73
- const result = {
74
- status: response.status,
75
- headers: response.headers,
76
- body: responseBody,
77
- duration_ms: response.duration_ms,
78
- };
22
+ const parsedHeaders = headers ? JSON.parse(headers) as Record<string, string> : undefined;
23
+
24
+ const result = await sendAdHocRequest({
25
+ method,
26
+ url,
27
+ headers: parsedHeaders,
28
+ body: body ?? undefined,
29
+ timeout,
30
+ envName,
31
+ collectionName,
32
+ jsonPath,
33
+ dbPath,
34
+ });
79
35
 
80
36
  let text = JSON.stringify(result, null, 2);
81
-
82
- // Apply maxResponseChars truncation
83
37
  if (maxResponseChars && text.length > maxResponseChars) {
84
- text = text.slice(0, maxResponseChars) + '\n[truncated]';
38
+ text = text.slice(0, maxResponseChars) + '\n\u2026[truncated]';
85
39
  }
86
40
 
87
41
  return {
@@ -8,7 +8,7 @@ import { basename } from "node:path";
8
8
 
9
9
  export function renderSuitesTab(state: CollectionState): string {
10
10
  if (state.suites.length === 0) {
11
- return `<div class="tab-empty">No test suites found on disk. Generate tests with <code>generate_and_save</code>.</div>`;
11
+ return `<div class="tab-empty">No test suites found on disk. Generate tests with <code>zond guide</code> or use the test-generation skill.</div>`;
12
12
  }
13
13
 
14
14
  const rows = state.suites.map((s, i) => renderSuiteRow(s, i)).join("");
@@ -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
- }