@kirrosh/zond 0.9.5 → 0.12.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/package.json +1 -1
- package/src/core/generator/index.ts +1 -0
- package/src/core/generator/serializer.ts +4 -0
- package/src/core/generator/suite-generator.ts +413 -0
- package/src/core/runner/execute-run.ts +12 -0
- package/src/core/setup-api.ts +45 -15
- package/src/mcp/descriptions.ts +3 -1
- package/src/mcp/tools/generate-and-save.ts +66 -4
- package/src/mcp/tools/query-db.ts +22 -3
- package/src/mcp/tools/run-tests.ts +20 -4
- package/src/mcp/tools/setup-api.ts +1 -1
package/package.json
CHANGED
|
@@ -12,3 +12,4 @@ export { compressEndpointsWithSchemas, buildGenerationGuide } from "./guide-buil
|
|
|
12
12
|
export type { GuideOptions } from "./guide-builder.ts";
|
|
13
13
|
export type { EndpointWarning, WarningCode } from "./endpoint-warnings.ts";
|
|
14
14
|
export type { EndpointInfo, ResponseInfo, GenerateOptions, SecuritySchemeInfo, CrudGroup } from "./types.ts";
|
|
15
|
+
export { generateSuites, generateStep, detectCrudGroups, generateCrudSuite, findUnresolvedVars } from "./suite-generator.ts";
|
|
@@ -34,6 +34,7 @@ export interface RawStep {
|
|
|
34
34
|
|
|
35
35
|
export interface RawSuite {
|
|
36
36
|
name: string;
|
|
37
|
+
tags?: string[];
|
|
37
38
|
folder?: string;
|
|
38
39
|
fileStem?: string;
|
|
39
40
|
base_url?: string;
|
|
@@ -48,6 +49,9 @@ export interface RawSuite {
|
|
|
48
49
|
export function serializeSuite(suite: RawSuite): string {
|
|
49
50
|
const lines: string[] = [];
|
|
50
51
|
lines.push(`name: ${yamlScalar(suite.name)}`);
|
|
52
|
+
if (suite.tags && suite.tags.length > 0) {
|
|
53
|
+
lines.push(`tags: [${suite.tags.join(", ")}]`);
|
|
54
|
+
}
|
|
51
55
|
if (suite.base_url) {
|
|
52
56
|
lines.push(`base_url: ${yamlScalar(suite.base_url)}`);
|
|
53
57
|
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
2
|
+
import type { EndpointInfo, SecuritySchemeInfo, CrudGroup } from "./types.ts";
|
|
3
|
+
import type { RawSuite, RawStep } from "./serializer.ts";
|
|
4
|
+
import { generateFromSchema } from "./data-factory.ts";
|
|
5
|
+
import { groupEndpointsByTag } from "./chunker.ts";
|
|
6
|
+
|
|
7
|
+
// ──────────────────────────────────────────────
|
|
8
|
+
// Helpers
|
|
9
|
+
// ──────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/** Convert OpenAPI path params {param} to test interpolation {{param}} */
|
|
12
|
+
function convertPath(path: string): string {
|
|
13
|
+
return path.replace(/\{([^}]+)\}/g, "{{$1}}");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function slugify(s: string): string {
|
|
17
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function escapeRegex(s: string): string {
|
|
21
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getExpectedStatus(ep: EndpointInfo): number {
|
|
25
|
+
const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
|
|
26
|
+
if (success) return success.statusCode;
|
|
27
|
+
if (ep.responses.length > 0) return ep.responses[0].statusCode;
|
|
28
|
+
return 200;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getSuccessSchema(ep: EndpointInfo): OpenAPIV3.SchemaObject | undefined {
|
|
32
|
+
return ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300)?.schema;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getBodyAssertions(ep: EndpointInfo): Record<string, Record<string, string>> | undefined {
|
|
36
|
+
const schema = getSuccessSchema(ep);
|
|
37
|
+
if (!schema) return undefined;
|
|
38
|
+
|
|
39
|
+
if (schema.type === "array") {
|
|
40
|
+
return { _body: { type: "array" } };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (schema.properties) {
|
|
44
|
+
const assertions: Record<string, Record<string, string>> = {};
|
|
45
|
+
const props = Object.keys(schema.properties).slice(0, 5);
|
|
46
|
+
for (const prop of props) {
|
|
47
|
+
assertions[prop] = { exists: "true" };
|
|
48
|
+
}
|
|
49
|
+
return Object.keys(assertions).length > 0 ? assertions : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getAuthHeaders(
|
|
56
|
+
ep: EndpointInfo,
|
|
57
|
+
schemes: SecuritySchemeInfo[],
|
|
58
|
+
): Record<string, string> | undefined {
|
|
59
|
+
if (ep.security.length === 0) return undefined;
|
|
60
|
+
|
|
61
|
+
for (const secName of ep.security) {
|
|
62
|
+
const scheme = schemes.find(s => s.name === secName);
|
|
63
|
+
if (!scheme) continue;
|
|
64
|
+
|
|
65
|
+
if (scheme.type === "http" && scheme.scheme === "bearer") {
|
|
66
|
+
return { Authorization: "Bearer {{auth_token}}" };
|
|
67
|
+
}
|
|
68
|
+
if (scheme.type === "apiKey" && scheme.in === "header" && scheme.apiKeyName) {
|
|
69
|
+
return { [scheme.apiKeyName]: "{{api_key}}" };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getRequiredQueryParams(ep: EndpointInfo): Record<string, unknown> | undefined {
|
|
77
|
+
const queryParams = ep.parameters.filter(p => p.in === "query" && p.required);
|
|
78
|
+
if (queryParams.length === 0) return undefined;
|
|
79
|
+
|
|
80
|
+
const query: Record<string, unknown> = {};
|
|
81
|
+
for (const p of queryParams) {
|
|
82
|
+
if (p.schema) {
|
|
83
|
+
query[p.name] = generateFromSchema(p.schema as OpenAPIV3.SchemaObject, p.name);
|
|
84
|
+
} else {
|
|
85
|
+
query[p.name] = "{{$randomString}}";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return query;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Check if all endpoints share the same auth headers → suite-level */
|
|
92
|
+
function getSuiteHeaders(
|
|
93
|
+
endpoints: EndpointInfo[],
|
|
94
|
+
schemes: SecuritySchemeInfo[],
|
|
95
|
+
): Record<string, string> | undefined {
|
|
96
|
+
if (endpoints.length === 0) return undefined;
|
|
97
|
+
|
|
98
|
+
const headerSets = endpoints.map(ep => getAuthHeaders(ep, schemes));
|
|
99
|
+
const first = headerSets[0];
|
|
100
|
+
if (!first) return undefined;
|
|
101
|
+
|
|
102
|
+
const firstJson = JSON.stringify(first);
|
|
103
|
+
const allSame = headerSets.every(h => JSON.stringify(h) === firstJson);
|
|
104
|
+
return allSame ? first : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Find the best field to capture from POST response (for CRUD chains) */
|
|
108
|
+
function getCaptureField(ep: EndpointInfo): string {
|
|
109
|
+
const schema = getSuccessSchema(ep);
|
|
110
|
+
if (schema?.properties) {
|
|
111
|
+
if ("id" in schema.properties) return "id";
|
|
112
|
+
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
|
113
|
+
const s = propSchema as OpenAPIV3.SchemaObject;
|
|
114
|
+
if (s.type === "integer" || s.format === "uuid") return name;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return "id";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ──────────────────────────────────────────────
|
|
121
|
+
// Public API
|
|
122
|
+
// ──────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/** Generate a single test step from an EndpointInfo */
|
|
125
|
+
export function generateStep(
|
|
126
|
+
ep: EndpointInfo,
|
|
127
|
+
securitySchemes: SecuritySchemeInfo[],
|
|
128
|
+
): RawStep {
|
|
129
|
+
const method = ep.method.toUpperCase();
|
|
130
|
+
const name = ep.operationId ?? ep.summary ?? `${method} ${ep.path}`;
|
|
131
|
+
const path = convertPath(ep.path);
|
|
132
|
+
|
|
133
|
+
const step: RawStep = {
|
|
134
|
+
name,
|
|
135
|
+
[method]: path,
|
|
136
|
+
expect: {
|
|
137
|
+
status: getExpectedStatus(ep),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const authHeaders = getAuthHeaders(ep, securitySchemes);
|
|
142
|
+
if (authHeaders) {
|
|
143
|
+
step.headers = authHeaders;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (["POST", "PUT", "PATCH"].includes(method) && ep.requestBodySchema) {
|
|
147
|
+
step.json = generateFromSchema(ep.requestBodySchema);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const query = getRequiredQueryParams(ep);
|
|
151
|
+
if (query) {
|
|
152
|
+
step.query = query;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const body = getBodyAssertions(ep);
|
|
156
|
+
if (body) {
|
|
157
|
+
step.expect.body = body;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return step;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Detect CRUD groups from a list of endpoints */
|
|
164
|
+
export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
|
|
165
|
+
const groups: CrudGroup[] = [];
|
|
166
|
+
const postEndpoints = endpoints.filter(ep => ep.method.toUpperCase() === "POST" && !ep.deprecated);
|
|
167
|
+
|
|
168
|
+
for (const createEp of postEndpoints) {
|
|
169
|
+
const basePath = createEp.path;
|
|
170
|
+
|
|
171
|
+
// Find item endpoints: basePath/{param}
|
|
172
|
+
const itemPattern = new RegExp(`^${escapeRegex(basePath)}/\\{([^}]+)\\}$`);
|
|
173
|
+
const itemEndpoints = endpoints.filter(ep => !ep.deprecated && itemPattern.test(ep.path));
|
|
174
|
+
|
|
175
|
+
if (itemEndpoints.length === 0) continue;
|
|
176
|
+
|
|
177
|
+
const itemPath = itemEndpoints[0].path;
|
|
178
|
+
const idMatch = itemPath.match(/\{([^}]+)\}$/);
|
|
179
|
+
if (!idMatch) continue;
|
|
180
|
+
const idParam = idMatch[1];
|
|
181
|
+
|
|
182
|
+
const read = itemEndpoints.find(ep => ep.method.toUpperCase() === "GET");
|
|
183
|
+
if (!read) continue; // Minimum: POST + GET/{id}
|
|
184
|
+
|
|
185
|
+
const update = itemEndpoints.find(ep => ["PUT", "PATCH"].includes(ep.method.toUpperCase()));
|
|
186
|
+
const del = itemEndpoints.find(ep => ep.method.toUpperCase() === "DELETE");
|
|
187
|
+
const list = endpoints.find(ep => ep.method.toUpperCase() === "GET" && ep.path === basePath && !ep.deprecated);
|
|
188
|
+
|
|
189
|
+
const resource = basePath.split("/").filter(Boolean).pop() ?? "resource";
|
|
190
|
+
|
|
191
|
+
groups.push({
|
|
192
|
+
resource,
|
|
193
|
+
basePath,
|
|
194
|
+
itemPath,
|
|
195
|
+
idParam,
|
|
196
|
+
create: createEp,
|
|
197
|
+
list,
|
|
198
|
+
read,
|
|
199
|
+
update,
|
|
200
|
+
delete: del,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return groups;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Generate a CRUD chain suite from a CrudGroup */
|
|
208
|
+
export function generateCrudSuite(
|
|
209
|
+
group: CrudGroup,
|
|
210
|
+
securitySchemes: SecuritySchemeInfo[],
|
|
211
|
+
): RawSuite {
|
|
212
|
+
const captureField = group.create ? getCaptureField(group.create) : "id";
|
|
213
|
+
const captureVar = `${group.resource.replace(/s$/, "")}_id`;
|
|
214
|
+
const tests: RawStep[] = [];
|
|
215
|
+
|
|
216
|
+
const allEps = [group.create, group.list, group.read, group.update, group.delete].filter(Boolean) as EndpointInfo[];
|
|
217
|
+
const suiteHeaders = getSuiteHeaders(allEps, securitySchemes);
|
|
218
|
+
|
|
219
|
+
// 1. Create
|
|
220
|
+
if (group.create) {
|
|
221
|
+
const step = generateStep(group.create, securitySchemes);
|
|
222
|
+
if (!step.expect.body) step.expect.body = {};
|
|
223
|
+
step.expect.body[captureField] = { capture: captureVar };
|
|
224
|
+
if (suiteHeaders) delete (step as any).headers;
|
|
225
|
+
tests.push(step);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 2. Read created
|
|
229
|
+
if (group.read) {
|
|
230
|
+
const step: RawStep = {
|
|
231
|
+
name: group.read.operationId ?? `Read created ${group.resource.replace(/s$/, "")}`,
|
|
232
|
+
GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
|
|
233
|
+
expect: {
|
|
234
|
+
status: getExpectedStatus(group.read),
|
|
235
|
+
body: getBodyAssertions(group.read),
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
tests.push(step);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 3. Update
|
|
242
|
+
if (group.update) {
|
|
243
|
+
const method = group.update.method.toUpperCase();
|
|
244
|
+
const step: RawStep = {
|
|
245
|
+
name: group.update.operationId ?? `Update ${group.resource.replace(/s$/, "")}`,
|
|
246
|
+
[method]: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
|
|
247
|
+
expect: {
|
|
248
|
+
status: getExpectedStatus(group.update),
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
if (group.update.requestBodySchema) {
|
|
252
|
+
step.json = generateFromSchema(group.update.requestBodySchema);
|
|
253
|
+
}
|
|
254
|
+
tests.push(step);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 4. Delete
|
|
258
|
+
if (group.delete) {
|
|
259
|
+
const step: RawStep = {
|
|
260
|
+
name: group.delete.operationId ?? `Delete ${group.resource.replace(/s$/, "")}`,
|
|
261
|
+
DELETE: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
|
|
262
|
+
expect: {
|
|
263
|
+
status: getExpectedStatus(group.delete),
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
tests.push(step);
|
|
267
|
+
|
|
268
|
+
// 5. Verify deleted
|
|
269
|
+
if (group.read) {
|
|
270
|
+
tests.push({
|
|
271
|
+
name: `Verify ${group.resource.replace(/s$/, "")} deleted`,
|
|
272
|
+
GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
|
|
273
|
+
expect: {
|
|
274
|
+
status: 404,
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const suite: RawSuite = {
|
|
281
|
+
name: `${group.resource}-crud`,
|
|
282
|
+
tags: ["crud"],
|
|
283
|
+
fileStem: `crud-${slugify(group.resource)}`,
|
|
284
|
+
base_url: "{{base_url}}",
|
|
285
|
+
tests,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
if (suiteHeaders) {
|
|
289
|
+
suite.headers = suiteHeaders;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return suite;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Find unresolved template variables in a suite (excluding known globals and captured vars) */
|
|
296
|
+
export function findUnresolvedVars(suite: RawSuite): string[] {
|
|
297
|
+
const KNOWN = new Set(["base_url", "auth_token", "api_key"]);
|
|
298
|
+
const captured = new Set<string>();
|
|
299
|
+
for (const step of suite.tests) {
|
|
300
|
+
if (step.expect?.body) {
|
|
301
|
+
for (const val of Object.values(step.expect.body)) {
|
|
302
|
+
if (val && typeof val === "object" && "capture" in val) captured.add((val as any).capture);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const vars = new Set<string>();
|
|
307
|
+
const scan = (obj: unknown) => {
|
|
308
|
+
if (typeof obj === "string") {
|
|
309
|
+
for (const m of obj.matchAll(/\{\{([^$}][^}]*)\}\}/g)) {
|
|
310
|
+
if (!KNOWN.has(m[1]) && !captured.has(m[1])) vars.add(m[1]);
|
|
311
|
+
}
|
|
312
|
+
} else if (obj && typeof obj === "object") {
|
|
313
|
+
for (const v of Object.values(obj)) scan(v);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
scan(suite);
|
|
317
|
+
return [...vars];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Main entry point: generate all suites from endpoints */
|
|
321
|
+
export function generateSuites(opts: {
|
|
322
|
+
endpoints: EndpointInfo[];
|
|
323
|
+
securitySchemes: SecuritySchemeInfo[];
|
|
324
|
+
}): RawSuite[] {
|
|
325
|
+
const { endpoints, securitySchemes } = opts;
|
|
326
|
+
|
|
327
|
+
// Filter deprecated
|
|
328
|
+
const active = endpoints.filter(ep => !ep.deprecated);
|
|
329
|
+
|
|
330
|
+
// 1. Detect CRUD groups
|
|
331
|
+
const crudGroups = detectCrudGroups(active);
|
|
332
|
+
|
|
333
|
+
// Collect endpoints consumed by CRUD groups
|
|
334
|
+
const crudEndpointKeys = new Set<string>();
|
|
335
|
+
for (const g of crudGroups) {
|
|
336
|
+
if (g.create) crudEndpointKeys.add(`${g.create.method.toUpperCase()} ${g.create.path}`);
|
|
337
|
+
if (g.list) crudEndpointKeys.add(`${g.list.method.toUpperCase()} ${g.list.path}`);
|
|
338
|
+
if (g.read) crudEndpointKeys.add(`${g.read.method.toUpperCase()} ${g.read.path}`);
|
|
339
|
+
if (g.update) crudEndpointKeys.add(`${g.update.method.toUpperCase()} ${g.update.path}`);
|
|
340
|
+
if (g.delete) crudEndpointKeys.add(`${g.delete.method.toUpperCase()} ${g.delete.path}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Remaining endpoints (not in any CRUD group)
|
|
344
|
+
const remaining = active.filter(ep => !crudEndpointKeys.has(`${ep.method.toUpperCase()} ${ep.path}`));
|
|
345
|
+
|
|
346
|
+
const suites: RawSuite[] = [];
|
|
347
|
+
|
|
348
|
+
// 2. Group remaining by tag → smoke + smoke-unsafe
|
|
349
|
+
const byTag = groupEndpointsByTag(remaining);
|
|
350
|
+
|
|
351
|
+
for (const [tag, tagEndpoints] of byTag) {
|
|
352
|
+
const tagSlug = slugify(tag) || "api";
|
|
353
|
+
|
|
354
|
+
// GET endpoints → smoke suite
|
|
355
|
+
const getEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() === "GET");
|
|
356
|
+
if (getEndpoints.length > 0) {
|
|
357
|
+
const tests = getEndpoints.map(ep => generateStep(ep, securitySchemes));
|
|
358
|
+
const headers = getSuiteHeaders(getEndpoints, securitySchemes);
|
|
359
|
+
|
|
360
|
+
const suite: RawSuite = {
|
|
361
|
+
name: `${tagSlug}-smoke`,
|
|
362
|
+
tags: ["smoke"],
|
|
363
|
+
fileStem: `smoke-${tagSlug}`,
|
|
364
|
+
base_url: "{{base_url}}",
|
|
365
|
+
tests,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
if (headers) {
|
|
369
|
+
suite.headers = headers;
|
|
370
|
+
for (const t of tests) {
|
|
371
|
+
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
372
|
+
delete (t as any).headers;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
suites.push(suite);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Non-GET endpoints → smoke-unsafe suite
|
|
381
|
+
const unsafeEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() !== "GET");
|
|
382
|
+
if (unsafeEndpoints.length > 0) {
|
|
383
|
+
const tests = unsafeEndpoints.map(ep => generateStep(ep, securitySchemes));
|
|
384
|
+
const headers = getSuiteHeaders(unsafeEndpoints, securitySchemes);
|
|
385
|
+
|
|
386
|
+
const suite: RawSuite = {
|
|
387
|
+
name: `${tagSlug}-smoke-unsafe`,
|
|
388
|
+
tags: ["smoke", "unsafe"],
|
|
389
|
+
fileStem: `smoke-${tagSlug}-unsafe`,
|
|
390
|
+
base_url: "{{base_url}}",
|
|
391
|
+
tests,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
if (headers) {
|
|
395
|
+
suite.headers = headers;
|
|
396
|
+
for (const t of tests) {
|
|
397
|
+
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
398
|
+
delete (t as any).headers;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
suites.push(suite);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// 3. CRUD suites
|
|
408
|
+
for (const group of crudGroups) {
|
|
409
|
+
suites.push(generateCrudSuite(group, securitySchemes));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return suites;
|
|
413
|
+
}
|
|
@@ -17,6 +17,7 @@ export interface ExecuteRunOptions {
|
|
|
17
17
|
tag?: string[];
|
|
18
18
|
envVars?: Record<string, string>;
|
|
19
19
|
dryRun?: boolean;
|
|
20
|
+
rerunFilter?: Set<string>; // "suite_name::test_name" keys to rerun
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface ExecuteRunResult {
|
|
@@ -40,6 +41,17 @@ export async function executeRun(options: ExecuteRunOptions): Promise<ExecuteRun
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// Rerun filter: keep only specific failed tests
|
|
45
|
+
if (options.rerunFilter && options.rerunFilter.size > 0) {
|
|
46
|
+
for (const suite of suites) {
|
|
47
|
+
suite.tests = suite.tests.filter(t => options.rerunFilter!.has(`${suite.name}::${t.name}`));
|
|
48
|
+
}
|
|
49
|
+
suites = suites.filter(s => s.tests.length > 0);
|
|
50
|
+
if (suites.length === 0) {
|
|
51
|
+
throw new Error("No matching tests found for rerun filter");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
// Safe mode: filter to GET-only tests
|
|
44
56
|
if (safe) {
|
|
45
57
|
for (const suite of suites) {
|
package/src/core/setup-api.ts
CHANGED
|
@@ -14,7 +14,7 @@ function toYaml(vars: Record<string, string>): string {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export interface SetupApiOptions {
|
|
17
|
-
name
|
|
17
|
+
name?: string;
|
|
18
18
|
spec?: string;
|
|
19
19
|
dir?: string;
|
|
20
20
|
envVars?: Record<string, string>;
|
|
@@ -29,13 +29,49 @@ export interface SetupApiResult {
|
|
|
29
29
|
testPath: string;
|
|
30
30
|
baseUrl: string;
|
|
31
31
|
specEndpoints: number;
|
|
32
|
+
pathParams?: Record<string, string>;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult> {
|
|
35
|
-
const {
|
|
36
|
+
const { spec, dbPath } = options;
|
|
36
37
|
|
|
37
38
|
getDb(dbPath);
|
|
38
39
|
|
|
40
|
+
// Try to load and validate spec, extract base_url
|
|
41
|
+
let openapiSpec: string | null = null;
|
|
42
|
+
let baseUrl = "";
|
|
43
|
+
let endpointCount = 0;
|
|
44
|
+
const pathParams = new Map<string, string>();
|
|
45
|
+
let specTitle: string | undefined;
|
|
46
|
+
if (spec) {
|
|
47
|
+
const doc = await readOpenApiSpec(spec);
|
|
48
|
+
openapiSpec = spec;
|
|
49
|
+
if ((doc as any).servers?.[0]?.url) {
|
|
50
|
+
baseUrl = (doc as any).servers[0].url;
|
|
51
|
+
}
|
|
52
|
+
specTitle = (doc as any).info?.title;
|
|
53
|
+
const endpoints = extractEndpoints(doc);
|
|
54
|
+
endpointCount = endpoints.length;
|
|
55
|
+
|
|
56
|
+
// Collect unique path parameters with default values
|
|
57
|
+
for (const ep of endpoints) {
|
|
58
|
+
for (const param of (ep.parameters ?? []).filter(p => p.in === "path")) {
|
|
59
|
+
if (pathParams.has(param.name)) continue;
|
|
60
|
+
const schema = param.schema as any;
|
|
61
|
+
if (param.example !== undefined) pathParams.set(param.name, String(param.example));
|
|
62
|
+
else if (schema?.example !== undefined) pathParams.set(param.name, String(schema.example));
|
|
63
|
+
else if (schema?.type === "integer" || schema?.type === "number") pathParams.set(param.name, "1");
|
|
64
|
+
else pathParams.set(param.name, "example");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Derive name: explicit > spec title > filename
|
|
70
|
+
const name = options.name
|
|
71
|
+
?? specTitle?.replace(/[^a-zA-Z0-9_\-\.]/g, "-").toLowerCase()
|
|
72
|
+
?? spec?.split(/[/\\]/).pop()?.replace(/\.\w+$/, "")
|
|
73
|
+
?? "api";
|
|
74
|
+
|
|
39
75
|
// Validate name uniqueness (or force-replace)
|
|
40
76
|
const existing = findCollectionByNameOrId(name);
|
|
41
77
|
if (existing) {
|
|
@@ -54,22 +90,13 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
|
|
|
54
90
|
// Create directories
|
|
55
91
|
mkdirSync(testPath, { recursive: true });
|
|
56
92
|
|
|
57
|
-
// Try to load and validate spec, extract base_url
|
|
58
|
-
let openapiSpec: string | null = null;
|
|
59
|
-
let baseUrl = "";
|
|
60
|
-
let endpointCount = 0;
|
|
61
|
-
if (spec) {
|
|
62
|
-
const doc = await readOpenApiSpec(spec);
|
|
63
|
-
openapiSpec = spec;
|
|
64
|
-
if ((doc as any).servers?.[0]?.url) {
|
|
65
|
-
baseUrl = (doc as any).servers[0].url;
|
|
66
|
-
}
|
|
67
|
-
endpointCount = extractEndpoints(doc).length;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
93
|
// Build environment variables
|
|
71
94
|
const envVars: Record<string, string> = {};
|
|
72
95
|
if (baseUrl) envVars.base_url = baseUrl;
|
|
96
|
+
// Add path parameter defaults (before user overrides)
|
|
97
|
+
for (const [k, v] of pathParams) {
|
|
98
|
+
if (!(k in envVars)) envVars[k] = v;
|
|
99
|
+
}
|
|
73
100
|
if (options.envVars) {
|
|
74
101
|
Object.assign(envVars, options.envVars);
|
|
75
102
|
}
|
|
@@ -102,6 +129,8 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
|
|
|
102
129
|
openapi_spec: openapiSpec ?? undefined,
|
|
103
130
|
});
|
|
104
131
|
|
|
132
|
+
const pathParamsObj = pathParams.size > 0 ? Object.fromEntries(pathParams) : undefined;
|
|
133
|
+
|
|
105
134
|
return {
|
|
106
135
|
created: true,
|
|
107
136
|
collectionId,
|
|
@@ -109,5 +138,6 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
|
|
|
109
138
|
testPath: normalizedTestPath,
|
|
110
139
|
baseUrl,
|
|
111
140
|
specEndpoints: endpointCount,
|
|
141
|
+
...(pathParamsObj ? { pathParams: pathParamsObj } : {}),
|
|
112
142
|
};
|
|
113
143
|
}
|
package/src/mcp/descriptions.ts
CHANGED
|
@@ -57,7 +57,9 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
57
57
|
"and return a focused test generation guide. For large APIs returns a chunking plan — " +
|
|
58
58
|
"call again with tag parameter for each chunk. Use testsDir param to only generate for uncovered endpoints. " +
|
|
59
59
|
"After generating YAML, use save_test_suites to save files, then run_tests to verify. " +
|
|
60
|
-
"Includes YAML format cheatsheet by default; pass includeFormat: false for subsequent tag chunks to save tokens."
|
|
60
|
+
"Includes YAML format cheatsheet by default; pass includeFormat: false for subsequent tag chunks to save tokens. " +
|
|
61
|
+
"Use mode: 'generate' to auto-generate and save deterministic YAML test files (smoke + CRUD) without LLM. " +
|
|
62
|
+
"Default mode is 'generate'; use mode: 'guide' for the text-based generation guide.",
|
|
61
63
|
|
|
62
64
|
ci_init:
|
|
63
65
|
"Generate a CI/CD workflow file for running API tests automatically on push, PR, and schedule. " +
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
4
|
import {
|
|
4
5
|
readOpenApiSpec,
|
|
@@ -6,10 +7,14 @@ import {
|
|
|
6
7
|
extractSecuritySchemes,
|
|
7
8
|
scanCoveredEndpoints,
|
|
8
9
|
filterUncoveredEndpoints,
|
|
10
|
+
serializeSuite,
|
|
11
|
+
generateSuites,
|
|
12
|
+
findUnresolvedVars,
|
|
9
13
|
} from "../../core/generator/index.ts";
|
|
10
14
|
import { compressEndpointsWithSchemas, buildGenerationGuide } from "../../core/generator/guide-builder.ts";
|
|
11
15
|
import { planChunks, filterByTag } from "../../core/generator/chunker.ts";
|
|
12
16
|
import { TOOL_DESCRIPTIONS } from "../descriptions.js";
|
|
17
|
+
import { validateAndSave } from "./save-test-suite.ts";
|
|
13
18
|
|
|
14
19
|
export function registerGenerateAndSaveTool(server: McpServer) {
|
|
15
20
|
server.registerTool("generate_and_save", {
|
|
@@ -22,8 +27,11 @@ export function registerGenerateAndSaveTool(server: McpServer) {
|
|
|
22
27
|
testsDir: z.optional(z.string()).describe("Path to existing tests directory — filters to uncovered endpoints only"),
|
|
23
28
|
overwrite: z.optional(z.boolean()).describe("Hint for save_test_suites overwrite behavior (default: false)"),
|
|
24
29
|
includeFormat: z.optional(z.boolean()).describe("Include YAML format reference (default: true, set false for subsequent tag chunks)"),
|
|
30
|
+
mode: z.optional(z.enum(["generate", "guide"])).describe(
|
|
31
|
+
"'generate' creates and saves YAML test files deterministically (default), 'guide' returns text for LLM-crafted tests"
|
|
32
|
+
),
|
|
25
33
|
},
|
|
26
|
-
}, async ({ specPath, outputDir, tag, methodFilter, testsDir, overwrite, includeFormat }) => {
|
|
34
|
+
}, async ({ specPath, outputDir, tag, methodFilter, testsDir, overwrite, includeFormat, mode }) => {
|
|
27
35
|
try {
|
|
28
36
|
const doc = await readOpenApiSpec(specPath);
|
|
29
37
|
let endpoints = extractEndpoints(doc);
|
|
@@ -31,6 +39,7 @@ export function registerGenerateAndSaveTool(server: McpServer) {
|
|
|
31
39
|
const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
|
|
32
40
|
const title = (doc as any).info?.title as string | undefined;
|
|
33
41
|
const effectiveOutputDir = outputDir ?? "./tests/";
|
|
42
|
+
const effectiveMode = mode ?? "generate";
|
|
34
43
|
|
|
35
44
|
// Apply method filter
|
|
36
45
|
if (methodFilter && methodFilter.length > 0) {
|
|
@@ -83,8 +92,11 @@ export function registerGenerateAndSaveTool(server: McpServer) {
|
|
|
83
92
|
instruction:
|
|
84
93
|
`This API has ${plan.totalEndpoints} endpoints across ${plan.chunks.length} tags. ` +
|
|
85
94
|
`Call generate_and_save with tag parameter for each chunk sequentially. ` +
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
(effectiveMode === "guide"
|
|
96
|
+
? `Pass includeFormat: false for subsequent chunks to save tokens. `
|
|
97
|
+
: "") +
|
|
98
|
+
`Example: generate_and_save(specPath: '${specPath}', tag: '${plan.chunks[0].tag}'` +
|
|
99
|
+
(effectiveMode === "guide" ? `, mode: 'guide'` : "") + `)`,
|
|
88
100
|
};
|
|
89
101
|
if (coverageInfo) {
|
|
90
102
|
result.coverage = coverageInfo;
|
|
@@ -94,7 +106,57 @@ export function registerGenerateAndSaveTool(server: McpServer) {
|
|
|
94
106
|
};
|
|
95
107
|
}
|
|
96
108
|
|
|
97
|
-
//
|
|
109
|
+
// ── Generate mode: deterministic YAML generation ──
|
|
110
|
+
if (effectiveMode === "generate") {
|
|
111
|
+
const suites = generateSuites({ endpoints, securitySchemes });
|
|
112
|
+
|
|
113
|
+
const files: Array<{
|
|
114
|
+
saved: boolean;
|
|
115
|
+
filePath: string;
|
|
116
|
+
tests: number;
|
|
117
|
+
error?: string;
|
|
118
|
+
}> = [];
|
|
119
|
+
|
|
120
|
+
for (const suite of suites) {
|
|
121
|
+
const yaml = serializeSuite(suite);
|
|
122
|
+
const fileName = (suite.fileStem ?? suite.name) + ".yaml";
|
|
123
|
+
const filePath = join(effectiveOutputDir, fileName);
|
|
124
|
+
|
|
125
|
+
const result = await validateAndSave(filePath, yaml, overwrite ?? false);
|
|
126
|
+
files.push({
|
|
127
|
+
saved: result.saved,
|
|
128
|
+
filePath: result.filePath ?? filePath,
|
|
129
|
+
tests: suite.tests.length,
|
|
130
|
+
...(result.error ? { error: result.error } : {}),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const warnings: string[] = [];
|
|
135
|
+
for (const suite of suites) {
|
|
136
|
+
const unresolved = findUnresolvedVars(suite);
|
|
137
|
+
if (unresolved.length > 0)
|
|
138
|
+
warnings.push(`${suite.fileStem ?? suite.name}.yaml: unresolved [${unresolved.join(", ")}]`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const response: Record<string, unknown> = {
|
|
142
|
+
mode: "generate",
|
|
143
|
+
suitesGenerated: suites.length,
|
|
144
|
+
files,
|
|
145
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
146
|
+
hint: files.some(f => !f.saved)
|
|
147
|
+
? "Some files were not saved (already exist?). Use overwrite: true to replace."
|
|
148
|
+
: "Files saved. Run run_tests to verify. Use mode: 'guide' for LLM-crafted tests with more detail.",
|
|
149
|
+
};
|
|
150
|
+
if (coverageInfo) {
|
|
151
|
+
response.coverage = coverageInfo;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Guide mode: text-based generation guide ──
|
|
98
160
|
const coverageHeader = coverageInfo
|
|
99
161
|
? `## Coverage: ${coverageInfo.covered}/${coverageInfo.total} endpoints covered (${coverageInfo.percentage}%). Generating tests for ${endpoints.length} uncovered endpoints:`
|
|
100
162
|
: undefined;
|
|
@@ -16,6 +16,27 @@ function parseBodySafe(raw: string | null | undefined): unknown {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const USEFUL_HEADERS = new Set([
|
|
20
|
+
"content-type", "content-length", "location", "retry-after",
|
|
21
|
+
"www-authenticate", "allow",
|
|
22
|
+
]);
|
|
23
|
+
const USEFUL_PREFIXES = ["x-", "ratelimit"];
|
|
24
|
+
|
|
25
|
+
function filterHeaders(raw: string | null | undefined): Record<string, string> | undefined {
|
|
26
|
+
if (!raw) return undefined;
|
|
27
|
+
try {
|
|
28
|
+
const h = JSON.parse(raw) as Record<string, string>;
|
|
29
|
+
const out: Record<string, string> = {};
|
|
30
|
+
for (const [k, v] of Object.entries(h)) {
|
|
31
|
+
const l = k.toLowerCase();
|
|
32
|
+
if (USEFUL_HEADERS.has(l) || USEFUL_PREFIXES.some(p => l.startsWith(p))) {
|
|
33
|
+
out[k] = v;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
37
|
+
} catch { return undefined; }
|
|
38
|
+
}
|
|
39
|
+
|
|
19
40
|
export function registerQueryDbTool(server: McpServer, dbPath?: string) {
|
|
20
41
|
server.registerTool("query_db", {
|
|
21
42
|
description: TOOL_DESCRIPTIONS.query_db,
|
|
@@ -137,9 +158,7 @@ export function registerQueryDbTool(server: McpServer, dbPath?: string) {
|
|
|
137
158
|
...(hint ? { hint } : {}),
|
|
138
159
|
...(sHint ? { schema_hint: sHint } : {}),
|
|
139
160
|
response_body: parseBodySafe(r.response_body),
|
|
140
|
-
response_headers: r.response_headers
|
|
141
|
-
? JSON.parse(r.response_headers)
|
|
142
|
-
: undefined,
|
|
161
|
+
response_headers: filterHeaders(r.response_headers),
|
|
143
162
|
assertions: r.assertions,
|
|
144
163
|
duration_ms: r.duration_ms,
|
|
145
164
|
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { executeRun } from "../../core/runner/execute-run.ts";
|
|
4
|
+
import { getDb } from "../../db/schema.ts";
|
|
5
|
+
import { getResultsByRunId } from "../../db/queries.ts";
|
|
4
6
|
import { TOOL_DESCRIPTIONS } from "../descriptions.js";
|
|
5
7
|
|
|
6
8
|
export function registerRunTestsTool(server: McpServer, dbPath?: string) {
|
|
@@ -13,8 +15,24 @@ export function registerRunTestsTool(server: McpServer, dbPath?: string) {
|
|
|
13
15
|
tag: z.optional(z.array(z.string())).describe("Filter suites by tag (OR logic)"),
|
|
14
16
|
envVars: z.optional(z.record(z.string(), z.string())).describe("Environment variables to inject (override env file, e.g. {\"TOKEN\": \"xxx\"})"),
|
|
15
17
|
dryRun: z.optional(z.boolean()).describe("Show requests without sending them (always exits 0)"),
|
|
18
|
+
rerunFrom: z.optional(z.number().int()).describe("Re-run only tests that failed/errored in this run ID"),
|
|
16
19
|
},
|
|
17
|
-
}, async ({ testPath, envName, safe, tag, envVars, dryRun }) => {
|
|
20
|
+
}, async ({ testPath, envName, safe, tag, envVars, dryRun, rerunFrom }) => {
|
|
21
|
+
// Build filter from previous failed run
|
|
22
|
+
let rerunFilter: Set<string> | undefined;
|
|
23
|
+
if (rerunFrom != null) {
|
|
24
|
+
getDb(dbPath);
|
|
25
|
+
const prevResults = getResultsByRunId(rerunFrom);
|
|
26
|
+
const failed = prevResults.filter(r => r.status === "fail" || r.status === "error");
|
|
27
|
+
if (failed.length === 0) {
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: "text" as const, text: JSON.stringify({ error: `Run ${rerunFrom} has no failures to rerun` }, null, 2) }],
|
|
30
|
+
isError: true,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
rerunFilter = new Set(failed.map(r => `${r.suite_name}::${r.test_name}`));
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
const { runId, results } = await executeRun({
|
|
19
37
|
testPath,
|
|
20
38
|
envName,
|
|
@@ -24,6 +42,7 @@ export function registerRunTestsTool(server: McpServer, dbPath?: string) {
|
|
|
24
42
|
tag,
|
|
25
43
|
envVars,
|
|
26
44
|
dryRun,
|
|
45
|
+
rerunFilter,
|
|
27
46
|
});
|
|
28
47
|
|
|
29
48
|
const total = results.reduce((s, r) => s + r.total, 0);
|
|
@@ -55,9 +74,6 @@ export function registerRunTestsTool(server: McpServer, dbPath?: string) {
|
|
|
55
74
|
);
|
|
56
75
|
}
|
|
57
76
|
}
|
|
58
|
-
hints.push("Use manage_server(action: 'start') to launch the Web UI and view results visually in a browser at http://localhost:8080");
|
|
59
|
-
hints.push("Ask the user if they want to set up CI/CD to run these tests automatically on push. If yes, use ci_init to generate a workflow and help them push to GitHub/GitLab.");
|
|
60
|
-
|
|
61
77
|
const summary = {
|
|
62
78
|
runId,
|
|
63
79
|
total,
|
|
@@ -22,7 +22,7 @@ export function registerSetupApiTool(server: McpServer, dbPath?: string) {
|
|
|
22
22
|
server.registerTool("setup_api", {
|
|
23
23
|
description: TOOL_DESCRIPTIONS.setup_api,
|
|
24
24
|
inputSchema: {
|
|
25
|
-
name: z.string().describe("API name (
|
|
25
|
+
name: z.optional(z.string()).describe("API name (auto-detected from spec title if omitted)"),
|
|
26
26
|
specPath: z.optional(z.string()).describe("Path or URL to OpenAPI spec"),
|
|
27
27
|
dir: z.optional(z.string()).describe("Base directory (default: ./apis/<name>/)"),
|
|
28
28
|
envVars: z.optional(z.string()).describe("Environment variables as JSON string (e.g. '{\"base_url\": \"...\", \"token\": \"...\"}')"),
|