@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +132 -112
  2. package/README.md +3 -10
  3. package/package.json +4 -4
  4. package/src/cli/commands/ci-init.ts +12 -1
  5. package/src/cli/commands/coverage.ts +21 -1
  6. package/src/cli/commands/db.ts +121 -0
  7. package/src/cli/commands/describe.ts +60 -0
  8. package/src/cli/commands/export.ts +144 -0
  9. package/src/cli/commands/generate.ts +158 -0
  10. package/src/cli/commands/guide.ts +127 -0
  11. package/src/cli/commands/init.ts +57 -0
  12. package/src/cli/commands/request.ts +57 -0
  13. package/src/cli/commands/run.ts +74 -14
  14. package/src/cli/commands/serve.ts +62 -3
  15. package/src/cli/commands/sync.ts +240 -0
  16. package/src/cli/commands/validate.ts +18 -2
  17. package/src/cli/index.ts +258 -17
  18. package/src/cli/json-envelope.ts +19 -0
  19. package/src/core/diagnostics/db-analysis.ts +423 -0
  20. package/src/core/diagnostics/failure-hints.ts +40 -0
  21. package/src/core/exporter/postman.ts +963 -0
  22. package/src/core/generator/data-factory.ts +55 -9
  23. package/src/core/generator/describe.ts +250 -0
  24. package/src/core/generator/guide-builder.ts +20 -0
  25. package/src/core/generator/index.ts +1 -1
  26. package/src/core/generator/openapi-reader.ts +6 -0
  27. package/src/core/generator/serializer.ts +17 -2
  28. package/src/core/generator/suite-generator.ts +291 -29
  29. package/src/core/generator/types.ts +1 -0
  30. package/src/core/meta/meta-store.ts +78 -0
  31. package/src/core/meta/types.ts +21 -0
  32. package/src/core/parser/schema.ts +12 -2
  33. package/src/core/parser/types.ts +12 -1
  34. package/src/core/parser/variables.ts +3 -0
  35. package/src/core/parser/yaml-parser.ts +2 -1
  36. package/src/core/runner/assertions.ts +44 -20
  37. package/src/core/runner/execute-run.ts +31 -8
  38. package/src/core/runner/executor.ts +35 -8
  39. package/src/core/runner/http-client.ts +1 -1
  40. package/src/core/runner/send-request.ts +94 -0
  41. package/src/core/runner/types.ts +2 -0
  42. package/src/core/sync/spec-differ.ts +38 -0
  43. package/src/db/queries.ts +4 -2
  44. package/src/db/schema.ts +11 -3
  45. package/src/web/views/suites-tab.ts +1 -1
  46. package/src/cli/commands/mcp.ts +0 -16
  47. package/src/mcp/descriptions.ts +0 -71
  48. package/src/mcp/server.ts +0 -45
  49. package/src/mcp/tools/ci-init.ts +0 -54
  50. package/src/mcp/tools/coverage-analysis.ts +0 -141
  51. package/src/mcp/tools/describe-endpoint.ts +0 -242
  52. package/src/mcp/tools/generate-and-save.ts +0 -202
  53. package/src/mcp/tools/manage-server.ts +0 -86
  54. package/src/mcp/tools/query-db.ts +0 -300
  55. package/src/mcp/tools/run-tests.ts +0 -115
  56. package/src/mcp/tools/save-test-suite.ts +0 -218
  57. package/src/mcp/tools/send-request.ts +0 -97
  58. package/src/mcp/tools/set-work-dir.ts +0 -35
  59. 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
- step.json = generateFromSchema(ep.requestBodySchema);
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]: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
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: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
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 => generateStep(ep, securitySchemes));
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 smoke-unsafe suite
403
- const unsafeEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() !== "GET");
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 tests = authEndpoints.map(ep => generateStep(ep, securitySchemes));
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
- return suites;
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
  }
@@ -19,6 +19,7 @@ export interface EndpointInfo {
19
19
  responses: ResponseInfo[];
20
20
  security: string[];
21
21
  deprecated?: boolean;
22
+ requiresEtag?: boolean;
22
23
  }
23
24
 
24
25
  export interface SecuritySchemeInfo {
@@ -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(),
@@ -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}`);