@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
@@ -0,0 +1,423 @@
1
+ import { getDb } from "../../db/schema.ts";
2
+ import { listCollections, listRuns, getRunById, getResultsByRunId, getCollectionById } from "../../db/queries.ts";
3
+ import { join } from "node:path";
4
+ import { statusHint, classifyFailure, envHint, envCategory, schemaHint, computeSharedEnvIssue, recommendedAction, softDeleteHint, type RecommendedAction } from "./failure-hints.ts";
5
+ import { AUTH_PATH_RE } from "../runner/execute-run.ts";
6
+
7
+ export function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean): string | undefined {
8
+ if (!raw) return undefined;
9
+ if (verbose || raw.length < 500) return raw;
10
+ const lines = raw.split(/\r?\n/);
11
+ const msgLines = [lines[0]!];
12
+ let traceCount = 0;
13
+ for (let i = 1; i < lines.length && traceCount < 3; i++) {
14
+ const line = lines[i]!;
15
+ if (/^\s+/.test(line) || /^\s*at\s/.test(line)) {
16
+ msgLines.push(line);
17
+ traceCount++;
18
+ }
19
+ }
20
+ const remaining = lines.length - msgLines.length;
21
+ if (remaining > 0) {
22
+ msgLines.push(`...[truncated ${remaining} lines]`);
23
+ }
24
+ return msgLines.join("\n");
25
+ }
26
+
27
+ export function parseBodySafe(raw: string | null | undefined): unknown {
28
+ if (!raw) return undefined;
29
+ const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "\u2026[truncated]" : raw;
30
+ try {
31
+ return JSON.parse(raw);
32
+ } catch {
33
+ return truncated;
34
+ }
35
+ }
36
+
37
+ const USEFUL_HEADERS = new Set([
38
+ "content-type", "content-length", "location", "retry-after",
39
+ "www-authenticate", "allow",
40
+ ]);
41
+ const USEFUL_PREFIXES = ["x-", "ratelimit"];
42
+
43
+ export function filterHeaders(raw: string | null | undefined): Record<string, string> | undefined {
44
+ if (!raw) return undefined;
45
+ try {
46
+ const h = JSON.parse(raw) as Record<string, string>;
47
+ const out: Record<string, string> = {};
48
+ for (const [k, v] of Object.entries(h)) {
49
+ const l = k.toLowerCase();
50
+ if (USEFUL_HEADERS.has(l) || USEFUL_PREFIXES.some(p => l.startsWith(p))) {
51
+ out[k] = v;
52
+ }
53
+ }
54
+ return Object.keys(out).length > 0 ? out : undefined;
55
+ } catch { return undefined; }
56
+ }
57
+
58
+ export interface RunDetail {
59
+ run: {
60
+ id: number;
61
+ started_at: string;
62
+ finished_at: string | null;
63
+ total: number;
64
+ passed: number;
65
+ failed: number;
66
+ skipped: number;
67
+ trigger: string | null;
68
+ environment: string | null;
69
+ duration_ms: number | null;
70
+ };
71
+ results: Array<{
72
+ suite_name: string;
73
+ test_name: string;
74
+ status: string;
75
+ duration_ms: number | null;
76
+ request_method: string | null;
77
+ request_url: string | null;
78
+ response_status: number | null;
79
+ error_message?: string;
80
+ assertions: unknown;
81
+ }>;
82
+ }
83
+
84
+ export function getRunDetail(runId: number, verbose?: boolean, dbPath?: string): RunDetail {
85
+ getDb(dbPath);
86
+ const run = getRunById(runId);
87
+ if (!run) throw new Error(`Run ${runId} not found`);
88
+ const results = getResultsByRunId(runId);
89
+ return {
90
+ run: {
91
+ id: run.id,
92
+ started_at: run.started_at,
93
+ finished_at: run.finished_at,
94
+ total: run.total,
95
+ passed: run.passed,
96
+ failed: run.failed,
97
+ skipped: run.skipped,
98
+ trigger: run.trigger,
99
+ environment: run.environment,
100
+ duration_ms: run.duration_ms,
101
+ },
102
+ results: results.map(r => ({
103
+ suite_name: r.suite_name,
104
+ test_name: r.test_name,
105
+ status: r.status,
106
+ duration_ms: r.duration_ms,
107
+ request_method: r.request_method,
108
+ request_url: r.request_url,
109
+ response_status: r.response_status,
110
+ error_message: truncateErrorMessage(r.error_message, verbose),
111
+ assertions: r.assertions,
112
+ })),
113
+ };
114
+ }
115
+
116
+ export interface FailureGroup {
117
+ pattern: string;
118
+ count: number;
119
+ failure_type: string;
120
+ recommended_action: RecommendedAction;
121
+ hint?: string;
122
+ examples: string[];
123
+ response_status: number | null;
124
+ }
125
+
126
+ export interface CascadeSkipGroup {
127
+ capture_var: string;
128
+ count: number;
129
+ examples: string[];
130
+ }
131
+
132
+ export interface DiagnoseResult {
133
+ run: {
134
+ id: number;
135
+ started_at: string;
136
+ environment: string | null;
137
+ duration_ms: number | null;
138
+ };
139
+ summary: {
140
+ total: number;
141
+ passed: number;
142
+ failed: number;
143
+ api_errors: number;
144
+ assertion_failures: number;
145
+ network_errors: number;
146
+ };
147
+ agent_directive?: string;
148
+ env_issue?: string;
149
+ auth_hint?: string;
150
+ cascade_skips?: CascadeSkipGroup[];
151
+ failures: Array<{
152
+ suite_name: string;
153
+ test_name: string;
154
+ suite_file?: string;
155
+ status: string;
156
+ failure_type: string;
157
+ recommended_action: RecommendedAction;
158
+ error_message?: string;
159
+ request_method: string | null;
160
+ request_url: string | null;
161
+ response_status: number | null;
162
+ hint?: string;
163
+ schema_hint?: string;
164
+ response_body?: unknown;
165
+ response_headers?: Record<string, string>;
166
+ assertions: unknown;
167
+ duration_ms: number | null;
168
+ }>;
169
+ grouped_failures?: FailureGroup[];
170
+ }
171
+
172
+ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string): DiagnoseResult {
173
+ getDb(dbPath);
174
+ const diagRun = getRunById(runId);
175
+ if (!diagRun) throw new Error(`Run ${runId} not found`);
176
+
177
+ let envFilePath: string | undefined;
178
+ if (diagRun.collection_id) {
179
+ const collection = getCollectionById(diagRun.collection_id);
180
+ if (collection?.base_dir) {
181
+ envFilePath = join(collection.base_dir, ".env.yaml").replace(/\\/g, "/");
182
+ }
183
+ }
184
+
185
+ const allResults = getResultsByRunId(runId);
186
+ const failures = allResults
187
+ .filter(r => r.status === "fail" || r.status === "error")
188
+ .map(r => {
189
+ const parsedBody = parseBodySafe(r.response_body);
190
+ const hint = envHint(r.request_url, r.error_message, envFilePath) ??
191
+ softDeleteHint(r.response_status, r.request_method, parsedBody) ??
192
+ statusHint(r.response_status);
193
+ const failure_type = classifyFailure(r.status, r.response_status);
194
+ const rec_action = recommendedAction(failure_type, r.response_status);
195
+ const sHint = schemaHint(failure_type, r.response_status);
196
+ return {
197
+ suite_name: r.suite_name,
198
+ test_name: r.test_name,
199
+ ...(r.suite_file ? { suite_file: r.suite_file } : {}),
200
+ status: r.status,
201
+ failure_type,
202
+ recommended_action: rec_action,
203
+ error_message: truncateErrorMessage(r.error_message, verbose),
204
+ request_method: r.request_method,
205
+ request_url: r.request_url,
206
+ response_status: r.response_status,
207
+ ...(hint ? { hint } : {}),
208
+ ...(sHint ? { schema_hint: sHint } : {}),
209
+ response_body: parsedBody,
210
+ response_headers: filterHeaders(r.response_headers),
211
+ assertions: r.assertions,
212
+ duration_ms: r.duration_ms,
213
+ };
214
+ });
215
+
216
+ const sharedEnvHint = computeSharedEnvIssue(failures, envFilePath);
217
+
218
+ let apiErrors = 0, assertionFailures = 0, networkErrors = 0;
219
+ let authFailureCount = 0;
220
+ for (const f of failures) {
221
+ if (f.failure_type === "api_error") apiErrors++;
222
+ else if (f.failure_type === "assertion_failed") assertionFailures++;
223
+ else if (f.failure_type === "network_error") networkErrors++;
224
+ if (f.response_status === 401 || f.response_status === 403) authFailureCount++;
225
+ }
226
+
227
+ let agent_directive: string | undefined;
228
+ if (apiErrors > 0) {
229
+ const fixable = assertionFailures + networkErrors;
230
+ agent_directive =
231
+ `${apiErrors} test${apiErrors === 1 ? "" : "s"} returned 5xx server errors. ` +
232
+ `Do NOT change test expectations to accept 5xx responses. ` +
233
+ `These are backend bugs, not test logic errors. ` +
234
+ `Stop iterating on these tests and report the failures to the API team.` +
235
+ (fixable > 0
236
+ ? ` The remaining ${fixable} failure${fixable === 1 ? "" : "s"} may be fixable in test logic.`
237
+ : "");
238
+ }
239
+
240
+ // Cascade skips: skipped tests due to missing captures from failed create steps
241
+ const CASCADE_RE = /^Depends on missing capture: (.+)$/;
242
+ const groupMap = new Map<string, string[]>();
243
+ for (const r of allResults) {
244
+ if (r.status !== "skip") continue;
245
+ const match = CASCADE_RE.exec(r.error_message ?? "");
246
+ if (!match) continue;
247
+ const captureVar = match[1]!;
248
+ const existing = groupMap.get(captureVar) ?? [];
249
+ existing.push(`${r.suite_name}/${r.test_name}`);
250
+ groupMap.set(captureVar, existing);
251
+ }
252
+ const cascade_skips: CascadeSkipGroup[] | undefined = groupMap.size > 0
253
+ ? [...groupMap.entries()].map(([capture_var, examples]) => ({
254
+ capture_var,
255
+ count: examples.length,
256
+ examples: examples.slice(0, 3),
257
+ }))
258
+ : undefined;
259
+
260
+ // Auth hint: when many tests fail with 401/403, suggest auth setup
261
+ let auth_hint: string | undefined;
262
+ if (authFailureCount >= 5 && authFailureCount / diagRun.total >= 0.3) {
263
+ const loginEndpoint = allResults.find(
264
+ r => r.request_method?.toUpperCase() === "POST" && AUTH_PATH_RE.test(r.request_url ?? "")
265
+ );
266
+ if (loginEndpoint) {
267
+ auth_hint = `${authFailureCount} tests failed with 401/403. Found auth endpoint: POST ${loginEndpoint.request_url} — add \`setup: true\` to your auth suite so its captured token is shared with all other suites, or set auth_token manually in .env.yaml`;
268
+ } else {
269
+ auth_hint = `${authFailureCount} tests failed with 401/403 — add \`setup: true\` to your auth suite so its captured token is shared with all other suites, or set auth_token in .env.yaml`;
270
+ }
271
+ }
272
+
273
+ const { grouped_failures, compactFailures } = verbose
274
+ ? { grouped_failures: undefined, compactFailures: failures }
275
+ : groupFailures(failures);
276
+
277
+ return {
278
+ run: {
279
+ id: diagRun.id,
280
+ started_at: diagRun.started_at,
281
+ environment: diagRun.environment,
282
+ duration_ms: diagRun.duration_ms,
283
+ },
284
+ summary: {
285
+ total: diagRun.total,
286
+ passed: diagRun.passed,
287
+ failed: diagRun.failed,
288
+ api_errors: apiErrors,
289
+ assertion_failures: assertionFailures,
290
+ network_errors: networkErrors,
291
+ },
292
+ ...(agent_directive ? { agent_directive } : {}),
293
+ ...(sharedEnvHint ? { env_issue: sharedEnvHint } : {}),
294
+ ...(auth_hint ? { auth_hint } : {}),
295
+ ...(cascade_skips ? { cascade_skips } : {}),
296
+ failures: compactFailures,
297
+ ...(grouped_failures ? { grouped_failures } : {}),
298
+ };
299
+ }
300
+
301
+ type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; hint?: string; response_status: number | null };
302
+
303
+ /** Group similar failures for compact output. Exported for testing. */
304
+ export function groupFailures<T extends FailureItem>(failures: T[]): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
305
+ if (failures.length <= 5) {
306
+ return { compactFailures: failures };
307
+ }
308
+
309
+ const groupMap = new Map<string, { items: T[]; failure_type: string; hint?: string; response_status: number | null }>();
310
+
311
+ for (const f of failures) {
312
+ const key = `${f.response_status ?? "null"}|${f.failure_type}`;
313
+ const existing = groupMap.get(key);
314
+ if (existing) {
315
+ existing.items.push(f);
316
+ } else {
317
+ groupMap.set(key, {
318
+ items: [f],
319
+ failure_type: f.failure_type,
320
+ hint: f.hint,
321
+ response_status: f.response_status,
322
+ });
323
+ }
324
+ }
325
+
326
+ const hasGroups = [...groupMap.values()].some(g => g.items.length > 2);
327
+ if (!hasGroups) {
328
+ return { compactFailures: failures };
329
+ }
330
+
331
+ const grouped_failures: FailureGroup[] = [];
332
+ const compactFailures: T[] = [];
333
+
334
+ for (const [, group] of groupMap) {
335
+ const pattern = group.response_status
336
+ ? `${group.response_status} ${group.failure_type}`
337
+ : group.failure_type;
338
+ grouped_failures.push({
339
+ pattern,
340
+ count: group.items.length,
341
+ failure_type: group.failure_type,
342
+ recommended_action: group.items[0]!.recommended_action,
343
+ hint: group.hint,
344
+ examples: group.items.slice(0, 2).map(f => `${f.suite_name}/${f.test_name}`),
345
+ response_status: group.response_status,
346
+ });
347
+ compactFailures.push(group.items[0]!);
348
+ }
349
+
350
+ return { grouped_failures, compactFailures };
351
+ }
352
+
353
+ export interface CompareResult {
354
+ runA: { id: number; started_at: string };
355
+ runB: { id: number; started_at: string };
356
+ summary: {
357
+ regressions: number;
358
+ fixes: number;
359
+ unchanged: number;
360
+ newTests: number;
361
+ removedTests: number;
362
+ };
363
+ regressions: Array<{ suite: string; test: string; before: string; after: string }>;
364
+ fixes: Array<{ suite: string; test: string; before: string; after: string }>;
365
+ hasRegressions: boolean;
366
+ }
367
+
368
+ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareResult {
369
+ getDb(dbPath);
370
+ const runARecord = getRunById(idA);
371
+ const runBRecord = getRunById(idB);
372
+ if (!runARecord) throw new Error(`Run #${idA} not found`);
373
+ if (!runBRecord) throw new Error(`Run #${idB} not found`);
374
+
375
+ const resultsA = getResultsByRunId(idA);
376
+ const resultsB = getResultsByRunId(idB);
377
+
378
+ const mapA = new Map<string, string>();
379
+ const mapB = new Map<string, string>();
380
+ for (const r of resultsA) mapA.set(`${r.suite_name}::${r.test_name}`, r.status);
381
+ for (const r of resultsB) mapB.set(`${r.suite_name}::${r.test_name}`, r.status);
382
+
383
+ const regressions: Array<{ suite: string; test: string; before: string; after: string }> = [];
384
+ const fixes: Array<{ suite: string; test: string; before: string; after: string }> = [];
385
+ let unchanged = 0;
386
+ let newTests = 0;
387
+ let removedTests = 0;
388
+
389
+ for (const [key, statusB] of mapB) {
390
+ const statusA = mapA.get(key);
391
+ if (statusA === undefined) { newTests++; continue; }
392
+ const [suite, test] = key.split("::") as [string, string];
393
+ const wasPass = statusA === "pass";
394
+ const isPass = statusB === "pass";
395
+ const wasFail = statusA === "fail" || statusA === "error";
396
+ const isFail = statusB === "fail" || statusB === "error";
397
+ if (wasPass && isFail) regressions.push({ suite, test, before: statusA, after: statusB });
398
+ else if (wasFail && isPass) fixes.push({ suite, test, before: statusA, after: statusB });
399
+ else unchanged++;
400
+ }
401
+ for (const key of mapA.keys()) {
402
+ if (!mapB.has(key)) removedTests++;
403
+ }
404
+
405
+ return {
406
+ runA: { id: idA, started_at: runARecord.started_at },
407
+ runB: { id: idB, started_at: runBRecord.started_at },
408
+ summary: { regressions: regressions.length, fixes: fixes.length, unchanged, newTests, removedTests },
409
+ regressions,
410
+ fixes,
411
+ hasRegressions: regressions.length > 0,
412
+ };
413
+ }
414
+
415
+ export function getCollections(dbPath?: string) {
416
+ getDb(dbPath);
417
+ return listCollections();
418
+ }
419
+
420
+ export function getRuns(limit?: number, dbPath?: string) {
421
+ getDb(dbPath);
422
+ return listRuns(limit ?? 20);
423
+ }
@@ -9,6 +9,7 @@ export function statusHint(status: number | null | undefined): string | null {
9
9
  if (status === 401 || status === 403) return "Auth failure — check auth_token/api_key in .env.yaml";
10
10
  if (status === 404) return "Resource not found — verify the path and ID";
11
11
  if (status === 400 || status === 422) return "Validation error — check request body fields match the schema";
12
+ if (status === 429) return "Rate limited — too many requests. Consider consolidating auth/login steps or adding delays between suites";
12
13
  return null;
13
14
  }
