@kirrosh/zond 0.13.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.
- package/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/package.json +4 -7
- package/src/cli/commands/ci-init.ts +12 -1
- package/src/cli/commands/coverage.ts +21 -1
- package/src/cli/commands/db.ts +121 -0
- package/src/cli/commands/describe.ts +60 -0
- package/src/cli/commands/generate.ts +127 -0
- package/src/cli/commands/guide.ts +127 -0
- package/src/cli/commands/init.ts +50 -77
- package/src/cli/commands/request.ts +57 -0
- package/src/cli/commands/run.ts +53 -10
- package/src/cli/commands/serve.ts +62 -3
- package/src/cli/commands/validate.ts +18 -2
- package/src/cli/index.ts +213 -215
- package/src/cli/json-envelope.ts +19 -0
- package/src/core/diagnostics/db-analysis.ts +351 -0
- package/src/core/diagnostics/failure-hints.ts +1 -0
- package/src/core/generator/data-factory.ts +19 -8
- package/src/core/generator/describe.ts +250 -0
- package/src/core/generator/guide-builder.ts +20 -0
- package/src/core/generator/index.ts +0 -3
- package/src/core/generator/suite-generator.ts +133 -20
- package/src/core/runner/executor.ts +1 -0
- package/src/core/runner/send-request.ts +94 -0
- package/src/core/runner/types.ts +1 -0
- package/src/db/queries.ts +4 -2
- package/src/db/schema.ts +11 -3
- package/src/mcp/descriptions.ts +0 -24
- package/src/mcp/server.ts +1 -8
- package/src/mcp/tools/describe-endpoint.ts +3 -218
- package/src/mcp/tools/query-db.ts +6 -222
- package/src/mcp/tools/run-tests.ts +1 -0
- package/src/mcp/tools/send-request.ts +15 -61
- package/src/web/views/suites-tab.ts +1 -1
- package/src/cli/commands/add-api.ts +0 -53
- package/src/cli/commands/ai-generate.ts +0 -106
- package/src/cli/commands/chat.ts +0 -43
- package/src/cli/commands/collections.ts +0 -41
- package/src/cli/commands/compare.ts +0 -129
- package/src/cli/commands/doctor.ts +0 -127
- package/src/cli/commands/runs.ts +0 -108
- package/src/cli/commands/update.ts +0 -142
- package/src/core/agent/agent-loop.ts +0 -116
- package/src/core/agent/context-manager.ts +0 -41
- package/src/core/agent/system-prompt.ts +0 -27
- package/src/core/agent/tools/diagnose-failure.ts +0 -51
- package/src/core/agent/tools/index.ts +0 -42
- package/src/core/agent/tools/query-results.ts +0 -40
- package/src/core/agent/tools/run-tests.ts +0 -38
- package/src/core/agent/tools/send-request.ts +0 -44
- package/src/core/agent/types.ts +0 -22
- package/src/core/generator/ai/ai-generator.ts +0 -61
- package/src/core/generator/ai/llm-client.ts +0 -159
- package/src/core/generator/ai/output-parser.ts +0 -307
- package/src/core/generator/ai/prompt-builder.ts +0 -153
- package/src/core/generator/ai/types.ts +0 -56
- package/src/mcp/tools/generate-and-save.ts +0 -202
- package/src/mcp/tools/save-test-suite.ts +0 -218
- package/src/mcp/tools/set-work-dir.ts +0 -35
- package/src/tui/chat-ui.ts +0 -150
|
@@ -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
|
|
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 (
|
|
109
|
-
|
|
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".
|