@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.
- package/CHANGELOG.md +110 -3
- package/README.md +26 -15
- package/package.json +10 -6
- package/src/cli/commands/catalog.ts +62 -0
- package/src/cli/commands/ci-init.ts +12 -6
- package/src/cli/commands/completions.ts +176 -0
- package/src/cli/commands/db.ts +2 -1
- package/src/cli/commands/generate.ts +18 -2
- package/src/cli/commands/init/agents-md.ts +61 -0
- package/src/cli/commands/init/bootstrap.ts +79 -0
- package/src/cli/commands/init/skills.ts +45 -0
- package/src/cli/commands/init/templates/agents.md +73 -0
- package/src/cli/commands/init/templates/markdown.d.ts +4 -0
- package/src/cli/commands/init/templates/skills/scenarios.md +97 -0
- package/src/cli/commands/init/templates/skills/zond.md +184 -0
- package/src/cli/commands/init/templates/zond-config.yml +15 -0
- package/src/cli/commands/init.ts +124 -31
- package/src/cli/commands/probe-methods.ts +108 -0
- package/src/cli/commands/probe-validation.ts +124 -0
- package/src/cli/commands/run.ts +99 -10
- package/src/cli/commands/serve.ts +52 -19
- package/src/cli/commands/sync.ts +28 -1
- package/src/cli/commands/update.ts +1 -1
- package/src/cli/commands/use.ts +57 -0
- package/src/cli/index.ts +21 -591
- package/src/cli/program.ts +655 -0
- package/src/cli/version.ts +3 -0
- package/src/core/context/current.ts +35 -0
- package/src/core/diagnostics/db-analysis.ts +11 -2
- package/src/core/diagnostics/render-md.ts +112 -0
- package/src/core/generator/catalog-builder.ts +179 -0
- package/src/core/generator/chunker.ts +14 -2
- package/src/core/generator/data-factory.ts +50 -19
- package/src/core/generator/guide-builder.ts +1 -1
- package/src/core/generator/index.ts +2 -0
- package/src/core/generator/openapi-reader.ts +18 -0
- package/src/core/generator/serializer.ts +11 -2
- package/src/core/generator/suite-generator.ts +106 -7
- package/src/core/meta/types.ts +0 -2
- package/src/core/parser/schema.ts +3 -1
- package/src/core/parser/types.ts +10 -1
- package/src/core/parser/variables.ts +90 -2
- package/src/core/parser/yaml-parser.ts +50 -1
- package/src/core/probe/method-probe.ts +197 -0
- package/src/core/probe/negative-probe.ts +657 -0
- package/src/core/reporter/console.ts +29 -3
- package/src/core/reporter/index.ts +2 -2
- package/src/core/reporter/json.ts +5 -2
- package/src/core/runner/assertions.ts +4 -1
- package/src/core/runner/executor.ts +132 -37
- package/src/core/runner/http-client.ts +40 -5
- package/src/core/runner/rate-limiter.ts +131 -0
- package/src/core/setup-api.ts +4 -1
- package/src/core/workspace/root.ts +94 -0
- package/src/db/schema.ts +4 -1
- package/src/web/routes/api.ts +80 -0
- package/src/web/routes/dashboard.ts +15 -0
- package/src/web/static/style.css +290 -0
- 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: (
|
|
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
|
-
|
|
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
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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 (
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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:");
|