@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
@@ -0,0 +1,351 @@
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 } from "./failure-hints.ts";
5
+
6
+ export function truncateErrorMessage(raw: string | null | undefined, verbose?: boolean): string | undefined {
7
+ if (!raw) return undefined;
8
+ if (verbose || raw.length < 500) return raw;
9
+ const lines = raw.split(/\r?\n/);
10
+ const msgLines = [lines[0]!];
11
+ let traceCount = 0;
12
+ for (let i = 1; i < lines.length && traceCount < 3; i++) {
13
+ const line = lines[i]!;
14
+ if (/^\s+/.test(line) || /^\s*at\s/.test(line)) {
15
+ msgLines.push(line);
16
+ traceCount++;
17
+ }
18
+ }
19
+ const remaining = lines.length - msgLines.length;
20
+ if (remaining > 0) {
21
+ msgLines.push(`...[truncated ${remaining} lines]`);
22
+ }
23
+ return msgLines.join("\n");
24
+ }
25
+
26
+ export function parseBodySafe(raw: string | null | undefined): unknown {
27
+ if (!raw) return undefined;
28
+ const truncated = raw.length > 2000 ? raw.slice(0, 2000) + "\u2026[truncated]" : raw;
29
+ try {
30
+ return JSON.parse(raw);
31
+ } catch {
32
+ return truncated;
33
+ }
34
+ }
35
+
36
+ const USEFUL_HEADERS = new Set([
37
+ "content-type", "content-length", "location", "retry-after",
38
+ "www-authenticate", "allow",
39
+ ]);
40
+ const USEFUL_PREFIXES = ["x-", "ratelimit"];
41
+
42
+ export function filterHeaders(raw: string | null | undefined): Record<string, string> | undefined {
43
+ if (!raw) return undefined;
44
+ try {
45
+ const h = JSON.parse(raw) as Record<string, string>;
46
+ const out: Record<string, string> = {};
47
+ for (const [k, v] of Object.entries(h)) {
48
+ const l = k.toLowerCase();
49
+ if (USEFUL_HEADERS.has(l) || USEFUL_PREFIXES.some(p => l.startsWith(p))) {
50
+ out[k] = v;
51
+ }
52
+ }
53
+ return Object.keys(out).length > 0 ? out : undefined;
54
+ } catch { return undefined; }
55
+ }
56
+
57
+ export interface RunDetail {
58
+ run: {
59
+ id: number;
60
+ started_at: string;
61
+ finished_at: string | null;
62
+ total: number;
63
+ passed: number;
64
+ failed: number;
65
+ skipped: number;
66
+ trigger: string | null;
67
+ environment: string | null;
68
+ duration_ms: number | null;
69
+ };
70
+ results: Array<{
71
+ suite_name: string;
72
+ test_name: string;
73
+ status: string;
74
+ duration_ms: number | null;
75
+ request_method: string | null;
76
+ request_url: string | null;
77
+ response_status: number | null;
78
+ error_message?: string;
79
+ assertions: unknown;
80
+ }>;
81
+ }
82
+
83
+ export function getRunDetail(runId: number, verbose?: boolean, dbPath?: string): RunDetail {
84
+ getDb(dbPath);
85
+ const run = getRunById(runId);
86
+ if (!run) throw new Error(`Run ${runId} not found`);
87
+ const results = getResultsByRunId(runId);
88
+ return {
89
+ run: {
90
+ id: run.id,
91
+ started_at: run.started_at,
92
+ finished_at: run.finished_at,
93
+ total: run.total,
94
+ passed: run.passed,
95
+ failed: run.failed,
96
+ skipped: run.skipped,
97
+ trigger: run.trigger,
98
+ environment: run.environment,
99
+ duration_ms: run.duration_ms,
100
+ },
101
+ results: results.map(r => ({
102
+ suite_name: r.suite_name,
103
+ test_name: r.test_name,
104
+ status: r.status,
105
+ duration_ms: r.duration_ms,
106
+ request_method: r.request_method,
107
+ request_url: r.request_url,
108
+ response_status: r.response_status,
109
+ error_message: truncateErrorMessage(r.error_message, verbose),
110
+ assertions: r.assertions,
111
+ })),
112
+ };
113
+ }
114
+
115
+ export interface FailureGroup {
116
+ pattern: string;
117
+ count: number;
118
+ failure_type: string;
119
+ hint?: string;
120
+ examples: string[];
121
+ response_status: number | null;
122
+ }
123
+
124
+ export interface DiagnoseResult {
125
+ run: {
126
+ id: number;
127
+ started_at: string;
128
+ environment: string | null;
129
+ duration_ms: number | null;
130
+ };
131
+ summary: {
132
+ total: number;
133
+ passed: number;
134
+ failed: number;
135
+ api_errors: number;
136
+ assertion_failures: number;
137
+ network_errors: number;
138
+ };
139
+ env_issue?: string;
140
+ failures: Array<{
141
+ suite_name: string;
142
+ test_name: string;
143
+ suite_file?: string;
144
+ status: string;
145
+ failure_type: string;
146
+ error_message?: string;
147
+ request_method: string | null;
148
+ request_url: string | null;
149
+ response_status: number | null;
150
+ hint?: string;
151
+ schema_hint?: string;
152
+ response_body?: unknown;
153
+ response_headers?: Record<string, string>;
154
+ assertions: unknown;
155
+ duration_ms: number | null;
156
+ }>;
157
+ grouped_failures?: FailureGroup[];
158
+ }
159
+
160
+ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string): DiagnoseResult {
161
+ getDb(dbPath);
162
+ const diagRun = getRunById(runId);
163
+ if (!diagRun) throw new Error(`Run ${runId} not found`);
164
+
165
+ let envFilePath: string | undefined;
166
+ if (diagRun.collection_id) {
167
+ const collection = getCollectionById(diagRun.collection_id);
168
+ if (collection?.base_dir) {
169
+ envFilePath = join(collection.base_dir, ".env.yaml").replace(/\\/g, "/");
170
+ }
171
+ }
172
+
173
+ const allResults = getResultsByRunId(runId);
174
+ const failures = allResults
175
+ .filter(r => r.status === "fail" || r.status === "error")
176
+ .map(r => {
177
+ const hint = envHint(r.request_url, r.error_message, envFilePath) ?? statusHint(r.response_status);
178
+ const failure_type = classifyFailure(r.status, r.response_status);
179
+ const sHint = schemaHint(failure_type, r.response_status);
180
+ return {
181
+ suite_name: r.suite_name,
182
+ test_name: r.test_name,
183
+ ...(r.suite_file ? { suite_file: r.suite_file } : {}),
184
+ status: r.status,
185
+ failure_type,
186
+ error_message: truncateErrorMessage(r.error_message, verbose),
187
+ request_method: r.request_method,
188
+ request_url: r.request_url,
189
+ response_status: r.response_status,
190
+ ...(hint ? { hint } : {}),
191
+ ...(sHint ? { schema_hint: sHint } : {}),
192
+ response_body: parseBodySafe(r.response_body),
193
+ response_headers: filterHeaders(r.response_headers),
194
+ assertions: r.assertions,
195
+ duration_ms: r.duration_ms,
196
+ };
197
+ });
198
+
199
+ const sharedEnvHint = computeSharedEnvIssue(failures, envFilePath);
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 { grouped_failures, compactFailures } = verbose
206
+ ? { grouped_failures: undefined, compactFailures: failures }
207
+ : groupFailures(failures);
208
+
209
+ return {
210
+ run: {
211
+ id: diagRun.id,
212
+ started_at: diagRun.started_at,
213
+ environment: diagRun.environment,
214
+ duration_ms: diagRun.duration_ms,
215
+ },
216
+ summary: {
217
+ total: diagRun.total,
218
+ passed: diagRun.passed,
219
+ failed: diagRun.failed,
220
+ api_errors: apiErrors,
221
+ assertion_failures: assertionFailures,
222
+ network_errors: networkErrors,
223
+ },
224
+ ...(sharedEnvHint ? { env_issue: sharedEnvHint } : {}),
225
+ failures: compactFailures,
226
+ ...(grouped_failures ? { grouped_failures } : {}),
227
+ };
228
+ }
229
+
230
+ type FailureItem = { suite_name: string; test_name: string; failure_type: string; hint?: string; response_status: number | null };
231
+
232
+ /** Group similar failures for compact output. Exported for testing. */
233
+ export function groupFailures<T extends FailureItem>(failures: T[]): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
234
+ if (failures.length <= 5) {
235
+ return { compactFailures: failures };
236
+ }
237
+
238
+ const groupMap = new Map<string, { items: T[]; failure_type: string; hint?: string; response_status: number | null }>();
239
+
240
+ for (const f of failures) {
241
+ const key = `${f.response_status ?? "null"}|${f.failure_type}`;
242
+ const existing = groupMap.get(key);
243
+ if (existing) {
244
+ existing.items.push(f);
245
+ } else {
246
+ groupMap.set(key, {
247
+ items: [f],
248
+ failure_type: f.failure_type,
249
+ hint: f.hint,
250
+ response_status: f.response_status,
251
+ });
252
+ }
253
+ }
254
+
255
+ const hasGroups = [...groupMap.values()].some(g => g.items.length > 2);
256
+ if (!hasGroups) {
257
+ return { compactFailures: failures };
258
+ }
259
+
260
+ const grouped_failures: FailureGroup[] = [];
261
+ const compactFailures: T[] = [];
262
+
263
+ for (const [, group] of groupMap) {
264
+ const pattern = group.response_status
265
+ ? `${group.response_status} ${group.failure_type}`
266
+ : group.failure_type;
267
+ grouped_failures.push({
268
+ pattern,
269
+ count: group.items.length,
270
+ failure_type: group.failure_type,
271
+ hint: group.hint,
272
+ examples: group.items.slice(0, 2).map(f => `${f.suite_name}/${f.test_name}`),
273
+ response_status: group.response_status,
274
+ });
275
+ compactFailures.push(group.items[0]!);
276
+ }
277
+
278
+ return { grouped_failures, compactFailures };
279
+ }
280
+
281
+ export interface CompareResult {
282
+ runA: { id: number; started_at: string };
283
+ runB: { id: number; started_at: string };
284
+ summary: {
285
+ regressions: number;
286
+ fixes: number;
287
+ unchanged: number;
288
+ newTests: number;
289
+ removedTests: number;
290
+ };
291
+ regressions: Array<{ suite: string; test: string; before: string; after: string }>;
292
+ fixes: Array<{ suite: string; test: string; before: string; after: string }>;
293
+ hasRegressions: boolean;
294
+ }
295
+
296
+ export function compareRuns(idA: number, idB: number, dbPath?: string): CompareResult {
297
+ getDb(dbPath);
298
+ const runARecord = getRunById(idA);
299
+ const runBRecord = getRunById(idB);
300
+ if (!runARecord) throw new Error(`Run #${idA} not found`);
301
+ if (!runBRecord) throw new Error(`Run #${idB} not found`);
302
+
303
+ const resultsA = getResultsByRunId(idA);
304
+ const resultsB = getResultsByRunId(idB);
305
+
306
+ const mapA = new Map<string, string>();
307
+ const mapB = new Map<string, string>();
308
+ for (const r of resultsA) mapA.set(`${r.suite_name}::${r.test_name}`, r.status);
309
+ for (const r of resultsB) mapB.set(`${r.suite_name}::${r.test_name}`, r.status);
310
+
311
+ const regressions: Array<{ suite: string; test: string; before: string; after: string }> = [];
312
+ const fixes: Array<{ suite: string; test: string; before: string; after: string }> = [];
313
+ let unchanged = 0;
314
+ let newTests = 0;
315
+ let removedTests = 0;
316
+
317
+ for (const [key, statusB] of mapB) {
318
+ const statusA = mapA.get(key);
319
+ if (statusA === undefined) { newTests++; continue; }
320
+ const [suite, test] = key.split("::") as [string, string];
321
+ const wasPass = statusA === "pass";
322
+ const isPass = statusB === "pass";
323
+ const wasFail = statusA === "fail" || statusA === "error";
324
+ const isFail = statusB === "fail" || statusB === "error";
325
+ if (wasPass && isFail) regressions.push({ suite, test, before: statusA, after: statusB });
326
+ else if (wasFail && isPass) fixes.push({ suite, test, before: statusA, after: statusB });
327
+ else unchanged++;
328
+ }
329
+ for (const key of mapA.keys()) {
330
+ if (!mapB.has(key)) removedTests++;
331
+ }
332
+
333
+ return {
334
+ runA: { id: idA, started_at: runARecord.started_at },
335
+ runB: { id: idB, started_at: runBRecord.started_at },
336
+ summary: { regressions: regressions.length, fixes: fixes.length, unchanged, newTests, removedTests },
337
+ regressions,
338
+ fixes,
339
+ hasRegressions: regressions.length > 0,
340
+ };
341
+ }
342
+
343
+ export function getCollections(dbPath?: string) {
344
+ getDb(dbPath);
345
+ return listCollections();
346
+ }
347
+
348
+ export function getRuns(limit?: number, dbPath?: string) {
349
+ getDb(dbPath);
350
+ return listRuns(limit ?? 20);
351
+ }
@@ -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
 
