@kirrosh/zond 0.20.0 → 0.22.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 +110 -3
  2. package/README.md +26 -15
  3. package/package.json +10 -6
  4. package/src/cli/commands/catalog.ts +62 -0
  5. package/src/cli/commands/ci-init.ts +12 -6
  6. package/src/cli/commands/completions.ts +176 -0
  7. package/src/cli/commands/db.ts +2 -1
  8. package/src/cli/commands/generate.ts +18 -2
  9. package/src/cli/commands/init/agents-md.ts +61 -0
  10. package/src/cli/commands/init/bootstrap.ts +79 -0
  11. package/src/cli/commands/init/skills.ts +45 -0
  12. package/src/cli/commands/init/templates/agents.md +73 -0
  13. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  14. package/src/cli/commands/init/templates/skills/scenarios.md +97 -0
  15. package/src/cli/commands/init/templates/skills/zond.md +184 -0
  16. package/src/cli/commands/init/templates/zond-config.yml +15 -0
  17. package/src/cli/commands/init.ts +124 -31
  18. package/src/cli/commands/probe-methods.ts +108 -0
  19. package/src/cli/commands/probe-validation.ts +124 -0
  20. package/src/cli/commands/run.ts +99 -10
  21. package/src/cli/commands/serve.ts +52 -19
  22. package/src/cli/commands/sync.ts +28 -1
  23. package/src/cli/commands/update.ts +1 -1
  24. package/src/cli/commands/use.ts +57 -0
  25. package/src/cli/index.ts +21 -591
  26. package/src/cli/program.ts +655 -0
  27. package/src/cli/version.ts +3 -0
  28. package/src/core/context/current.ts +35 -0
  29. package/src/core/diagnostics/db-analysis.ts +11 -2
  30. package/src/core/diagnostics/render-md.ts +112 -0
  31. package/src/core/generator/catalog-builder.ts +179 -0
  32. package/src/core/generator/chunker.ts +14 -2
  33. package/src/core/generator/data-factory.ts +50 -19
  34. package/src/core/generator/guide-builder.ts +1 -1
  35. package/src/core/generator/index.ts +2 -0
  36. package/src/core/generator/openapi-reader.ts +18 -0
  37. package/src/core/generator/serializer.ts +11 -2
  38. package/src/core/generator/suite-generator.ts +106 -7
  39. package/src/core/meta/types.ts +0 -2
  40. package/src/core/parser/schema.ts +3 -1
  41. package/src/core/parser/types.ts +10 -1
  42. package/src/core/parser/variables.ts +90 -2
  43. package/src/core/parser/yaml-parser.ts +50 -1
  44. package/src/core/probe/method-probe.ts +197 -0
  45. package/src/core/probe/negative-probe.ts +657 -0
  46. package/src/core/reporter/console.ts +29 -3
  47. package/src/core/reporter/index.ts +2 -2
  48. package/src/core/reporter/json.ts +5 -2
  49. package/src/core/runner/assertions.ts +4 -1
  50. package/src/core/runner/executor.ts +132 -37
  51. package/src/core/runner/http-client.ts +40 -5
  52. package/src/core/runner/rate-limiter.ts +131 -0
  53. package/src/core/setup-api.ts +4 -1
  54. package/src/core/workspace/root.ts +94 -0
  55. package/src/db/schema.ts +4 -1
  56. package/src/web/routes/api.ts +80 -0
  57. package/src/web/routes/dashboard.ts +15 -0
  58. package/src/web/static/style.css +290 -0
  59. package/src/web/views/explorer-tab.ts +402 -0
@@ -335,16 +335,25 @@ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples
335
335
  const pattern = group.response_status
336
336
  ? `${group.response_status} ${group.failure_type}`
337
337
  : group.failure_type;
