@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +132 -112
  2. package/README.md +3 -10
  3. package/package.json +2 -3
  4. package/src/cli/commands/export.ts +144 -0
  5. package/src/cli/commands/generate.ts +31 -0
  6. package/src/cli/commands/run.ts +22 -5
  7. package/src/cli/commands/sync.ts +240 -0
  8. package/src/cli/index.ts +54 -10
  9. package/src/core/diagnostics/db-analysis.ts +79 -7
  10. package/src/core/diagnostics/failure-hints.ts +39 -0
  11. package/src/core/exporter/postman.ts +963 -0
  12. package/src/core/generator/data-factory.ts +38 -3
  13. package/src/core/generator/index.ts +1 -1
  14. package/src/core/generator/openapi-reader.ts +6 -0
  15. package/src/core/generator/serializer.ts +17 -2
  16. package/src/core/generator/suite-generator.ts +163 -14
  17. package/src/core/generator/types.ts +1 -0
  18. package/src/core/meta/meta-store.ts +78 -0
  19. package/src/core/meta/types.ts +21 -0
  20. package/src/core/parser/schema.ts +12 -2
  21. package/src/core/parser/types.ts +12 -1
  22. package/src/core/parser/variables.ts +3 -0
  23. package/src/core/parser/yaml-parser.ts +2 -1
  24. package/src/core/runner/assertions.ts +44 -20
  25. package/src/core/runner/execute-run.ts +31 -8
  26. package/src/core/runner/executor.ts +34 -8
  27. package/src/core/runner/http-client.ts +1 -1
  28. package/src/core/runner/types.ts +1 -0
  29. package/src/core/sync/spec-differ.ts +38 -0
  30. package/src/cli/commands/mcp.ts +0 -16
  31. package/src/mcp/descriptions.ts +0 -47
  32. package/src/mcp/server.ts +0 -38
  33. package/src/mcp/tools/ci-init.ts +0 -54
  34. package/src/mcp/tools/coverage-analysis.ts +0 -141
  35. package/src/mcp/tools/describe-endpoint.ts +0 -27
  36. package/src/mcp/tools/manage-server.ts +0 -86
  37. package/src/mcp/tools/query-db.ts +0 -84
  38. package/src/mcp/tools/run-tests.ts +0 -116
  39. package/src/mcp/tools/send-request.ts +0 -51
  40. 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" || schema.format === "date") return "2025-01-01T00:00:00Z";
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
- if (schema?.minimum !== undefined && schema.minimum > 0) {
123
- return schema.minimum;
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
- lines.push(`${prefix}- ${firstKey}: ${formatInlineValue(firstVal)}`);
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
- lines.push(`${prefix} ${k}: ${formatInlineValue(v)}`);
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
- 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);
@@ -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]: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
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: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
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 tests = authEndpoints.map(ep => generateStep(ep, securitySchemes));
379
- const headers = getSuiteHeaders(authEndpoints, securitySchemes);
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
- const others = allAuthEndpoints.filter(ep => ep !== registerEp && ep !== loginEp);
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 => 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
+ });
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 smoke-unsafe suite
535
- 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
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
- 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;
573
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}`);