@@ -38,10 +38,10 @@ export function generateFromSchema(
38
38
  return guessStringPlaceholder(schema, propertyName);
39
39
 
40
40
  case "integer":
41
- return guessIntPlaceholder(propertyName);
41
+ return guessIntPlaceholder(propertyName, schema);
42
42
 
43
43
  case "number":
44
- return "{{$randomInt}}";
44
+ return 29.99;
45
45
 
46
46
  case "boolean":
47
47
  return true;
@@ -86,6 +86,11 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
86
86
  if (schema.format === "email") return "{{$randomEmail}}";
87
87
  if (schema.format === "uuid") return "{{$uuid}}";
88
88
  if (schema.format === "date-time" || schema.format === "date") return "2025-01-01T00:00:00Z";
89
+ if (schema.format === "uri" || schema.format === "url") return "https://example.com/test";
90
+ if (schema.format === "hostname") return "example.com";
91
+ if (schema.format === "ipv4") return "192.168.1.1";
92
+ if (schema.format === "ipv6") return "::1";
93
+ if (schema.format === "password") return "TestPass123!";
89
94
 
90
95
  // Name-based heuristics
91
96
  if (name) {
@@ -99,17 +104,23 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
99
104
  if (lower === "name" || lower.endsWith("_name") || lower.endsWith("Name")) {
100
105
  return "{{$randomName}}";
101
106
  }
107
+ if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") {
108
+ return "https://example.com/test";
109
+ }
110
+ if (lower === "password" || lower.endsWith("_password")) {
111
+ return "TestPass123!";
112
+ }
113
+ if (lower === "phone" || lower === "telephone" || lower.endsWith("_phone")) {
114
+ return "+1234567890";
115
+ }
102
116
  }