338
+ // 5xx (api_error) are critical backend bugs — never collapse them.
339
+ // Diagnose must surface every 5xx, otherwise users miss real
340
+ // regressions hidden behind a single sample.
341
+ const isApiError = group.failure_type === "api_error";
342
+ const showAll = isApiError || maxExamples === 0;
338
343
  grouped_failures.push({
339
344
  pattern,
340
345
  count: group.items.length,
341
346
  failure_type: group.failure_type,
342
347
  recommended_action: group.items[0]!.recommended_action,
343
348
  hint: group.hint,
344
- examples: (maxExamples === 0 ? group.items : group.items.slice(0, maxExamples)).map(f => `${f.suite_name}/${f.test_name}`),
349
+ examples: (showAll ? group.items : group.items.slice(0, maxExamples)).map(f => `${f.suite_name}/${f.test_name}`),
345
350
  response_status: group.response_status,
346
351
  });
347
- compactFailures.push(group.items[0]!);
352
+ if (isApiError) {
353
+ compactFailures.push(...group.items);
354
+ } else {
355
+ compactFailures.push(group.items[0]!);
356
+ }
348
357
  }
349
358
 
350
359
  return { grouped_failures, compactFailures };
@@ -0,0 +1,112 @@
1
+ import type { DiagnoseResult } from "./db-analysis.ts";
2
+
3
+ /**
4
+ * Render a DiagnoseResult into a human-readable markdown digest.
5
+ * Output is the canonical body of the `zond://run/{id}/diagnosis` MCP resource.
6
+ */
7
+ export function renderDiagnosisMarkdown(result: DiagnoseResult): string {
8
+ const { run, summary, agent_directive, env_issue, auth_hint, cascade_skips, failures, grouped_failures } = result;
9
+ const out: string[] = [];
10
+
11
+ const passed = summary.passed;
12
+ const total = summary.total;
13
+ const env = run.environment ?? "no env";
14
+ const duration = run.duration_ms !== null ? `${run.duration_ms}ms` : "unknown duration";
15
+
16
+ out.push(`# Run ${run.id} — ${passed}/${total} passed`);
17
+ out.push(`${run.started_at} · ${env} · ${duration}`);
18
+ out.push("");
19
+ out.push("## Summary");
20
+ out.push(`- total: ${summary.total}`);
21
+ out.push(`- passed: ${summary.passed}`);
22
+ out.push(`- failed: ${summary.failed} (api_errors: ${summary.api_errors}, assertion_failures: ${summary.assertion_failures}, network_errors: ${summary.network_errors})`);
23
+
24
+ if (agent_directive) {
25
+ out.push("");
26
+ out.push("## Agent directive");
27
+ out.push(agent_directive);
28
+ }
29
+
30
+ if (env_issue) {
31
+ out.push("");
32
+ out.push("## Env issue");
33
+ out.push(env_issue);
34
+ }
35
+
36
+ if (auth_hint) {
37
+ out.push("");
38
+ out.push("## Auth hint");
39
+ out.push(auth_hint);
40
+ }
41
+
42
+ if (cascade_skips && cascade_skips.length > 0) {
43
+ out.push("");
44
+ out.push("## Cascade skips");
45
+ for (const group of cascade_skips) {
46
+ out.push(`- **${group.capture_var}** — ${group.count} test${group.count === 1 ? "" : "s"} skipped`);
47
+ for (const example of group.examples) {
48
+ out.push(` - ${example}`);
49
+ }
50
+ }
51
+ }
52
+
53
+ if (grouped_failures && grouped_failures.length > 0) {
54
+ out.push("");
55
+ out.push("## Failure groups");
56
+ for (const group of grouped_failures) {
57
+ out.push("");
58
+ out.push(`### ${group.pattern} — ${group.count} occurrence${group.count === 1 ? "" : "s"}`);
59
+ out.push(`recommended_action: \`${group.recommended_action}\``);
60
+ if (group.hint) out.push(`hint: ${group.hint}`);
61
+ if (group.examples.length > 0) {
62
+ out.push("examples:");
63
+ for (const example of group.examples) {
64
+ out.push(`- ${example}`);
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ if (failures.length > 0) {
71
+ out.push("");
72
+ out.push(grouped_failures && grouped_failures.length > 0 ? "## Representative failures" : "## Failures");
73
+ for (const f of failures) {
74
+ out.push("");
75
+ out.push(`### ${f.suite_name} > ${f.test_name}`);
76
+ const meta: string[] = [`failure_type: \`${f.failure_type}\``, `recommended_action: \`${f.recommended_action}\``];
77
+ if (f.suite_file) meta.push(`file: \`${f.suite_file}\``);
78
+ out.push(meta.join(" · "));
79
+ const requestLine = `${f.request_method ?? "?"} ${f.request_url ?? "?"}`;
80
+ const statusLine = f.response_status !== null ? ` → ${f.response_status}` : "";
81
+ out.push(`- request: ${requestLine}${statusLine}`);
82
+ if (f.error_message) out.push(`- error: ${f.error_message}`);
83
+ if (f.hint) out.push(`- hint: ${f.hint}`);
84
+ if (f.schema_hint) out.push(`- schema: ${f.schema_hint}`);
85
+ if (f.response_headers) {
86
+ const headerStr = Object.entries(f.response_headers).map(([k, v]) => `${k}: ${v}`).join(", ");
87
+ out.push(`- headers: ${headerStr}`);
88
+ }
89
+ if (f.response_body !== undefined) {
90
+ const bodyStr = typeof f.response_body === "string"
91
+ ? f.response_body
92
+ : JSON.stringify(f.response_body, null, 2);
93
+ const fenced = bodyStr.includes("\n") || bodyStr.length > 80;
94
+ if (fenced) {
95
+ out.push("- response_body:");
96
+ out.push(" ```");
97
+ for (const line of bodyStr.split("\n")) out.push(` ${line}`);
98
+ out.push(" ```");
99
+ } else {
100
+ out.push(`- response_body: \`${bodyStr}\``);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ if (failures.length === 0 && (!grouped_failures || grouped_failures.length === 0)) {
107
+ out.push("");
108
+ out.push("_No failures._");
109
+ }
110
+
111
+ return out.join("\n");
112
+ }
@@ -0,0 +1,179 @@
1
+ import type { EndpointInfo, SecuritySchemeInfo } from "./types.ts";
2
+ import { compressSchema, formatParam, isAnySchema } from "./schema-utils.ts";
3
+
4
+ export interface CatalogEndpoint {
5
+ method: string;
6
+ path: string;
7
+ summary?: string;
8
+ tags: string[];
9
+ deprecated?: boolean;
10
+ parameters?: string[];
11
+ requestBody?: string;
12
+ responses: Array<{
13
+ status: number;
14
+ schema?: string;
15
+ }>;
16
+ }
17
+
18
+ export interface ApiCatalog {
19
+ generatedAt: string;
20
+ specSource: string;
21
+ specHash: string;
22
+ apiName?: string;
23
+ apiVersion?: string;
24
+ baseUrl?: string;
25
+ auth: string[];
26
+ endpointCount: number;
27
+ endpoints: CatalogEndpoint[];
28
+ }
29
+
30
+ export interface BuildCatalogParams {
31
+ endpoints: EndpointInfo[];
32
+ securitySchemes: SecuritySchemeInfo[];
33
+ specSource: string;
34
+ specHash: string;
35
+ apiName?: string;
36
+ apiVersion?: string;
37
+ baseUrl?: string;
38
+ }
39
+
40
+ function formatSecurityScheme(s: SecuritySchemeInfo): string {
41
+ let desc = `${s.name}: ${s.type}`;
42
+ if (s.scheme) desc += `/${s.scheme}`;
43
+ if (s.bearerFormat) desc += ` (${s.bearerFormat})`;
44
+ if (s.in && s.apiKeyName) desc += ` (${s.apiKeyName} in ${s.in})`;
45
+ return desc;
46
+ }
47
+
48
+ function formatParamWithLocation(p: import("openapi-types").OpenAPIV3.ParameterObject): string {
49
+ const base = formatParam(p);
50
+ // Insert location before (req) or at the end
51
+ const reqSuffix = " (req)";
52
+ if (base.endsWith(reqSuffix)) {
53
+ return `${base.slice(0, -reqSuffix.length)} (${p.in}, req)`;
54
+ }
55
+ return `${base} (${p.in})`;
56
+ }
57
+
58
+ function buildCatalogEndpoint(ep: EndpointInfo): CatalogEndpoint {
59
+ const result: CatalogEndpoint = {
60
+ method: ep.method.toUpperCase(),
61
+ path: ep.path,
62
+ tags: ep.tags,
63
+ responses: [],
64
+ };
65
+
66
+ if (ep.summary) result.summary = ep.summary;
67
+ if (ep.deprecated) result.deprecated = true;
68
+
69
+ if (ep.parameters.length > 0) {
70
+ result.parameters = ep.parameters.map(formatParamWithLocation);
71
+ }
72
+
73
+ if (ep.requestBodySchema) {
74
+ result.requestBody = isAnySchema(ep.requestBodySchema)
75
+ ? "any"
76
+ : compressSchema(ep.requestBodySchema);
77
+ }
78
+
79
+ for (const resp of ep.responses) {
80
+ const entry: { status: number; schema?: string } = { status: resp.statusCode };
81
+ if (resp.schema && !isAnySchema(resp.schema)) {
82
+ entry.schema = compressSchema(resp.schema);
83
+ }
84
+ result.responses.push(entry);
85
+ }
86
+
87
+ return result;
88
+ }
89
+
90
+ export function buildCatalog(params: BuildCatalogParams): ApiCatalog {
91
+ return {
92
+ generatedAt: new Date().toISOString(),
93
+ specSource: params.specSource,
94
+ specHash: params.specHash,
95
+ ...(params.apiName ? { apiName: params.apiName } : {}),
96
+ ...(params.apiVersion ? { apiVersion: params.apiVersion } : {}),
97
+ ...(params.baseUrl ? { baseUrl: params.baseUrl } : {}),
98
+ auth: params.securitySchemes.map(formatSecurityScheme),
99
+ endpointCount: params.endpoints.length,
100
+ endpoints: params.endpoints.map(buildCatalogEndpoint),
101
+ };
102
+ }
103
+
104
+ // --- YAML serialization ---
105
+
106
+ function yamlEscape(value: string): string {
107
+ if (/[:{}\[\],&*#?|<>=!%@`"']/.test(value) || value.includes("\n")) {
108
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
109
+ }
110
+ return value;
111
+ }
112
+
113
+ function yamlScalar(value: unknown): string {
114
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
115
+ return yamlEscape(String(value));
116
+ }
117
+
118
+ export function serializeCatalog(catalog: ApiCatalog): string {
119
+ const lines: string[] = [];
120
+ lines.push("# Auto-generated by zond. Regenerate with: zond catalog <spec>");
121
+ lines.push(`generatedAt: ${yamlScalar(catalog.generatedAt)}`);
122
+ lines.push(`specSource: ${yamlScalar(catalog.specSource)}`);
123
+ lines.push(`specHash: ${yamlScalar(catalog.specHash)}`);
124
+ if (catalog.apiName) lines.push(`apiName: ${yamlScalar(catalog.apiName)}`);
125
+ if (catalog.apiVersion) lines.push(`apiVersion: ${yamlScalar(catalog.apiVersion)}`);
126
+ if (catalog.baseUrl) lines.push(`baseUrl: ${yamlScalar(catalog.baseUrl)}`);
127
+ lines.push(`endpointCount: ${catalog.endpointCount}`);
128
+
129
+ // Auth
130
+ if (catalog.auth.length > 0) {
131
+ lines.push("auth:");
132
+ for (const a of catalog.auth) {
133
+ lines.push(` - ${yamlScalar(a)}`);
134
+ }
135
+ } else {
136
+ lines.push("auth: []");
137
+ }
138
+
139
+ // Endpoints
140
+ if (catalog.endpoints.length === 0) {
141
+ lines.push("endpoints: []");
142
+ return lines.join("\n") + "\n";
143
+ }
144
+ lines.push("endpoints:");
145
+ for (const ep of catalog.endpoints) {
146
+ lines.push(` - method: ${ep.method}`);
147
+ lines.push(` path: ${yamlScalar(ep.path)}`);
148
+ if (ep.summary) lines.push(` summary: ${yamlScalar(ep.summary)}`);
149
+ if (ep.tags.length > 0) {
150
+ lines.push(` tags: [${ep.tags.map(yamlScalar).join(", ")}]`);
151
+ }
152
+ if (ep.deprecated) lines.push(` deprecated: true`);
153
+
154
+ if (ep.parameters && ep.parameters.length > 0) {
155
+ lines.push(` parameters:`);
156
+ for (const p of ep.parameters) {
157
+ lines.push(` - ${yamlScalar(p)}`);
158
+ }
159
+ }
160
+
161
+ if (ep.requestBody) {
162
+ lines.push(` requestBody: ${yamlScalar(ep.requestBody)}`);
163
+ }
164
+
165
+ if (ep.responses.length > 0) {
166
+ lines.push(` responses:`);
167
+ for (const r of ep.responses) {
168
+ if (r.schema) {
169
+ lines.push(` - status: ${r.status}`);
170
+ lines.push(` schema: ${yamlScalar(r.schema)}`);
171
+ } else {
172
+ lines.push(` - status: ${r.status}`);
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ return lines.join("\n") + "\n";
179
+ }
@@ -39,9 +39,21 @@ export function planChunks(endpoints: EndpointInfo[]): ChunkPlan {
39
39
 
40
40
  /** Filter endpoints that have the given tag (case-insensitive) */
41
41
  export function filterByTag(endpoints: EndpointInfo[], tag: string): EndpointInfo[] {
42
- const lower = tag.toLowerCase();
42
+ const lower = tag.trim().toLowerCase();
43
43
  if (lower === "untagged") {
44
44
  return endpoints.filter(ep => ep.tags.length === 0);
45
45
  }
46
- return endpoints.filter(ep => ep.tags.some(t => t.toLowerCase() === lower));
46
+ return endpoints.filter(ep => ep.tags.some(t => t.trim().toLowerCase() === lower));
47
+ }
48
+
49
+ /** Collect the unique set of tags across all endpoints (sorted, original casing). */
50
+ export function collectTags(endpoints: EndpointInfo[]): string[] {
51
+ const seen = new Map<string, string>();
52
+ for (const ep of endpoints) {
53
+ for (const t of ep.tags) {
54
+ const key = t.trim().toLowerCase();
55
+ if (!seen.has(key)) seen.set(key, t.trim());
56
+ }
57
+ }
58
+ return Array.from(seen.values()).sort((a, b) => a.localeCompare(b));
47
59
  }
@@ -11,6 +11,12 @@ export function generateFromSchema(
11
11
  ): unknown {
12
12
  if (_depth > 5) return {};
13
13
 
14
+ // Highest-priority signal: explicit example from spec.
15
+ // Beats enum, format, heuristics — the spec author told us what to send.
16
+ if (schema.example !== undefined) {
17
+ return schema.example;
18
+ }
19
+
14
20
  // allOf: merge all schemas
15
21
  if (schema.allOf) {
16
22
  const merged: OpenAPIV3.SchemaObject = { type: "object", properties: {} };
@@ -31,15 +37,26 @@ export function generateFromSchema(
31
37
  return generateFromSchema(schema.anyOf[0] as OpenAPIV3.SchemaObject, propertyName, _depth + 1);
32
38
  }
33
39
 
34
- // enum: first value
40
+ // enum: first value (always valid for the API contract)
35
41
  if (schema.enum && schema.enum.length > 0) {
36
42
  return schema.enum[0];
37
43
  }
38
44
 
39
- // uuid format overrides type (e.g. integer fields with format: uuid)
40
- if (schema.format === "uuid") return "{{$uuid}}";
41
-
42
- switch (schema.type) {
45
+ // Format-based placeholders override type resolution. Schemas in the wild
46
+ // commonly carry `format` without an explicit `type` (loosely-defined specs)
47
+ // or with `type: ["string", "null"]` (OpenAPI 3.1 nullable). Falling through
48
+ // to the type switch in those cases dropped us into the default branch and
49
+ // produced `{{$randomString}}` for `format: email` — TASK-86 regression.
50
+ const formatPlaceholder = formatToPlaceholder(schema.format);
51
+ if (formatPlaceholder !== undefined) return formatPlaceholder;
52
+
53
+ // OpenAPI 3.1: type can be `["string", "null"]`. Collapse to the first
54
+ // non-null entry so the switch below routes correctly.
55
+ const effectiveType = Array.isArray(schema.type)
56
+ ? (schema.type as string[]).find(t => t !== "null") as OpenAPIV3.SchemaObject["type"] | undefined
57
+ : schema.type;
58
+
59
+ switch (effectiveType) {
43
60
  case "string":
44
61
  return guessStringPlaceholder(schema, propertyName);
45
62
 
@@ -53,8 +70,9 @@ export function generateFromSchema(
53
70
  return true;
54
71
 
55
72
  case "array": {
56
- if (schema.items) {
57
- const item = generateFromSchema(schema.items as OpenAPIV3.SchemaObject, undefined, _depth + 1);
73
+ const arr = schema as OpenAPIV3.ArraySchemaObject;
74
+ if (arr.items) {
75
+ const item = generateFromSchema(arr.items as OpenAPIV3.SchemaObject, undefined, _depth + 1);
58
76
  return [item];
59
77
  }
60
78
  return [];
@@ -79,7 +97,7 @@ export function generateFromSchema(
79
97
  return { key1: "value1", key2: "value2" };
80
98
  }
81
99
  // Bare object with no properties
82
- if (schema.type === "object") {
100
+ if (effectiveType === "object") {
83
101
  return {};
84
102
  }
85
103
  return "{{$randomString}}";
@@ -87,6 +105,27 @@ export function generateFromSchema(
87
105
  }
88
106
  }
89
107
 
108
+ /**
109
+ * Map an OpenAPI `format` value to a zond generator placeholder. Returns
110
+ * undefined when the format is unknown or absent so callers can fall back
111
+ * to type / property-name heuristics. Exported for tests.
112
+ */
113
+ export function formatToPlaceholder(format: string | undefined): string | undefined {
114
+ switch (format) {
115
+ case "email": return "{{$randomEmail}}";
116
+ case "uuid": return "{{$uuid}}";
117
+ case "date-time": return "{{$randomIsoDate}}";
118
+ case "date": return "{{$randomDate}}";
119
+ case "uri":
120
+ case "url": return "{{$randomUrl}}";
121
+ case "hostname": return "{{$randomFqdn}}";
122
+ case "ipv4": return "{{$randomIpv4}}";
123
+ case "ipv6": return "::1";
124
+ case "password": return "TestPass123!";
125
+ default: return undefined;
126
+ }
127
+ }
128
+
90
129
  /**
91
130
  * Generate a multipart body object from an OpenAPI multipart/form-data schema.
92
131
  * Binary fields (format: binary/byte) become file upload objects; all others become strings.
@@ -112,16 +151,8 @@ export function generateMultipartFromSchema(
112
151
  }
113
152
 
114
153
  function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string): string {
115
- // Format-based
116
- if (schema.format === "email") return "{{$randomEmail}}";
117
- if (schema.format === "uuid") return "{{$uuid}}";
118
- if (schema.format === "date-time") return "2025-01-01T00:00:00Z";
119
- if (schema.format === "date") return "2025-01-01";
120
- if (schema.format === "uri" || schema.format === "url") return "https://example.com/test";
121
- if (schema.format === "hostname") return "example.com";
122
- if (schema.format === "ipv4") return "192.168.1.1";
123
- if (schema.format === "ipv6") return "::1";
124
- if (schema.format === "password") return "TestPass123!";
154
+ // Format-based dispatch already happened earlier in generateFromSchema;
155
+ // this branch only sees strings whose format is empty or unrecognised.
125
156
 
126
157
  // Name-based heuristics
127
158
  if (name) {
@@ -136,7 +167,7 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
136
167
  return "{{$randomName}}";
137
168
  }
138
169
  if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") {
139
- return "https://example.com/test";
170
+ return "{{$randomUrl}}";
140
171
  }
141
172
  if (lower === "password" || lower.endsWith("_password")) {
142
173
  return "TestPass123!";
@@ -120,7 +120,7 @@ Use \`json:\` for JSON request bodies. Do NOT use \`body:\` — it is not a vali
120
120
  For form-encoded: use \`form:\` instead of \`json:\`.
121
121
 
122
122
  ### Built-in generators
123
- \`{{$uuid}}\`, \`{{$randomInt}}\`, \`{{$timestamp}}\`, \`{{$isoTimestamp}}\`, \`{{$randomName}}\`, \`{{$randomEmail}}\`, \`{{$randomString}}\`
123
+ \`{{$uuid}}\`, \`{{$randomInt}}\`, \`{{$timestamp}}\`, \`{{$isoTimestamp}}\`, \`{{$randomName}}\`, \`{{$randomEmail}}\`, \`{{$randomString}}\`, \`{{$randomUrl}}\` (uri/url), \`{{$randomFqdn}}\` (hostname), \`{{$randomIpv4}}\` (ipv4), \`{{$randomDate}}\` (date), \`{{$randomIsoDate}}\` (date-time)
124
124
 
125
125
  ### Variable capture & interpolation
126
126
  \`\`\`yaml
@@ -10,3 +10,5 @@ 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
12
  export { generateSuites, generateStep, detectCrudGroups, generateCrudSuite, generateSanitySuite, findUnresolvedVars } from "./suite-generator.ts";
13
+ export { buildCatalog, serializeCatalog } from "./catalog-builder.ts";
14
+ export type { ApiCatalog, CatalogEndpoint } from "./catalog-builder.ts";
@@ -93,6 +93,24 @@ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
93
93
  const chosen = rb.content[requestBodyContentType!];
94
94
  if (chosen?.schema) {
95
95
  requestBodySchema = chosen.schema as OpenAPIV3.SchemaObject;
96
+ // OpenAPI allows examples at the media-type level (sibling to schema).
97
+ // Lift them onto the schema so the generator sees a single signal.
98
+ if (requestBodySchema.example === undefined) {
99
+ if ((chosen as OpenAPIV3.MediaTypeObject).example !== undefined) {
100
+ requestBodySchema = {
101
+ ...requestBodySchema,
102
+ example: (chosen as OpenAPIV3.MediaTypeObject).example,
103
+ };
104
+ } else if (chosen.examples) {
105
+ const firstNamed = Object.values(chosen.examples)[0];
106
+ if (firstNamed && typeof firstNamed === "object" && "value" in firstNamed) {
107
+ requestBodySchema = {
108
+ ...requestBodySchema,
109
+ example: (firstNamed as OpenAPIV3.ExampleObject).value,
110
+ };
111
+ }
112
+ }
113
+ }
96
114
  }
97
115
  }
98
116
  }
@@ -27,7 +27,7 @@ export interface RawStep {
27
27
  name: string;
28
28
  [methodKey: string]: unknown;
29
29
  expect: {
30
- status?: number;
30
+ status?: number | number[];
31
31
  body?: Record<string, Record<string, string>>;
32
32
  headers?: Record<string, unknown>;
33
33
  };
@@ -103,6 +103,11 @@ export function serializeSuite(suite: RawSuite): string {
103
103
  lines.push(` skip_if: ${yamlScalar(String(test.skip_if))}`);
104
104
  }
105
105
 
106
+ // always (cleanup steps that survive cascade-skip on tainted captures)
107
+ if (test.always === true) {
108
+ lines.push(" always: true");
109
+ }
110
+
106
111
  // retry_until
107
112
  if (test.retry_until && typeof test.retry_until === "object") {
108
113
  const rt = test.retry_until as Record<string, unknown>;
@@ -131,7 +136,11 @@ export function serializeSuite(suite: RawSuite): string {
131
136
  if (hasExpect) {
132
137
  lines.push(" expect:");
133
138
  if (test.expect.status !== undefined) {
134
- lines.push(` status: ${test.expect.status}`);
139
+ if (Array.isArray(test.expect.status)) {
140
+ lines.push(` status: [${test.expect.status.join(", ")}]`);
141
+ } else {
142
+ lines.push(` status: ${test.expect.status}`);
143
+ }
135
144
  }
136
145
  if (test.expect.body) {
137
146
  lines.push(" body:");