@kirrosh/zond 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -112
- package/README.md +3 -10
- package/package.json +2 -3
- package/src/cli/commands/export.ts +144 -0
- package/src/cli/commands/generate.ts +31 -0
- package/src/cli/commands/run.ts +22 -5
- package/src/cli/commands/sync.ts +240 -0
- package/src/cli/index.ts +54 -10
- package/src/core/diagnostics/db-analysis.ts +79 -7
- package/src/core/diagnostics/failure-hints.ts +39 -0
- package/src/core/exporter/postman.ts +963 -0
- package/src/core/generator/data-factory.ts +38 -3
- package/src/core/generator/index.ts +1 -1
- package/src/core/generator/openapi-reader.ts +6 -0
- package/src/core/generator/serializer.ts +17 -2
- package/src/core/generator/suite-generator.ts +163 -14
- package/src/core/generator/types.ts +1 -0
- package/src/core/meta/meta-store.ts +78 -0
- package/src/core/meta/types.ts +21 -0
- package/src/core/parser/schema.ts +12 -2
- package/src/core/parser/types.ts +12 -1
- package/src/core/parser/variables.ts +3 -0
- package/src/core/parser/yaml-parser.ts +2 -1
- package/src/core/runner/assertions.ts +44 -20
- package/src/core/runner/execute-run.ts +31 -8
- package/src/core/runner/executor.ts +34 -8
- package/src/core/runner/http-client.ts +1 -1
- package/src/core/runner/types.ts +1 -0
- package/src/core/sync/spec-differ.ts +38 -0
- package/src/cli/commands/mcp.ts +0 -16
- package/src/mcp/descriptions.ts +0 -47
- package/src/mcp/server.ts +0 -38
- package/src/mcp/tools/ci-init.ts +0 -54
- package/src/mcp/tools/coverage-analysis.ts +0 -141
- package/src/mcp/tools/describe-endpoint.ts +0 -27
- package/src/mcp/tools/manage-server.ts +0 -86
- package/src/mcp/tools/query-db.ts +0 -84
- package/src/mcp/tools/run-tests.ts +0 -116
- package/src/mcp/tools/send-request.ts +0 -51
- package/src/mcp/tools/setup-api.ts +0 -88
|
@@ -33,6 +33,9 @@ export function generateFromSchema(
|
|
|
33
33
|
return schema.enum[0];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// uuid format overrides type (e.g. integer fields with format: uuid)
|
|
37
|
+
if (schema.format === "uuid") return "{{$uuid}}";
|
|
38
|
+
|
|
36
39
|
switch (schema.type) {
|
|
37
40
|
case "string":
|
|
38
41
|
return guessStringPlaceholder(schema, propertyName);
|
|
@@ -81,11 +84,36 @@ export function generateFromSchema(
|
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Generate a multipart body object from an OpenAPI multipart/form-data schema.
|
|
89
|
+
* Binary fields (format: binary/byte) become file upload objects; all others become strings.
|
|
90
|
+
*/
|
|
91
|
+
export function generateMultipartFromSchema(
|
|
92
|
+
schema: OpenAPIV3.SchemaObject,
|
|
93
|
+
): Record<string, unknown> {
|
|
94
|
+
const result: Record<string, unknown> = {};
|
|
95
|
+
|
|
96
|
+
if (!schema.properties) return result;
|
|
97
|
+
|
|
98
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
99
|
+
const s = propSchema as OpenAPIV3.SchemaObject;
|
|
100
|
+
if (s.format === "binary" || s.format === "byte") {
|
|
101
|
+
result[key] = { file: `./fixtures/${key}.bin`, content_type: "application/octet-stream" };
|
|
102
|
+
} else {
|
|
103
|
+
const val = generateFromSchema(s, key);
|
|
104
|
+
result[key] = val;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
84
111
|
function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string): string {
|
|
85
112
|
// Format-based
|
|
86
113
|
if (schema.format === "email") return "{{$randomEmail}}";
|
|
87
114
|
if (schema.format === "uuid") return "{{$uuid}}";
|
|
88
|
-
if (schema.format === "date-time"
|
|
115
|
+
if (schema.format === "date-time") return "2025-01-01T00:00:00Z";
|
|
116
|
+
if (schema.format === "date") return "2025-01-01";
|
|
89
117
|
if (schema.format === "uri" || schema.format === "url") return "https://example.com/test";
|
|
90
118
|
if (schema.format === "hostname") return "example.com";
|
|
91
119
|
if (schema.format === "ipv4") return "192.168.1.1";
|
|
@@ -119,8 +147,15 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
|
|
|
119
147
|
}
|
|
120
148
|
|
|
121
149
|
function guessIntPlaceholder(name?: string, schema?: OpenAPIV3.SchemaObject): number | string {
|
|
122
|
-
|
|
123
|
-
|
|
150
|
+
const min = schema?.minimum;
|
|
151
|
+
const max = schema?.maximum;
|
|
152
|
+
if (max !== undefined) {
|
|
153
|
+
// Use a safe concrete value within the declared range
|
|
154
|
+
const lo = min !== undefined && min > 0 ? min : 1;
|
|
155
|
+
return Math.min(lo, max);
|
|
156
|
+
}
|
|
157
|
+
if (min !== undefined && min > 0) {
|
|
158
|
+
return min;
|
|
124
159
|
}
|
|
125
160
|
return "{{$randomInt}}";
|
|
126
161
|
}
|
|
@@ -9,4 +9,4 @@ export { compressEndpointsWithSchemas, buildGenerationGuide } from "./guide-buil
|
|
|
9
9
|
export type { GuideOptions } from "./guide-builder.ts";
|
|
10
10
|
export type { EndpointWarning, WarningCode } from "./endpoint-warnings.ts";
|
|
11
11
|
export type { EndpointInfo, ResponseInfo, GenerateOptions, SecuritySchemeInfo, CrudGroup } from "./types.ts";
|
|
12
|
-
export { generateSuites, generateStep, detectCrudGroups, generateCrudSuite, findUnresolvedVars } from "./suite-generator.ts";
|
|
12
|
+
export { generateSuites, generateStep, detectCrudGroups, generateCrudSuite, generateSanitySuite, findUnresolvedVars } from "./suite-generator.ts";
|
|
@@ -124,6 +124,11 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
|
|
|
124
124
|
const securityReqs = operation.security ?? doc.security ?? [];
|
|
125
125
|
const security = securityReqs.flatMap((req) => Object.keys(req));
|
|
126
126
|
|
|
127
|
+
// ETag optimistic locking: detect if endpoint requires If-Match header
|
|
128
|
+
const requiresEtag =
|
|
129
|
+
responses.some(r => r.statusCode === 412) ||
|
|
130
|
+
parameters.some(p => p.name.toLowerCase() === "if-match" && p.in === "header");
|
|
131
|
+
|
|
127
132
|
endpoints.push({
|
|
128
133
|
path,
|
|
129
134
|
method: method.toUpperCase(),
|
|
@@ -137,6 +142,7 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
|
|
|
137
142
|
responses,
|
|
138
143
|
security,
|
|
139
144
|
deprecated: operation.deprecated ?? false,
|
|
145
|
+
requiresEtag,
|
|
140
146
|
});
|
|
141
147
|
}
|
|
142
148
|
}
|
|
@@ -29,11 +29,13 @@ export interface RawStep {
|
|
|
29
29
|
expect: {
|
|
30
30
|
status?: number;
|
|
31
31
|
body?: Record<string, Record<string, string>>;
|
|
32
|
+
headers?: Record<string, unknown>;
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export interface RawSuite {
|
|
36
37
|
name: string;
|
|
38
|
+
setup?: boolean;
|
|
37
39
|
tags?: string[];
|
|
38
40
|
folder?: string;
|
|
39
41
|
fileStem?: string;
|
|
@@ -49,6 +51,9 @@ export interface RawSuite {
|
|
|
49
51
|
export function serializeSuite(suite: RawSuite): string {
|
|
50
52
|
const lines: string[] = [];
|
|
51
53
|
lines.push(`name: ${yamlScalar(suite.name)}`);
|
|
54
|
+
if (suite.setup) {
|
|
55
|
+
lines.push("setup: true");
|
|
56
|
+
}
|
|
52
57
|
if (suite.tags && suite.tags.length > 0) {
|
|
53
58
|
lines.push(`tags: [${suite.tags.join(", ")}]`);
|
|
54
59
|
}
|
|
@@ -167,10 +172,20 @@ function serializeValue(value: unknown, indent: number, lines: string[]): void {
|
|
|
167
172
|
const entries = Object.entries(item as Record<string, unknown>);
|
|
168
173
|
if (entries.length > 0) {
|
|
169
174
|
const [firstKey, firstVal] = entries[0]!;
|
|
170
|
-
|
|
175
|
+
if (typeof firstVal === "object" && firstVal !== null) {
|
|
176
|
+
lines.push(`${prefix}- ${firstKey}:`);
|
|
177
|
+
serializeValue(firstVal, indent + 1, lines);
|
|
178
|
+
} else {
|
|
179
|
+
lines.push(`${prefix}- ${firstKey}: ${formatInlineValue(firstVal)}`);
|
|
180
|
+
}
|
|
171
181
|
for (let i = 1; i < entries.length; i++) {
|
|
172
182
|
const [k, v] = entries[i]!;
|
|
173
|
-
|
|
183
|
+
if (typeof v === "object" && v !== null) {
|
|
184
|
+
lines.push(`${prefix} ${k}:`);
|
|
185
|
+
serializeValue(v, indent + 1, lines);
|
|
186
|
+
} else {
|
|
187
|
+
lines.push(`${prefix} ${k}: ${formatInlineValue(v)}`);
|
|
188
|
+
}
|
|
174
189
|
}
|
|
175
190
|
} else {
|
|
176
191
|
lines.push(`${prefix}- {}`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { OpenAPIV3 } from "openapi-types";
|
|
2
2
|
import type { EndpointInfo, SecuritySchemeInfo, CrudGroup } from "./types.ts";
|
|
3
3
|
import type { RawSuite, RawStep } from "./serializer.ts";
|
|
4
|
-
import { generateFromSchema } from "./data-factory.ts";
|
|
4
|
+
import { generateFromSchema, generateMultipartFromSchema } from "./data-factory.ts";
|
|
5
5
|
import { groupEndpointsByTag } from "./chunker.ts";
|
|
6
6
|
|
|
7
7
|
// ──────────────────────────────────────────────
|
|
@@ -13,6 +13,22 @@ function convertPath(path: string): string {
|
|
|
13
13
|
return path.replace(/\{([^}]+)\}/g, "{{$1}}");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Convert path params to seed values for smoke suites (no capture context).
|
|
18
|
+
* Uses the parameter's example/default from the spec, or falls back to "1" for
|
|
19
|
+
* id-like params. Non-id string params keep the {{placeholder}} form.
|
|
20
|
+
*/
|
|
21
|
+
function convertPathWithSeeds(path: string, ep: EndpointInfo): string {
|
|
22
|
+
return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
23
|
+
const param = ep.parameters.find(p => p.name === name && p.in === "path");
|
|
24
|
+
const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
25
|
+
const example = (param as any)?.example ?? schema?.example ?? schema?.default;
|
|
26
|
+
if (example !== undefined) return String(example);
|
|
27
|
+
if (schema?.type === "integer" || /^id$|_id$|Id$/i.test(name)) return "1";
|
|
28
|
+
return `{{${name}}}`;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
function slugify(s: string): string {
|
|
17
33
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
18
34
|
}
|
|
@@ -21,6 +37,19 @@ function escapeRegex(s: string): string {
|
|
|
21
37
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
22
38
|
}
|
|
23
39
|
|
|
40
|
+
const HEALTHCHECK_PATH_RE = /\/(health|healthz|ping|status|ready|readiness|liveness|alive)\b/i;
|
|
41
|
+
const RESET_PATH_RE = /\/(reset|flush|purge|truncate|wipe|clear-data|factory-reset)\b/i;
|
|
42
|
+
const LOGOUT_PATH_RE = /\/(logout|signout|invalidate|revoke)\b/i;
|
|
43
|
+
const SHORT_PATH_RE = /^\/[a-z0-9-]*$/i; // matches /, /api, /v1, etc.
|
|
44
|
+
|
|
45
|
+
function selectHealthcheckEndpoint(gets: EndpointInfo[]): EndpointInfo | undefined {
|
|
46
|
+
return (
|
|
47
|
+
gets.find(ep => HEALTHCHECK_PATH_RE.test(ep.path) && !ep.parameters.some(p => p.in === "path")) ??
|
|
48
|
+
gets.find(ep => SHORT_PATH_RE.test(ep.path) && !ep.parameters.some(p => p.in === "path") && ep.security.length === 0) ??
|
|
49
|
+
gets.find(ep => !ep.parameters.some(p => p.in === "path") && ep.security.length === 0)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
24
53
|
function getExpectedStatus(ep: EndpointInfo): number {
|
|
25
54
|
const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
|
|
26
55
|
if (success) return success.statusCode;
|
|
@@ -161,7 +190,11 @@ export function generateStep(
|
|
|
161
190
|
}
|
|
162
191
|
|
|
163
192
|
if (["POST", "PUT", "PATCH"].includes(method) && ep.requestBodySchema) {
|
|
164
|
-
|
|
193
|
+
if (ep.requestBodyContentType === "multipart/form-data") {
|
|
194
|
+
step.multipart = generateMultipartFromSchema(ep.requestBodySchema);
|
|
195
|
+
} else {
|
|
196
|
+
step.json = generateFromSchema(ep.requestBodySchema);
|
|
197
|
+
}
|
|
165
198
|
}
|
|
166
199
|
|
|
167
200
|
const query = getRequiredQueryParams(ep);
|
|
@@ -265,13 +298,31 @@ export function generateCrudSuite(
|
|
|
265
298
|
// 3. Update
|
|
266
299
|
if (group.update) {
|
|
267
300
|
const method = group.update.method.toUpperCase();
|
|
301
|
+
const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
|
|
302
|
+
const etagVar = `${group.resource.replace(/s$/, "")}_etag`;
|
|
303
|
+
|
|
304
|
+
// If endpoint requires ETag (optimistic locking), capture it from a GET step first
|
|
305
|
+
if (group.update.requiresEtag && group.read) {
|
|
306
|
+
tests.push({
|
|
307
|
+
name: `Get ETag before update ${group.resource.replace(/s$/, "")}`,
|
|
308
|
+
GET: itemPath,
|
|
309
|
+
expect: {
|
|
310
|
+
status: getExpectedStatus(group.read),
|
|
311
|
+
headers: { ETag: { capture: etagVar } },
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
268
316
|
const step: RawStep = {
|
|
269
317
|
name: group.update.operationId ?? `Update ${group.resource.replace(/s$/, "")}`,
|
|
270
|
-
[method]:
|
|
318
|
+
[method]: itemPath,
|
|
271
319
|
expect: {
|
|
272
320
|
status: getExpectedStatus(group.update),
|
|
273
321
|
},
|
|
274
322
|
};
|
|
323
|
+
if (group.update.requiresEtag) {
|
|
324
|
+
step.headers = { "If-Match": `"{{${etagVar}}}"` };
|
|
325
|
+
}
|
|
275
326
|
if (group.update.requestBodySchema) {
|
|
276
327
|
step.json = generateFromSchema(group.update.requestBodySchema);
|
|
277
328
|
}
|
|
@@ -280,13 +331,32 @@ export function generateCrudSuite(
|
|
|
280
331
|
|
|
281
332
|
// 4. Delete
|
|
282
333
|
if (group.delete) {
|
|
334
|
+
const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
|
|
335
|
+
const etagVar = `${group.resource.replace(/s$/, "")}_etag`;
|
|
336
|
+
|
|
337
|
+
// If delete requires ETag and update didn't already capture it, add a GET step
|
|
338
|
+
const updateAlreadyCapturedEtag = group.update?.requiresEtag;
|
|
339
|
+
if (group.delete.requiresEtag && group.read && !updateAlreadyCapturedEtag) {
|
|
340
|
+
tests.push({
|
|
341
|
+
name: `Get ETag before delete ${group.resource.replace(/s$/, "")}`,
|
|
342
|
+
GET: itemPath,
|
|
343
|
+
expect: {
|
|
344
|
+
status: getExpectedStatus(group.read),
|
|
345
|
+
headers: { ETag: { capture: etagVar } },
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
283
350
|
const step: RawStep = {
|
|
284
351
|
name: group.delete.operationId ?? `Delete ${group.resource.replace(/s$/, "")}`,
|
|
285
|
-
DELETE:
|
|
352
|
+
DELETE: itemPath,
|
|
286
353
|
expect: {
|
|
287
354
|
status: getExpectedStatus(group.delete),
|
|
288
355
|
},
|
|
289
356
|
};
|
|
357
|
+
if (group.delete.requiresEtag) {
|
|
358
|
+
step.headers = { "If-Match": `"{{${etagVar}}}"` };
|
|
359
|
+
}
|
|
290
360
|
tests.push(step);
|
|
291
361
|
|
|
292
362
|
// 5. Verify deleted
|
|
@@ -374,12 +444,14 @@ export function generateAuthSuite(
|
|
|
374
444
|
return generateConsistentAuthSuite(registerEp, loginEp, authEndpoints, securitySchemes);
|
|
375
445
|
}
|
|
376
446
|
|
|
377
|
-
// Fallback: plain auth suite
|
|
378
|
-
const
|
|
379
|
-
const
|
|
447
|
+
// Fallback: plain auth suite — exclude logout/revoke endpoints from setup suite
|
|
448
|
+
const nonLogoutEndpoints = authEndpoints.filter(ep => !LOGOUT_PATH_RE.test(ep.path));
|
|
449
|
+
const tests = nonLogoutEndpoints.map(ep => generateStep(ep, securitySchemes));
|
|
450
|
+
const headers = getSuiteHeaders(nonLogoutEndpoints, securitySchemes);
|
|
380
451
|
|
|
381
452
|
const suite: RawSuite = {
|
|
382
453
|
name: "auth",
|
|
454
|
+
setup: true,
|
|
383
455
|
tags: ["auth"],
|
|
384
456
|
fileStem: "auth",
|
|
385
457
|
base_url: "{{base_url}}",
|
|
@@ -452,14 +524,18 @@ function generateConsistentAuthSuite(
|
|
|
452
524
|
}
|
|
453
525
|
tests.push(loginStep);
|
|
454
526
|
|
|
455
|
-
// 3. Any remaining auth endpoints (not register/login)
|
|
456
|
-
|
|
527
|
+
// 3. Any remaining auth endpoints (not register/login, not logout)
|
|
528
|
+
// Logout/revoke endpoints must NOT be in a setup suite — they invalidate the token
|
|
529
|
+
const others = allAuthEndpoints.filter(ep =>
|
|
530
|
+
ep !== registerEp && ep !== loginEp && !LOGOUT_PATH_RE.test(ep.path)
|
|
531
|
+
);
|
|
457
532
|
for (const ep of others) {
|
|
458
533
|
tests.push(generateStep(ep, securitySchemes));
|
|
459
534
|
}
|
|
460
535
|
|
|
461
536
|
return {
|
|
462
537
|
name: "auth",
|
|
538
|
+
setup: true,
|
|
463
539
|
tags: ["auth"],
|
|
464
540
|
fileStem: "auth",
|
|
465
541
|
base_url: "{{base_url}}",
|
|
@@ -467,6 +543,40 @@ function generateConsistentAuthSuite(
|
|
|
467
543
|
};
|
|
468
544
|
}
|
|
469
545
|
|
|
546
|
+
/** Generate 1-2 minimal tests for quick connectivity and auth validation */
|
|
547
|
+
export function generateSanitySuite(opts: {
|
|
548
|
+
authEndpoints: EndpointInfo[];
|
|
549
|
+
nonAuthGetEndpoints: EndpointInfo[];
|
|
550
|
+
securitySchemes: SecuritySchemeInfo[];
|
|
551
|
+
}): RawSuite | null {
|
|
552
|
+
const { authEndpoints, nonAuthGetEndpoints, securitySchemes } = opts;
|
|
553
|
+
const tests: RawStep[] = [];
|
|
554
|
+
|
|
555
|
+
// Priority 1: auth login/token endpoint
|
|
556
|
+
if (authEndpoints.length > 0) {
|
|
557
|
+
const loginEp =
|
|
558
|
+
authEndpoints.find(ep => /\/(login|signin|token)\b/i.test(ep.path) && ep.method.toUpperCase() === "POST") ??
|
|
559
|
+
authEndpoints[0]!;
|
|
560
|
+
tests.push(generateStep(loginEp, securitySchemes));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Priority 2: healthcheck or first simple GET with no path params
|
|
564
|
+
const healthEp = selectHealthcheckEndpoint(nonAuthGetEndpoints);
|
|
565
|
+
if (healthEp) {
|
|
566
|
+
tests.push(generateStep(healthEp, securitySchemes));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (tests.length === 0) return null;
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
name: "sanity",
|
|
573
|
+
tags: ["sanity"],
|
|
574
|
+
fileStem: "sanity",
|
|
575
|
+
base_url: "{{base_url}}",
|
|
576
|
+
tests: tests.slice(0, 2),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
470
580
|
/** Main entry point: generate all suites from endpoints */
|
|
471
581
|
export function generateSuites(opts: {
|
|
472
582
|
endpoints: EndpointInfo[];
|
|
@@ -505,10 +615,16 @@ export function generateSuites(opts: {
|
|
|
505
615
|
for (const [tag, tagEndpoints] of byTag) {
|
|
506
616
|
const tagSlug = slugify(tag) || "api";
|
|
507
617
|
|
|
508
|
-
// GET endpoints → smoke suite
|
|
618
|
+
// GET endpoints → smoke suite (use seed values for path params — no capture context)
|
|
509
619
|
const getEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() === "GET");
|
|
510
620
|
if (getEndpoints.length > 0) {
|
|
511
|
-
const tests = getEndpoints.map(ep =>
|
|
621
|
+
const tests = getEndpoints.map(ep => {
|
|
622
|
+
const step = generateStep(ep, securitySchemes);
|
|
623
|
+
// Replace path param placeholders with seed values so the suite runs out of the box
|
|
624
|
+
const seededPath = convertPathWithSeeds(ep.path, ep);
|
|
625
|
+
(step as any)[ep.method.toUpperCase()] = seededPath;
|
|
626
|
+
return step;
|
|
627
|
+
});
|
|
512
628
|
const headers = getSuiteHeaders(getEndpoints, securitySchemes);
|
|
513
629
|
|
|
514
630
|
const suite: RawSuite = {
|
|
@@ -531,8 +647,37 @@ export function generateSuites(opts: {
|
|
|
531
647
|
suites.push(suite);
|
|
532
648
|
}
|
|
533
649
|
|
|
534
|
-
// Non-GET endpoints
|
|
535
|
-
const
|
|
650
|
+
// Non-GET endpoints: split reset/system endpoints out of smoke-unsafe
|
|
651
|
+
const nonGetEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() !== "GET");
|
|
652
|
+
const resetEndpoints = nonGetEndpoints.filter(ep => RESET_PATH_RE.test(ep.path));
|
|
653
|
+
const unsafeEndpoints = nonGetEndpoints.filter(ep => !RESET_PATH_RE.test(ep.path));
|
|
654
|
+
|
|
655
|
+
// Reset/system endpoints → [system, reset] suite (never run as part of smoke)
|
|
656
|
+
if (resetEndpoints.length > 0) {
|
|
657
|
+
const tests = resetEndpoints.map(ep => generateStep(ep, securitySchemes));
|
|
658
|
+
const headers = getSuiteHeaders(resetEndpoints, securitySchemes);
|
|
659
|
+
|
|
660
|
+
const suite: RawSuite = {
|
|
661
|
+
name: `${tagSlug}-system`,
|
|
662
|
+
tags: ["system", "reset"],
|
|
663
|
+
fileStem: `system-${tagSlug}`,
|
|
664
|
+
base_url: "{{base_url}}",
|
|
665
|
+
tests,
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
if (headers) {
|
|
669
|
+
suite.headers = headers;
|
|
670
|
+
for (const t of tests) {
|
|
671
|
+
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
672
|
+
delete (t as any).headers;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
suites.push(suite);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Remaining non-GET endpoints → smoke-unsafe suite
|
|
536
681
|
if (unsafeEndpoints.length > 0) {
|
|
537
682
|
const tests = unsafeEndpoints.map(ep => generateStep(ep, securitySchemes));
|
|
538
683
|
const headers = getSuiteHeaders(unsafeEndpoints, securitySchemes);
|
|
@@ -569,5 +714,9 @@ export function generateSuites(opts: {
|
|
|
569
714
|
suites.push(suite);
|
|
570
715
|
}
|
|
571
716
|
|
|
572
|
-
|
|
717
|
+
// 5. Sanity suite (prepend — 1-2 tests for quick connectivity/auth check)
|
|
718
|
+
const nonAuthGetEndpoints = nonAuth.filter(ep => ep.method.toUpperCase() === "GET");
|
|
719
|
+
const sanitySuite = generateSanitySuite({ authEndpoints, nonAuthGetEndpoints, securitySchemes });
|
|
720
|
+
|
|
721
|
+
return sanitySuite ? [sanitySuite, ...suites] : suites;
|
|
573
722
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import type { ZondMeta, FileMeta } from "./types.ts";
|
|
4
|
+
import type { RawSuite } from "../generator/serializer.ts";
|
|
5
|
+
import { normalizePath } from "../generator/coverage-scanner.ts";
|
|
6
|
+
|
|
7
|
+
const META_FILENAME = ".zond-meta.json";
|
|
8
|
+
|
|
9
|
+
export async function readMeta(testsDir: string): Promise<ZondMeta | null> {
|
|
10
|
+
const metaPath = join(testsDir, META_FILENAME);
|
|
11
|
+
const file = Bun.file(metaPath);
|
|
12
|
+
if (!(await file.exists())) return null;
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(await file.text()) as ZondMeta;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function writeMeta(testsDir: string, meta: ZondMeta): Promise<void> {
|
|
21
|
+
const metaPath = join(testsDir, META_FILENAME);
|
|
22
|
+
await Bun.write(metaPath, JSON.stringify(meta, null, 2) + "\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hashSpec(specContent: string): string {
|
|
26
|
+
return createHash("sha256").update(specContent).digest("hex");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Derive suite type from tags array or filename.
|
|
31
|
+
*/
|
|
32
|
+
function detectSuiteType(suite: RawSuite): FileMeta["suiteType"] {
|
|
33
|
+
const tags = suite.tags ?? [];
|
|
34
|
+
if (tags.includes("auth")) return "auth";
|
|
35
|
+
if (tags.includes("sanity")) return "sanity";
|
|
36
|
+
if (tags.includes("crud")) return "crud";
|
|
37
|
+
if (tags.includes("unsafe")) return "unsafe";
|
|
38
|
+
return "smoke";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract first tag from fileStem or suite folder for grouping.
|
|
43
|
+
* e.g. fileStem "smoke-users" → tag "users"
|
|
44
|
+
*/
|
|
45
|
+
function detectTag(suite: RawSuite): string | undefined {
|
|
46
|
+
const stem = suite.fileStem ?? suite.name;
|
|
47
|
+
const match = stem.match(/^(?:smoke|crud|auth|sanity|unsafe)-(.+?)(?:-unsafe)?$/);
|
|
48
|
+
return match?.[1];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build normalized endpoint keys from a raw suite's test steps.
|
|
53
|
+
* e.g. "GET /users/{*}", "POST /users"
|
|
54
|
+
*/
|
|
55
|
+
function extractEndpointKeys(suite: RawSuite): string[] {
|
|
56
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
57
|
+
const keys: string[] = [];
|
|
58
|
+
for (const step of suite.tests) {
|
|
59
|
+
for (const method of HTTP_METHODS) {
|
|
60
|
+
const path = step[method] as string | undefined;
|
|
61
|
+
if (path) {
|
|
62
|
+
keys.push(`${method} ${normalizePath(path)}`);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return [...new Set(keys)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildFileMeta(suite: RawSuite, zondVersion: string): FileMeta {
|
|
71
|
+
return {
|
|
72
|
+
generatedAt: new Date().toISOString(),
|
|
73
|
+
zondVersion,
|
|
74
|
+
suiteType: detectSuiteType(suite),
|
|
75
|
+
tag: detectTag(suite),
|
|
76
|
+
endpoints: extractEndpointKeys(suite),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface FileMeta {
|
|
2
|
+
generatedAt: string;
|
|
3
|
+
zondVersion: string;
|
|
4
|
+
suiteType: "smoke" | "crud" | "auth" | "sanity" | "unsafe";
|
|
5
|
+
tag?: string;
|
|
6
|
+
/** Normalized endpoint keys, e.g. ["GET /users", "POST /users/{*}"] */
|
|
7
|
+
endpoints: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ZondMeta {
|
|
11
|
+
/** Version of zond that last wrote this metadata */
|
|
12
|
+
zondVersion: string;
|
|
13
|
+
/** ISO timestamp of last sync/generate */
|
|
14
|
+
lastSyncedAt: string;
|
|
15
|
+
/** Spec URL or file path used for last generation */
|
|
16
|
+
specUrl: string;
|
|
17
|
+
/** SHA-256 hex of spec content at time of last generation */
|
|
18
|
+
specHash: string;
|
|
19
|
+
/** Per-file metadata, keyed by filename (e.g. "smoke-users.yaml") */
|
|
20
|
+
files: Record<string, FileMeta>;
|
|
21
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import type { TestSuite, TestStep, AssertionRule, TestStepExpect, SuiteConfig, RetryUntil, ForEach } from "./types.ts";
|
|
2
|
+
import type { TestSuite, TestStep, AssertionRule, TestStepExpect, SuiteConfig, RetryUntil, ForEach, MultipartField } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
|
5
5
|
|
|
@@ -134,7 +134,7 @@ const TestStepExpectSchema: z.ZodType<TestStepExpect> = z.preprocess(
|
|
|
134
134
|
z.object({
|
|
135
135
|
status: z.union([z.number().int(), z.array(z.number().int())]).optional(),
|
|
136
136
|
body: z.record(z.string(), AssertionRuleSchema).optional(),
|
|
137
|
-
headers: z.record(z.string(), z.string()).optional(),
|
|
137
|
+
headers: z.record(z.string(), z.union([z.string(), AssertionRuleSchema])).optional(),
|
|
138
138
|
duration: z.number().optional(),
|
|
139
139
|
}),
|
|
140
140
|
) as z.ZodType<TestStepExpect>;
|
|
@@ -150,6 +150,14 @@ const ForEachSchema: z.ZodType<ForEach> = z.object({
|
|
|
150
150
|
in: z.unknown(),
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
+
const MultipartFileFieldSchema = z.object({
|
|
154
|
+
file: z.string(),
|
|
155
|
+
filename: z.string().optional(),
|
|
156
|
+
content_type: z.string().optional(),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const MultipartFieldSchema: z.ZodType<MultipartField> = z.union([z.string(), MultipartFileFieldSchema]);
|
|
160
|
+
|
|
153
161
|
const TestStepSchema: z.ZodType<TestStep> = z.preprocess(
|
|
154
162
|
(raw) => {
|
|
155
163
|
const obj = extractMethodAndPath(raw);
|
|
@@ -169,6 +177,7 @@ const TestStepSchema: z.ZodType<TestStep> = z.preprocess(
|
|
|
169
177
|
headers: z.record(z.string(), z.string()).optional(),
|
|
170
178
|
json: z.unknown().optional(),
|
|
171
179
|
form: z.record(z.string(), z.string()).optional(),
|
|
180
|
+
multipart: z.record(z.string(), MultipartFieldSchema).optional(),
|
|
172
181
|
query: z.record(z.string(), z.string()).optional(),
|
|
173
182
|
expect: TestStepExpectSchema,
|
|
174
183
|
skip_if: z.string().optional(),
|
|
@@ -207,6 +216,7 @@ const TestSuiteSchema = z.preprocess(
|
|
|
207
216
|
z.object({
|
|
208
217
|
name: z.string(),
|
|
209
218
|
description: z.string().optional(),
|
|
219
|
+
setup: z.boolean().optional(),
|
|
210
220
|
tags: z.array(z.string()).optional(),
|
|
211
221
|
base_url: z.string().optional(),
|
|
212
222
|
headers: z.record(z.string(), z.string()).optional(),
|
package/src/core/parser/types.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface AssertionRule {
|
|
|
26
26
|
export interface TestStepExpect {
|
|
27
27
|
status?: number | number[];
|
|
28
28
|
body?: Record<string, AssertionRule>;
|
|
29
|
-
headers?: Record<string, string>;
|
|
29
|
+
headers?: Record<string, string | AssertionRule>;
|
|
30
30
|
duration?: number;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -41,6 +41,14 @@ export interface ForEach {
|
|
|
41
41
|
in: unknown;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export interface MultipartFileField {
|
|
45
|
+
file: string;
|
|
46
|
+
filename?: string;
|
|
47
|
+
content_type?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type MultipartField = string | MultipartFileField;
|
|
51
|
+
|
|
44
52
|
export interface TestStep {
|
|
45
53
|
name: string;
|
|
46
54
|
method: HttpMethod;
|
|
@@ -48,6 +56,7 @@ export interface TestStep {
|
|
|
48
56
|
headers?: Record<string, string>;
|
|
49
57
|
json?: unknown;
|
|
50
58
|
form?: Record<string, string>;
|
|
59
|
+
multipart?: Record<string, MultipartField>;
|
|
51
60
|
query?: Record<string, string>;
|
|
52
61
|
expect: TestStepExpect;
|
|
53
62
|
skip_if?: string;
|
|
@@ -67,6 +76,8 @@ export interface SuiteConfig {
|
|
|
67
76
|
export interface TestSuite {
|
|
68
77
|
name: string;
|
|
69
78
|
description?: string;
|
|
79
|
+
/** If true, this suite runs before all regular suites and its captures are shared into their env */
|
|
80
|
+
setup?: boolean;
|
|
70
81
|
tags?: string[];
|
|
71
82
|
base_url?: string;
|
|
72
83
|
headers?: Record<string, string>;
|
|
@@ -79,6 +79,9 @@ export function substituteStep(step: TestStep, vars: Record<string, unknown>): T
|
|
|
79
79
|
if (step.form) {
|
|
80
80
|
result.form = substituteDeep(step.form, vars);
|
|
81
81
|
}
|
|
82
|
+
if (step.multipart) {
|
|
83
|
+
result.multipart = substituteDeep(step.multipart, vars);
|
|
84
|
+
}
|
|
82
85
|
if (step.query) {
|
|
83
86
|
result.query = substituteDeep(step.query, vars);
|
|
84
87
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Glob } from "bun";
|
|
2
|
+
import { resolve } from "node:path";
|
|
2
3
|
import { validateSuite } from "./schema.ts";
|
|
3
4
|
import type { TestSuite } from "./types.ts";
|
|
4
5
|
|
|
@@ -19,7 +20,7 @@ export async function parseFile(filePath: string): Promise<TestSuite> {
|
|
|
19
20
|
|
|
20
21
|
try {
|
|
21
22
|
const suite = validateSuite(raw);
|
|
22
|
-
suite.filePath = filePath;
|
|
23
|
+
suite.filePath = resolve(filePath);
|
|
23
24
|
return suite;
|
|
24
25
|
} catch (err) {
|
|
25
26
|
throw new Error(`Validation error in ${filePath}: ${(err as Error).message}`);
|