103
117
 
104
118
  return "{{$randomString}}";
105
119
  }
106
120
 
107
- function guessIntPlaceholder(name?: string): string {
108
- if (name) {
109
- const lower = name.toLowerCase();
110
- if (lower === "id" || lower.endsWith("_id") || lower.endsWith("Id")) {
111
- return "{{$randomInt}}";
112
- }
121
+ function guessIntPlaceholder(name?: string, schema?: OpenAPIV3.SchemaObject): number | string {
122
+ if (schema?.minimum !== undefined && schema.minimum > 0) {
123
+ return schema.minimum;
113
124
  }
114
125
  return "{{$randomInt}}";
115
126
  }
@@ -0,0 +1,250 @@
1
+ import type { OpenAPIV3 } from "openapi-types";
2
+ import { readOpenApiSpec } from "./openapi-reader.ts";
3
+ import { decycleSchema } from "./schema-utils.ts";
4
+
5
+ export interface DescribeEndpointResult {
6
+ method: string;
7
+ path: string;
8
+ operationId?: string;
9
+ summary?: string;
10
+ description?: string;
11
+ tags?: string[];
12
+ deprecated: boolean;
13
+ security: string[];
14
+ parameters: Record<string, object[]>;
15
+ requestBody?: object;
16
+ responses: Record<string, object>;
17
+ testSnippet: string;
18
+ }
19
+
20
+ export interface CompactEndpoint {
21
+ method: string;
22
+ path: string;
23
+ operationId?: string;
24
+ summary?: string;
25
+ deprecated: boolean;
26
+ }
27
+
28
+ export function generateTestSnippet(params: {
29
+ method: string;
30
+ path: string;
31
+ operationId?: string;
32
+ pathParams: string[];
33
+ queryParams: Array<{ name: string; required?: boolean }>;
34
+ requestBody?: { required?: boolean; schema?: OpenAPIV3.SchemaObject };
35
+ hasSecurity: boolean;
36
+ successStatus: string;
37
+ }): string {
38
+ const { method, path, operationId, queryParams, requestBody, hasSecurity, successStatus } = params;
39
+
40
+ const urlPath = path.replace(/\{([^}]+)\}/g, (_, name) => `{{${name}}}`);
41
+ const url = `{{base_url}}${urlPath}`;
42
+
43
+ const lines: string[] = [];
44
+ const testName = operationId ?? `${method} ${path}`;
45
+ lines.push(`- name: "${testName}"`);
46
+ lines.push(` ${method}: "${url}"`);
47
+
48
+ if (hasSecurity) {
49
+ lines.push(` headers:`);
50
+ lines.push(` Authorization: "Bearer {{auth_token}}"`);
51
+ }
52
+
53
+ const requiredQuery = queryParams.filter(p => p.required);
54
+ if (requiredQuery.length > 0) {
55
+ lines.push(` query:`);
56
+ for (const p of requiredQuery) {
57
+ lines.push(` ${p.name}: "{{${p.name}}}"`);
58
+ }
59
+ }
60
+
61
+ if (requestBody && ["POST", "PUT", "PATCH"].includes(method)) {
62
+ const schema = requestBody.schema as OpenAPIV3.SchemaObject | undefined;
63
+ const required = Array.isArray(schema?.required) ? schema.required : [];
64
+ const properties = schema?.properties as Record<string, OpenAPIV3.SchemaObject> | undefined;
65
+ if (properties && Object.keys(properties).length > 0) {
66
+ lines.push(` json:`);
67
+ for (const [propName, propSchema] of Object.entries(properties)) {
68
+ if (!required.includes(propName)) continue;
69
+ const type = (propSchema as OpenAPIV3.SchemaObject).type ?? "string";
70
+ const placeholder = type === "integer" || type === "number" ? 0 : type === "boolean" ? false : `"{{${propName}}}"`;
71
+ lines.push(` ${propName}: ${placeholder}`);
72
+ }
73
+ }
74
+ }
75
+
76
+ lines.push(` expect:`);
77
+ lines.push(` status: ${successStatus}`);
78
+
79
+ return lines.join("\n");
80
+ }
81
+
82
+ export async function describeEndpoint(
83
+ specPath: string,
84
+ method: string,
85
+ endpointPath: string,
86
+ options?: { insecure?: boolean },
87
+ ): Promise<DescribeEndpointResult> {
88
+ const doc = await readOpenApiSpec(specPath, options) as OpenAPIV3.Document;
89
+
90
+ const methodLower = method.toLowerCase() as OpenAPIV3.HttpMethods;
91
+ const normalizedPath = endpointPath.replace(/\/+$/, "") || "/";
92
+
93
+ let operation: OpenAPIV3.OperationObject | undefined;
94
+ let resolvedPath = normalizedPath;
95
+ const paths = doc.paths ?? {};
96
+
97
+ if (paths[normalizedPath]?.[methodLower]) {
98
+ operation = paths[normalizedPath][methodLower] as OpenAPIV3.OperationObject;
99
+ } else {
100
+ const lowerTarget = normalizedPath.toLowerCase();
101
+ for (const [p, pathItem] of Object.entries(paths)) {
102
+ if (p.toLowerCase() === lowerTarget && pathItem?.[methodLower]) {
103
+ operation = pathItem[methodLower] as OpenAPIV3.OperationObject;
104
+ resolvedPath = p;
105
+ break;
106
+ }
107
+ }
108
+ }
109
+
110
+ if (!operation) {
111
+ const available = Object.entries(paths).flatMap(([p, pathItem]) =>
112
+ Object.keys(pathItem ?? {})
113
+ .filter(k => ["get","post","put","patch","delete","head","options","trace"].includes(k))
114
+ .map(k => `${k.toUpperCase()} ${p}`)
115
+ ).sort();
116
+ throw new Error(`Endpoint ${method.toUpperCase()} ${endpointPath} not found in spec. Available: ${available.join(", ")}`);
117
+ }
118
+
119
+ const pathItem = paths[resolvedPath] ?? {};
120
+
121
+ const pathLevelParams = (pathItem.parameters ?? []) as OpenAPIV3.ParameterObject[];
122
+ const opLevelParams = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[];
123
+
124
+ const paramMap = new Map<string, OpenAPIV3.ParameterObject>();
125
+ for (const p of pathLevelParams) paramMap.set(`${p.in}:${p.name}`, p);
126
+ for (const p of opLevelParams) paramMap.set(`${p.in}:${p.name}`, p);
127
+
128
+ const grouped: Record<string, object[]> = { path: [], query: [], header: [], cookie: [] };
129
+ for (const p of paramMap.values()) {
130
+ const loc = p.in in grouped ? p.in : "query";
131
+ const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
132
+ grouped[loc]!.push({
133
+ name: p.name,
134
+ required: p.required ?? false,
135
+ ...(schema?.type ? { type: schema.type } : {}),
136
+ ...(schema?.format ? { format: schema.format } : {}),
137
+ ...(schema?.enum ? { enum: schema.enum } : {}),
138
+ ...(schema?.default !== undefined ? { default: schema.default } : {}),
139
+ ...(p.description ? { description: p.description } : {}),
140
+ });
141
+ }
142
+
143
+ let requestBody: object | undefined;
144
+ if (operation.requestBody) {
145
+ const rb = operation.requestBody as OpenAPIV3.RequestBodyObject;
146
+ const contentTypes = Object.keys(rb.content ?? {});
147
+ const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
148
+ const mediaObj = preferredCt ? rb.content[preferredCt] : undefined;
149
+ requestBody = {
150
+ required: rb.required ?? false,
151
+ ...(preferredCt ? { contentType: preferredCt } : {}),
152
+ ...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
153
+ ...(rb.description ? { description: rb.description } : {}),
154
+ };
155
+ }
156
+
157
+ const responses: Record<string, object> = {};
158
+ for (const [statusCode, respObj] of Object.entries(operation.responses ?? {})) {
159
+ const resp = respObj as OpenAPIV3.ResponseObject;
160
+ const contentTypes = Object.keys(resp.content ?? {});
161
+ const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
162
+ const mediaObj = preferredCt ? resp.content?.[preferredCt] : undefined;
163
+
164
+ const headers: Record<string, object> = {};
165
+ for (const [hName, hObj] of Object.entries(resp.headers ?? {})) {
166
+ const h = hObj as OpenAPIV3.HeaderObject;
167
+ headers[hName] = {
168
+ ...(h.description ? { description: h.description } : {}),
169
+ ...(h.schema ? { schema: h.schema } : {}),
170
+ };
171
+ }
172
+
173
+ responses[statusCode] = {
174
+ description: resp.description,
175
+ headers,
176
+ ...(preferredCt ? { contentType: preferredCt } : {}),
177
+ ...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
178
+ };
179
+ }
180
+
181
+ const docSecurity = (doc.security ?? []) as OpenAPIV3.SecurityRequirementObject[];
182
+ const opSecurity = (operation.security ?? docSecurity) as OpenAPIV3.SecurityRequirementObject[];
183
+ const securityNames = [...new Set(opSecurity.flatMap(req => Object.keys(req)))];
184
+
185
+ const responseCodes = Object.keys(operation.responses ?? {});
186
+ const successStatus = responseCodes.find(c => c.startsWith("2")) ?? responseCodes[0] ?? "200";
187
+
188
+ const pathParamNames = [...paramMap.values()]
189
+ .filter(p => p.in === "path")
190
+ .map(p => p.name);
191
+ const queryParamsList = [...paramMap.values()]
192
+ .filter(p => p.in === "query")
193
+ .map(p => ({ name: p.name, required: p.required }));
194
+ const reqBodyForSnippet = requestBody
195
+ ? { required: (operation.requestBody as OpenAPIV3.RequestBodyObject)?.required, schema: (requestBody as any).schema }
196
+ : undefined;
197
+
198
+ const testSnippet = generateTestSnippet({
199
+ method: method.toUpperCase(),
200
+ path: resolvedPath,
201
+ operationId: operation.operationId,
202
+ pathParams: pathParamNames,
203
+ queryParams: queryParamsList,
204
+ requestBody: reqBodyForSnippet,
205
+ hasSecurity: securityNames.length > 0,
206
+ successStatus,
207
+ });
208
+
209
+ const result: DescribeEndpointResult = {
210
+ method: method.toUpperCase(),
211
+ path: resolvedPath,
212
+ ...(operation.operationId ? { operationId: operation.operationId } : {}),
213
+ ...(operation.summary ? { summary: operation.summary } : {}),
214
+ ...(operation.description ? { description: operation.description } : {}),
215
+ ...(operation.tags?.length ? { tags: operation.tags } : {}),
216
+ deprecated: operation.deprecated ?? false,
217
+ security: securityNames,
218
+ parameters: grouped,
219
+ ...(requestBody ? { requestBody } : {}),
220
+ responses,
221
+ testSnippet,
222
+ };
223
+
224
+ return decycleSchema(result) as DescribeEndpointResult;
225
+ }
226
+
227
+ export async function describeCompact(
228
+ specPath: string,
229
+ options?: { insecure?: boolean },
230
+ ): Promise<CompactEndpoint[]> {
231
+ const doc = await readOpenApiSpec(specPath, options) as OpenAPIV3.Document;
232
+ const paths = doc.paths ?? {};
233
+ const result: CompactEndpoint[] = [];
234
+
235
+ for (const [path, pathItem] of Object.entries(paths)) {
236
+ for (const method of ["get","post","put","patch","delete","head","options","trace"]) {
237
+ const op = (pathItem as any)?.[method] as OpenAPIV3.OperationObject | undefined;
238
+ if (!op) continue;
239
+ result.push({
240
+ method: method.toUpperCase(),
241
+ path,
242
+ ...(op.operationId ? { operationId: op.operationId } : {}),
243
+ ...(op.summary ? { summary: op.summary } : {}),
244
+ deprecated: op.deprecated ?? false,
245
+ });
246
+ }
247
+ }
248
+
249
+ return result;
250
+ }
@@ -196,6 +196,26 @@ Use spec paths with \`{param}\` placeholders in the path for coverage to match:
196
196
  - Spec says \`GET /products/{id}\` → write \`GET: /products/1\` (hardcode the value)
197
197
  - Coverage scanner matches test paths against spec paths automatically
198
198
 
199
+ ### Suite variable isolation — IMPORTANT
200
+ Each suite runs in its own variable scope. Captured variables (via \`capture:\`) do NOT propagate between suites.
201
+ If multiple suites need auth, each suite must either:
202
+ - Include its own login step with \`capture: auth_token\`
203
+ - Or use \`auth_token\` from \`.env.yaml\` (pre-configured, no capture needed)
204
+
205
+ Do NOT create a separate "setup" suite expecting other suites to use its captures.
206
+
207
+ ### ETag / Conditional Requests
208
+ If-Match and If-None-Match require escaped quotes around the ETag value:
209
+ \`\`\`yaml
210
+ - name: Update with ETag
211
+ PUT: /items/{{item_id}}
212
+ headers:
213
+ If-Match: "\\"{{etag}}\\""
214
+ json: { name: "updated" }
215
+ expect:
216
+ status: 200
217
+ \`\`\`
218
+
199
219
  ### CRITICAL: Never mask server errors
200
220
  - If an endpoint returns 500 — do NOT change expect to \`status: 500\`. Keep \`status: 200\` and let the test fail.
201
221
  - A failing test = signal about an API bug. The goal is NOT "all tests green" but "tests reflect expected behavior".