14
15
 
@@ -36,6 +37,26 @@ export function envHint(url: string | null, errorMessage: string | null, envFile
36
37
  return null;
37
38
  }
38
39
 
40
+ export type RecommendedAction =
41
+ | "report_backend_bug"
42
+ | "fix_auth_config"
43
+ | "fix_test_logic"
44
+ | "fix_network_config";
45
+
46
+ export function recommendedAction(
47
+ failureType: "api_error" | "assertion_failed" | "network_error",
48
+ responseStatus: number | null,
49
+ ): RecommendedAction {
50
+ if (failureType === "api_error") return "report_backend_bug";
51
+ if (failureType === "network_error") {
52
+ if (responseStatus === 401 || responseStatus === 403) return "fix_auth_config";
53
+ return "fix_network_config";
54
+ }
55
+ // assertion_failed
56
+ if (responseStatus === 401 || responseStatus === 403) return "fix_auth_config";
57
+ return "fix_test_logic";
58
+ }
59
+
39
60
  export function envCategory(hint: string | undefined): string | null {
40
61
  if (!hint) return null;
41
62
  if (hint.includes("base_url is not set") || hint.includes("base_url is missing") || hint.includes("base_url is not configured")) return "base_url_missing";
@@ -54,6 +75,25 @@ export function schemaHint(
54
75
  return null;
55
76
  }
56
77
 
78
+ export function softDeleteHint(
79
+ actualStatus: number | null | undefined,
80
+ requestMethod: string | null | undefined,
81
+ responseBody: unknown,
82
+ ): string | null {
83
+ if (actualStatus !== 200 || requestMethod?.toUpperCase() !== "GET") return null;
84
+ if (responseBody && typeof responseBody === "object") {
85
+ const hasStatusField =
86
+ "status" in (responseBody as object) ||
87
+ "state" in (responseBody as object) ||
88
+ "deleted" in (responseBody as object) ||
89
+ "is_deleted" in (responseBody as object);
90
+ if (hasStatusField) {
91
+ return 'GET returned 200 with a status/state field after DELETE — likely soft delete. Update the test: remove the "Verify deleted → 404" step and instead assert the status field value (e.g. status: "cancelled")';
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
57
97
  export function computeSharedEnvIssue(
58
98
  failures: Array<{ hint?: string }>,
59
99
  envFilePath?: string,