@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,86 +0,0 @@
1
- import { z } from "zod";
2
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { TOOL_DESCRIPTIONS } from "../descriptions.js";
4
- let serverInstance: ReturnType<typeof Bun.serve> | null = null;
5
- let serverPort: number = 0;
6
-
7
- export function registerManageServerTool(server: McpServer, dbPath?: string) {
8
- server.registerTool("manage_server", {
9
- description: TOOL_DESCRIPTIONS.manage_server,
10
- inputSchema: {
11
- action: z.enum(["start", "stop", "restart", "status"]).describe("Action to perform"),
12
- port: z.optional(z.number().int().min(1).max(65535)).describe("Port number (default: 8080, only for start/restart)"),
13
- },
14
- }, async ({ action, port }) => {
15
- const targetPort = port ?? 8080;
16
-
17
- switch (action) {
18
- case "start": {
19
- if (serverInstance) {
20
- return result({ running: true, port: serverPort, url: `http://localhost:${serverPort}`, message: "Server already running" });
21
- }
22
- return await startServer(targetPort, dbPath);
23
- }
24
-
25
- case "stop": {
26
- if (!serverInstance) {
27
- return result({ running: false, message: "Server is not running" });
28
- }
29
- serverInstance.stop();
30
- serverInstance = null;
31
- const stoppedPort = serverPort;
32
- serverPort = 0;
33
- return result({ running: false, message: `Server stopped (was on port ${stoppedPort})` });
34
- }
35
-
36
- case "restart": {
37
- if (serverInstance) {
38
- serverInstance.stop();
39
- serverInstance = null;
40
- serverPort = 0;
41
- }
42
- return await startServer(targetPort, dbPath);
43
- }
44
-
45
- case "status": {
46
- if (serverInstance) {
47
- return result({ running: true, port: serverPort, url: `http://localhost:${serverPort}` });
48
- }
49
- return result({ running: false });
50
- }
51
- }
52
- });
53
- }
54
-
55
- async function startServer(port: number, dbPath?: string) {
56
- try {
57
- const { getDb } = await import("../../db/schema.ts");
58
- const { createApp } = await import("../../web/server.ts");
59
-
60
- getDb(dbPath);
61
- const app = createApp();
62
-
63
- serverInstance = Bun.serve({
64
- fetch: app.fetch,
65
- port,
66
- hostname: "0.0.0.0",
67
- });
68
- serverPort = port;
69
-
70
- return result({ running: true, port, url: `http://localhost:${port}`, message: "Server started" });
71
- } catch (err) {
72
- return {
73
- content: [{ type: "text" as const, text: JSON.stringify({
74
- running: false,
75
- error: (err as Error).message,
76
- }, null, 2) }],
77
- isError: true,
78
- };
79
- }
80
- }
81
-
82
- function result(data: Record<string, unknown>) {
83
- return {
84
- content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
85
- };
86
- }
@@ -1,300 +0,0 @@
1
- import { z } from "zod";
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
- 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
- }
61
-
62
- export function registerQueryDbTool(server: McpServer, dbPath?: string) {
63
- server.registerTool("query_db", {
64
- description: TOOL_DESCRIPTIONS.query_db,
65
- inputSchema: {
66
- action: z.enum(["list_collections", "list_runs", "get_run_results", "diagnose_failure", "compare_runs"])
67
- .describe("Query action to perform"),
68
- runId: z.optional(z.number().int())
69
- .describe("Run ID (required for get_run_results and diagnose_failure)"),
70
- runIdB: z.optional(z.number().int())
71
- .describe("Second run ID (required for compare_runs — this is the newer run)"),
72
- limit: z.optional(z.number().int().min(1).max(100))
73
- .describe("Max number of runs to return (default: 20, only for list_runs)"),
74
- verbose: z.optional(z.boolean())
75
- .describe("Show full error messages and stack traces (default: false, truncates long traces)"),
76
- },
77
- }, async ({ action, runId, runIdB, limit, verbose }) => {
78
- try {
79
- getDb(dbPath);
80
-
81
- switch (action) {
82
- case "list_collections": {
83
- const collections = listCollections();
84
- return {
85
- content: [{ type: "text" as const, text: JSON.stringify(collections, null, 2) }],
86
- };
87
- }
88
-
89
- case "list_runs": {
90
- const runs = listRuns(limit ?? 20);
91
- return {
92
- content: [{ type: "text" as const, text: JSON.stringify(runs, null, 2) }],
93
- };
94
- }
95
-
96
- case "get_run_results": {
97
- if (runId == null) {
98
- return {
99
- content: [{ type: "text" as const, text: JSON.stringify({ error: "runId is required for get_run_results" }, null, 2) }],
100
- isError: true,
101
- };
102
- }
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
- };
136
- return {
137
- content: [{ type: "text" as const, text: JSON.stringify(detail, null, 2) }],
138
- };
139
- }
140
-
141
- case "diagnose_failure": {
142
- if (runId == null) {
143
- return {
144
- content: [{ type: "text" as const, text: JSON.stringify({ error: "runId is required for diagnose_failure" }, null, 2) }],
145
- isError: true,
146
- };
147
- }
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
- };
223
- return {
224
- content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
225
- };
226
- }
227
-
228
- case "compare_runs": {
229
- if (runId == null || runIdB == null) {
230
- return {
231
- content: [{ type: "text" as const, text: JSON.stringify({ error: "Both runId (run A) and runIdB (run B) are required for compare_runs" }, null, 2) }],
232
- isError: true,
233
- };
234
- }
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
- };
288
- return {
289
- content: [{ type: "text" as const, text: JSON.stringify(compareResult, null, 2) }],
290
- };
291
- }
292
- }
293
- } catch (err) {
294
- return {
295
- content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
296
- isError: true,
297
- };
298
- }
299
- });
300
- }
@@ -1,115 +0,0 @@
1
- import { z } from "zod";
2
- import { resolve } from "node:path";
3
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
- import { executeRun } from "../../core/runner/execute-run.ts";
5
- import { getDb } from "../../db/schema.ts";
6
- import { getResultsByRunId, findCollectionByTestPath } from "../../db/queries.ts";
7
- import { readOpenApiSpec, extractEndpoints, scanCoveredEndpoints, filterUncoveredEndpoints } from "../../core/generator/index.ts";
8
- import { TOOL_DESCRIPTIONS } from "../descriptions.js";
9
-
10
- export function registerRunTestsTool(server: McpServer, dbPath?: string) {
11
- server.registerTool("run_tests", {
12
- description: TOOL_DESCRIPTIONS.run_tests,
13
- inputSchema: {
14
- testPath: z.string().describe("Path to test YAML file or directory"),
15
- envName: z.optional(z.string()).describe("Environment name (loads .env.<name>.yaml)"),
16
- safe: z.optional(z.boolean()).describe("Run only GET tests (read-only, safe mode)"),
17
- tag: z.optional(z.array(z.string())).describe("Filter suites by tag (OR logic)"),
18
- envVars: z.optional(z.record(z.string(), z.string())).describe("Environment variables to inject (override env file, e.g. {\"TOKEN\": \"xxx\"})"),
19
- dryRun: z.optional(z.boolean()).describe("Show requests without sending them (always exits 0)"),
20
- rerunFrom: z.optional(z.number().int()).describe("Re-run only tests that failed/errored in this run ID"),
21
- },
22
- }, async ({ testPath, envName, safe, tag, envVars, dryRun, rerunFrom }) => {
23
- // Build filter from previous failed run
24
- let rerunFilter: Set<string> | undefined;
25
- if (rerunFrom != null) {
26
- getDb(dbPath);
27
- const prevResults = getResultsByRunId(rerunFrom);
28
- const failed = prevResults.filter(r => r.status === "fail" || r.status === "error");
29
- if (failed.length === 0) {
30
- return {
31
- content: [{ type: "text" as const, text: JSON.stringify({ error: `Run ${rerunFrom} has no failures to rerun` }, null, 2) }],
32
- isError: true,
33
- };
34
- }
35
- rerunFilter = new Set(failed.map(r => `${r.suite_name}::${r.test_name}`));
36
- }
37
-
38
- const { runId, results } = await executeRun({
39
- testPath,
40
- envName,
41
- trigger: "mcp",
42
- dbPath,
43
- safe,
44
- tag,
45
- envVars,
46
- dryRun,
47
- rerunFilter,
48
- });
49
-
50
- const total = results.reduce((s, r) => s + r.total, 0);
51
- const passed = results.reduce((s, r) => s + r.passed, 0);
52
- const failed = results.reduce((s, r) => s + r.failed, 0);
53
- const skipped = results.reduce((s, r) => s + r.skipped, 0);
54
-
55
- const failedSteps = results.flatMap(r =>
56
- r.steps.filter(s => s.status === "fail" || s.status === "error").map(s => ({
57
- suite: r.suite_name,
58
- test: s.name,
59
- status: s.status,
60
- error: s.error,
61
- assertions: s.assertions.filter(a => !a.passed).map(a => ({
62
- field: a.field,
63
- expected: a.expected,
64
- actual: a.actual,
65
- })),
66
- }))
67
- );
68
-
69
- // Best-effort coverage calculation
70
- let coverage: { covered: number; total: number; percentage: number } | undefined;
71
- try {
72
- const resolvedPath = resolve(testPath);
73
- const collection = findCollectionByTestPath(resolvedPath);
74
- if (collection?.openapi_spec) {
75
- const doc = await readOpenApiSpec(collection.openapi_spec);
76
- const allEndpoints = extractEndpoints(doc);
77
- const coveredEps = await scanCoveredEndpoints(collection.test_path);
78
- const uncovered = filterUncoveredEndpoints(allEndpoints, coveredEps);
79
- const coveredCount = allEndpoints.length - uncovered.length;
80
- coverage = {
81
- covered: coveredCount,
82
- total: allEndpoints.length,
83
- percentage: allEndpoints.length > 0 ? Math.round((coveredCount / allEndpoints.length) * 100) : 100,
84
- };
85
- }
86
- } catch { /* coverage is best-effort, don't fail run */ }
87
-
88
- const hints: string[] = [];
89
- if (failedSteps.length > 0) {
90
- hints.push("Use query_db(action: 'diagnose_failure', runId: " + runId + ") for detailed failure analysis");
91
- const hasAssertionFailures = failedSteps.some(s => s.assertions.length > 0);
92
- if (hasAssertionFailures) {
93
- hints.push(
94
- "Some tests have assertion failures — use describe_endpoint(specPath, method, path) to verify expected schemas"
95
- );
96
- }
97
- }
98
- const summary = {
99
- runId,
100
- total,
101
- passed,
102
- failed,
103
- skipped,
104
- suites: results.length,
105
- status: failed === 0 ? "all_passed" : "has_failures",
106
- ...(failedSteps.length > 0 ? { failures: failedSteps } : {}),
107
- ...(coverage ? { coverage } : {}),
108
- hints,
109
- };
110
-
111
- return {
112
- content: [{ type: "text" as const, text: JSON.stringify(summary, null, 2) }],
113
- };
114
- });
115
- }