@kirrosh/zond 0.14.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -112
- package/README.md +3 -10
- package/package.json +4 -4
- 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/export.ts +144 -0
- package/src/cli/commands/generate.ts +158 -0
- package/src/cli/commands/guide.ts +127 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/request.ts +57 -0
- package/src/cli/commands/run.ts +74 -14
- package/src/cli/commands/serve.ts +62 -3
- package/src/cli/commands/sync.ts +240 -0
- package/src/cli/commands/validate.ts +18 -2
- package/src/cli/index.ts +258 -17
- package/src/cli/json-envelope.ts +19 -0
- package/src/core/diagnostics/db-analysis.ts +423 -0
- package/src/core/diagnostics/failure-hints.ts +40 -0
- package/src/core/exporter/postman.ts +963 -0
- package/src/core/generator/data-factory.ts +55 -9
- package/src/core/generator/describe.ts +250 -0
- package/src/core/generator/guide-builder.ts +20 -0
- 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 +291 -29
- 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 +35 -8
- package/src/core/runner/http-client.ts +1 -1
- package/src/core/runner/send-request.ts +94 -0
- package/src/core/runner/types.ts +2 -0
- package/src/core/sync/spec-differ.ts +38 -0
- package/src/db/queries.ts +4 -2
- package/src/db/schema.ts +11 -3
- package/src/web/views/suites-tab.ts +1 -1
- package/src/cli/commands/mcp.ts +0 -16
- package/src/mcp/descriptions.ts +0 -71
- package/src/mcp/server.ts +0 -45
- 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 -242
- package/src/mcp/tools/generate-and-save.ts +0 -202
- package/src/mcp/tools/manage-server.ts +0 -86
- package/src/mcp/tools/query-db.ts +0 -300
- package/src/mcp/tools/run-tests.ts +0 -115
- package/src/mcp/tools/save-test-suite.ts +0 -218
- package/src/mcp/tools/send-request.ts +0 -97
- package/src/mcp/tools/set-work-dir.ts +0 -35
- package/src/mcp/tools/setup-api.ts +0 -88
|
@@ -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);
|
|
@@ -233,6 +266,13 @@ export function generateCrudSuite(
|
|
|
233
266
|
const allEps = [group.create, group.list, group.read, group.update, group.delete].filter(Boolean) as EndpointInfo[];
|
|
234
267
|
const suiteHeaders = getSuiteHeaders(allEps, securitySchemes);
|
|
235
268
|
|
|
269
|
+
// 0. List all (before create, to verify collection exists)
|
|
270
|
+
if (group.list) {
|
|
271
|
+
const step = generateStep(group.list, securitySchemes);
|
|
272
|
+
if (suiteHeaders) delete (step as any).headers;
|
|
273
|
+
tests.push(step);
|
|
274
|
+
}
|
|
275
|
+
|
|
236
276
|
// 1. Create
|
|
237
277
|
if (group.create) {
|
|
238
278
|
const step = generateStep(group.create, securitySchemes);
|
|
@@ -258,13 +298,31 @@ export function generateCrudSuite(
|
|
|
258
298
|
// 3. Update
|
|
259
299
|
if (group.update) {
|
|
260
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
|
+
|
|
261
316
|
const step: RawStep = {
|
|
262
317
|
name: group.update.operationId ?? `Update ${group.resource.replace(/s$/, "")}`,
|
|
263
|
-
[method]:
|
|
318
|
+
[method]: itemPath,
|
|
264
319
|
expect: {
|
|
265
320
|
status: getExpectedStatus(group.update),
|
|
266
321
|
},
|
|
267
322
|
};
|
|
323
|
+
if (group.update.requiresEtag) {
|
|
324
|
+
step.headers = { "If-Match": `"{{${etagVar}}}"` };
|
|
325
|
+
}
|
|
268
326
|
if (group.update.requestBodySchema) {
|
|
269
327
|
step.json = generateFromSchema(group.update.requestBodySchema);
|
|
270
328
|
}
|
|
@@ -273,13 +331,32 @@ export function generateCrudSuite(
|
|
|
273
331
|
|
|
274
332
|
// 4. Delete
|
|
275
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
|
+
|
|
276
350
|
const step: RawStep = {
|
|
277
351
|
name: group.delete.operationId ?? `Delete ${group.resource.replace(/s$/, "")}`,
|
|
278
|
-
DELETE:
|
|
352
|
+
DELETE: itemPath,
|
|
279
353
|
expect: {
|
|
280
354
|
status: getExpectedStatus(group.delete),
|
|
281
355
|
},
|
|
282
356
|
};
|
|
357
|
+
if (group.delete.requiresEtag) {
|
|
358
|
+
step.headers = { "If-Match": `"{{${etagVar}}}"` };
|
|
359
|
+
}
|
|
283
360
|
tests.push(step);
|
|
284
361
|
|
|
285
362
|
// 5. Verify deleted
|
|
@@ -335,6 +412,171 @@ export function findUnresolvedVars(suite: RawSuite, envKeys?: Set<string>): stri
|
|
|
335
412
|
return [...vars];
|
|
336
413
|
}
|
|
337
414
|
|
|
415
|
+
/** Check if a schema has a specific field name (case-insensitive) */
|
|
416
|
+
function schemaHasField(schema: OpenAPIV3.SchemaObject | undefined, ...names: string[]): boolean {
|
|
417
|
+
if (!schema?.properties) return false;
|
|
418
|
+
const keys = Object.keys(schema.properties).map(k => k.toLowerCase());
|
|
419
|
+
return names.some(n => keys.includes(n.toLowerCase()));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Generate auth suite with register+login consistency */
|
|
423
|
+
export function generateAuthSuite(
|
|
424
|
+
authEndpoints: EndpointInfo[],
|
|
425
|
+
securitySchemes: SecuritySchemeInfo[],
|
|
426
|
+
): RawSuite {
|
|
427
|
+
// Detect register → login pair
|
|
428
|
+
const registerEp = authEndpoints.find(ep =>
|
|
429
|
+
/\/(register|signup)\b/i.test(ep.path) && ep.method.toUpperCase() === "POST"
|
|
430
|
+
);
|
|
431
|
+
const loginEp = authEndpoints.find(ep =>
|
|
432
|
+
ep !== registerEp &&
|
|
433
|
+
/\/(login|signin|auth)\b/i.test(ep.path) &&
|
|
434
|
+
ep.method.toUpperCase() === "POST"
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const hasCredentialPair = registerEp && loginEp &&
|
|
438
|
+
schemaHasField(registerEp.requestBodySchema, "email", "username") &&
|
|
439
|
+
schemaHasField(registerEp.requestBodySchema, "password") &&
|
|
440
|
+
schemaHasField(loginEp.requestBodySchema, "email", "username") &&
|
|
441
|
+
schemaHasField(loginEp.requestBodySchema, "password");
|
|
442
|
+
|
|
443
|
+
if (hasCredentialPair) {
|
|
444
|
+
return generateConsistentAuthSuite(registerEp, loginEp, authEndpoints, securitySchemes);
|
|
445
|
+
}
|
|
446
|
+
|
|
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);
|
|
451
|
+
|
|
452
|
+
const suite: RawSuite = {
|
|
453
|
+
name: "auth",
|
|
454
|
+
setup: true,
|
|
455
|
+
tags: ["auth"],
|
|
456
|
+
fileStem: "auth",
|
|
457
|
+
base_url: "{{base_url}}",
|
|
458
|
+
tests,
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
if (headers) {
|
|
462
|
+
suite.headers = headers;
|
|
463
|
+
for (const t of tests) {
|
|
464
|
+
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
465
|
+
delete (t as any).headers;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return suite;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Generate auth suite with consistent register → login credentials */
|
|
474
|
+
function generateConsistentAuthSuite(
|
|
475
|
+
registerEp: EndpointInfo,
|
|
476
|
+
loginEp: EndpointInfo,
|
|
477
|
+
allAuthEndpoints: EndpointInfo[],
|
|
478
|
+
securitySchemes: SecuritySchemeInfo[],
|
|
479
|
+
): RawSuite {
|
|
480
|
+
const tests: RawStep[] = [];
|
|
481
|
+
|
|
482
|
+
// Determine credential field: "email" or "username"
|
|
483
|
+
const useEmail = schemaHasField(registerEp.requestBodySchema, "email");
|
|
484
|
+
const credField = useEmail ? "email" : "username";
|
|
485
|
+
const credValue = useEmail ? "test_{{$timestamp}}@test.com" : "testuser_{{$timestamp}}";
|
|
486
|
+
|
|
487
|
+
// 0. Set shared credentials
|
|
488
|
+
const setStep: RawStep = {
|
|
489
|
+
name: "Set test credentials",
|
|
490
|
+
set: {
|
|
491
|
+
[`test_${credField}`]: credValue,
|
|
492
|
+
test_password: "TestPass123!",
|
|
493
|
+
},
|
|
494
|
+
expect: {},
|
|
495
|
+
} as RawStep;
|
|
496
|
+
tests.push(setStep);
|
|
497
|
+
|
|
498
|
+
// 1. Register step — replace credential fields with shared vars
|
|
499
|
+
const registerStep = generateStep(registerEp, securitySchemes);
|
|
500
|
+
if (registerStep.json && typeof registerStep.json === "object") {
|
|
501
|
+
const json = registerStep.json as Record<string, unknown>;
|
|
502
|
+
if (credField in json) json[credField] = `{{test_${credField}}}`;
|
|
503
|
+
if ("password" in json) json.password = "{{test_password}}";
|
|
504
|
+
}
|
|
505
|
+
tests.push(registerStep);
|
|
506
|
+
|
|
507
|
+
// 2. Login step — reuse same credentials + capture token
|
|
508
|
+
const loginStep = generateStep(loginEp, securitySchemes);
|
|
509
|
+
if (loginStep.json && typeof loginStep.json === "object") {
|
|
510
|
+
const json = loginStep.json as Record<string, unknown>;
|
|
511
|
+
if (credField in json) json[credField] = `{{test_${credField}}}`;
|
|
512
|
+
if ("password" in json) json.password = "{{test_password}}";
|
|
513
|
+
}
|
|
514
|
+
// Try to capture auth token from login response
|
|
515
|
+
const loginSchema = getSuccessSchema(loginEp);
|
|
516
|
+
if (loginSchema?.properties) {
|
|
517
|
+
const tokenField = Object.keys(loginSchema.properties).find(k =>
|
|
518
|
+
/token|access_token|accessToken|jwt/i.test(k)
|
|
519
|
+
);
|
|
520
|
+
if (tokenField) {
|
|
521
|
+
if (!loginStep.expect.body) loginStep.expect.body = {};
|
|
522
|
+
loginStep.expect.body[tokenField] = { capture: "auth_token" };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
tests.push(loginStep);
|
|
526
|
+
|
|
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
|
+
);
|
|
532
|
+
for (const ep of others) {
|
|
533
|
+
tests.push(generateStep(ep, securitySchemes));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
name: "auth",
|
|
538
|
+
setup: true,
|
|
539
|
+
tags: ["auth"],
|
|
540
|
+
fileStem: "auth",
|
|
541
|
+
base_url: "{{base_url}}",
|
|
542
|
+
tests,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
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
|
+
|
|
338
580
|
/** Main entry point: generate all suites from endpoints */
|
|
339
581
|
export function generateSuites(opts: {
|
|
340
582
|
endpoints: EndpointInfo[];
|
|
@@ -373,10 +615,16 @@ export function generateSuites(opts: {
|
|
|
373
615
|
for (const [tag, tagEndpoints] of byTag) {
|
|
374
616
|
const tagSlug = slugify(tag) || "api";
|
|
375
617
|
|
|
376
|
-
// GET endpoints → smoke suite
|
|
618
|
+
// GET endpoints → smoke suite (use seed values for path params — no capture context)
|
|
377
619
|
const getEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() === "GET");
|
|
378
620
|
if (getEndpoints.length > 0) {
|
|
379
|
-
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
|
+
});
|
|
380
628
|
const headers = getSuiteHeaders(getEndpoints, securitySchemes);
|
|
381
629
|
|
|
382
630
|
const suite: RawSuite = {
|
|
@@ -399,8 +647,37 @@ export function generateSuites(opts: {
|
|
|
399
647
|
suites.push(suite);
|
|
400
648
|
}
|
|
401
649
|
|
|
402
|
-
// Non-GET endpoints
|
|
403
|
-
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
|
|
404
681
|
if (unsafeEndpoints.length > 0) {
|
|
405
682
|
const tests = unsafeEndpoints.map(ep => generateStep(ep, securitySchemes));
|
|
406
683
|
const headers = getSuiteHeaders(unsafeEndpoints, securitySchemes);
|
|
@@ -433,28 +710,13 @@ export function generateSuites(opts: {
|
|
|
433
710
|
|
|
434
711
|
// 4. Auth suite (separate — requires real credentials)
|
|
435
712
|
if (authEndpoints.length > 0) {
|
|
436
|
-
const
|
|
437
|
-
const headers = getSuiteHeaders(authEndpoints, securitySchemes);
|
|
438
|
-
|
|
439
|
-
const suite: RawSuite = {
|
|
440
|
-
name: "auth",
|
|
441
|
-
tags: ["auth"],
|
|
442
|
-
fileStem: "auth",
|
|
443
|
-
base_url: "{{base_url}}",
|
|
444
|
-
tests,
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
if (headers) {
|
|
448
|
-
suite.headers = headers;
|
|
449
|
-
for (const t of tests) {
|
|
450
|
-
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
451
|
-
delete (t as any).headers;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
713
|
+
const suite = generateAuthSuite(authEndpoints, securitySchemes);
|
|
456
714
|
suites.push(suite);
|
|
457
715
|
}
|
|
458
716
|
|
|
459
|
-
|
|
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;
|
|
460
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}`);
|