@kirrosh/zond 0.21.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/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 +0 -1
- 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 +0 -1
- package/src/cli/commands/update.ts +1 -1
- package/src/cli/commands/use.ts +57 -0
- package/src/cli/index.ts +21 -609
- 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/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/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
|
@@ -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
|
+
}
|
|
@@ -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
|
|
@@ -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:");
|
|
@@ -28,6 +28,30 @@ function convertPathWithSeeds(path: string, ep: EndpointInfo): string {
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* For negative-smoke suites: replace path params with guaranteed-non-existent values.
|
|
33
|
+
* Picks a value that's syntactically valid for the param's type/format but very
|
|
34
|
+
* unlikely to match a real resource (zero-UUID, very large int, sentinel string).
|
|
35
|
+
*/
|
|
36
|
+
function getNonexistentSeed(schema: OpenAPIV3.SchemaObject | undefined): string {
|
|
37
|
+
if (!schema) return "nonexistent_id_zzzzzz";
|
|
38
|
+
if (schema.format === "uuid") return "00000000-0000-0000-0000-000000000000";
|
|
39
|
+
if (schema.type === "integer" || schema.type === "number") return "999999999";
|
|
40
|
+
return "nonexistent_id_zzzzzz";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function convertPathWithBadIds(path: string, ep: EndpointInfo): string {
|
|
44
|
+
return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
45
|
+
const param = ep.parameters.find(p => p.name === name && p.in === "path");
|
|
46
|
+
const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
47
|
+
return getNonexistentSeed(schema);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function endpointHasPathParams(ep: EndpointInfo): boolean {
|
|
52
|
+
return ep.parameters.some(p => p.in === "path");
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
function slugify(s: string): string {
|
|
32
56
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
33
57
|
}
|
|
@@ -360,9 +384,11 @@ export function generateCrudSuite(
|
|
|
360
384
|
});
|
|
361
385
|
}
|
|
362
386
|
|
|
387
|
+
// T44: cleanup must run even if earlier assertions failed (tainted captures)
|
|
363
388
|
const step: RawStep = {
|
|
364
389
|
name: group.delete.operationId ?? `Delete ${group.resource.replace(/s$/, "")}`,
|
|
365
390
|
DELETE: itemPath,
|
|
391
|
+
always: true,
|
|
366
392
|
expect: {
|
|
367
393
|
status: getExpectedStatus(group.delete),
|
|
368
394
|
},
|
|
@@ -372,11 +398,12 @@ export function generateCrudSuite(
|
|
|
372
398
|
}
|
|
373
399
|
tests.push(step);
|
|
374
400
|
|
|
375
|
-
// 5. Verify deleted
|
|
401
|
+
// 5. Verify deleted — also always, so we confirm cleanup happened
|
|
376
402
|
if (group.read) {
|
|
377
403
|
tests.push({
|
|
378
404
|
name: `Verify ${group.resource.replace(/s$/, "")} deleted`,
|
|
379
405
|
GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
|
|
406
|
+
always: true,
|
|
380
407
|
expect: {
|
|
381
408
|
status: 404,
|
|
382
409
|
},
|
|
@@ -384,9 +411,13 @@ export function generateCrudSuite(
|
|
|
384
411
|
}
|
|
385
412
|
}
|
|
386
413
|
|
|
414
|
+
// T28: classify by cleanup behavior. A suite that owns a DELETE leaves the API
|
|
415
|
+
// in its starting state (ephemeral); without DELETE it leaves residual data.
|
|
416
|
+
const cleanupTag = group.delete ? "ephemeral" : "persistent-write";
|
|
417
|
+
|
|
387
418
|
const suite: RawSuite = {
|
|
388
419
|
name: `${group.resource}-crud`,
|
|
389
|
-
tags: ["crud"],
|
|
420
|
+
tags: ["crud", cleanupTag],
|
|
390
421
|
fileStem: `crud-${slugify(group.resource)}`,
|
|
391
422
|
base_url: "{{base_url}}",
|
|
392
423
|
tests,
|
|
@@ -634,17 +665,20 @@ export function generateSuites(opts: {
|
|
|
634
665
|
for (const [tag, tagEndpoints] of byTag) {
|
|
635
666
|
const tagSlug = slugify(tag) || "api";
|
|
636
667
|
|
|
637
|
-
// GET endpoints →
|
|
668
|
+
// GET endpoints → split into paramless (regular smoke) and path-param (negative+positive smoke)
|
|
638
669
|
const getEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() === "GET");
|
|
639
|
-
|
|
640
|
-
|
|
670
|
+
const paramlessGets = getEndpoints.filter(ep => !endpointHasPathParams(ep));
|
|
671
|
+
const pathParamGets = getEndpoints.filter(ep => endpointHasPathParams(ep));
|
|
672
|
+
|
|
673
|
+
// Regular smoke: paramless GETs (e.g. list endpoints, health checks)
|
|
674
|
+
if (paramlessGets.length > 0) {
|
|
675
|
+
const tests = paramlessGets.map(ep => {
|
|
641
676
|
const step = generateStep(ep, securitySchemes);
|
|
642
|
-
// Replace path param placeholders with seed values so the suite runs out of the box
|
|
643
677
|
const seededPath = convertPathWithSeeds(ep.path, ep);
|
|
644
678
|
(step as any)[ep.method.toUpperCase()] = seededPath;
|
|
645
679
|
return step;
|
|
646
680
|
});
|
|
647
|
-
const headers = getSuiteHeaders(
|
|
681
|
+
const headers = getSuiteHeaders(paramlessGets, securitySchemes);
|
|
648
682
|
|
|
649
683
|
const suite: RawSuite = {
|
|
650
684
|
name: `${tagSlug}-smoke`,
|
|
@@ -666,6 +700,71 @@ export function generateSuites(opts: {
|
|
|
666
700
|
suites.push(suite);
|
|
667
701
|
}
|
|
668
702
|
|
|
703
|
+
// Negative smoke: path-param GETs with guaranteed-bad IDs, expect 400/404/422
|
|
704
|
+
if (pathParamGets.length > 0) {
|
|
705
|
+
const tests = pathParamGets.map(ep => {
|
|
706
|
+
const step = generateStep(ep, securitySchemes);
|
|
707
|
+
(step as any)[ep.method.toUpperCase()] = convertPathWithBadIds(ep.path, ep);
|
|
708
|
+
// Negative path: resource doesn't exist. Drop body assertions (response shape varies).
|
|
709
|
+
step.expect = { status: [400, 404, 422] };
|
|
710
|
+
return step;
|
|
711
|
+
});
|
|
712
|
+
const headers = getSuiteHeaders(pathParamGets, securitySchemes);
|
|
713
|
+
|
|
714
|
+
const suite: RawSuite = {
|
|
715
|
+
name: `${tagSlug}-smoke-negative`,
|
|
716
|
+
tags: ["smoke", "negative"],
|
|
717
|
+
fileStem: `smoke-${tagSlug}-negative`,
|
|
718
|
+
base_url: "{{base_url}}",
|
|
719
|
+
tests,
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
if (headers) {
|
|
723
|
+
suite.headers = headers;
|
|
724
|
+
for (const t of tests) {
|
|
725
|
+
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
726
|
+
delete (t as any).headers;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
suites.push(suite);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Positive smoke: path-param GETs with {{var}} placeholders + skip_if for unset env
|
|
735
|
+
if (pathParamGets.length > 0) {
|
|
736
|
+
const tests = pathParamGets.map(ep => {
|
|
737
|
+
const step = generateStep(ep, securitySchemes);
|
|
738
|
+
// Path stays as {{param}} so user-provided env values flow in
|
|
739
|
+
// Pick the first path param for skip_if guard (the resource ID)
|
|
740
|
+
const firstPathParam = ep.parameters.find(p => p.in === "path");
|
|
741
|
+
if (firstPathParam) {
|
|
742
|
+
step.skip_if = `{{${firstPathParam.name}}} ==`;
|
|
743
|
+
}
|
|
744
|
+
return step;
|
|
745
|
+
});
|
|
746
|
+
const headers = getSuiteHeaders(pathParamGets, securitySchemes);
|
|
747
|
+
|
|
748
|
+
const suite: RawSuite = {
|
|
749
|
+
name: `${tagSlug}-smoke-positive`,
|
|
750
|
+
tags: ["smoke", "positive", "needs-id"],
|
|
751
|
+
fileStem: `smoke-${tagSlug}-positive`,
|
|
752
|
+
base_url: "{{base_url}}",
|
|
753
|
+
tests,
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
if (headers) {
|
|
757
|
+
suite.headers = headers;
|
|
758
|
+
for (const t of tests) {
|
|
759
|
+
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
760
|
+
delete (t as any).headers;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
suites.push(suite);
|
|
766
|
+
}
|
|
767
|
+
|
|
669
768
|
// Non-GET endpoints: split reset/system endpoints out of smoke-unsafe
|
|
670
769
|
const nonGetEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() !== "GET");
|
|
671
770
|
const resetEndpoints = nonGetEndpoints.filter(ep => RESET_PATH_RE.test(ep.path));
|
package/src/core/meta/types.ts
CHANGED
|
@@ -12,8 +12,6 @@ export interface ZondMeta {
|
|
|
12
12
|
zondVersion: string;
|
|
13
13
|
/** ISO timestamp of last sync/generate */
|
|
14
14
|
lastSyncedAt: string;
|
|
15
|
-
/** Spec URL or file path used for last generation */
|
|
16
|
-
specUrl: string;
|
|
17
15
|
/** SHA-256 hex of spec content at time of last generation */
|
|
18
16
|
specHash: string;
|
|
19
17
|
/** Per-file metadata, keyed by filename (e.g. "smoke-users.yaml") */
|
|
@@ -92,7 +92,7 @@ const AssertionRuleSchemaInner: z.ZodType<AssertionRule> = z.preprocess(
|
|
|
92
92
|
},
|
|
93
93
|
z.object({
|
|
94
94
|
capture: z.string().optional(),
|
|
95
|
-
type: z.enum(["string", "integer", "number", "boolean", "array", "object"]).optional(),
|
|
95
|
+
type: z.enum(["string", "integer", "number", "boolean", "array", "object", "null"]).optional(),
|
|
96
96
|
equals: z.unknown().optional(),
|
|
97
97
|
not_equals: z.unknown().optional(),
|
|
98
98
|
contains: z.string().optional(),
|
|
@@ -184,6 +184,7 @@ const TestStepSchema: z.ZodType<TestStep> = z.preprocess(
|
|
|
184
184
|
retry_until: RetryUntilSchema.optional(),
|
|
185
185
|
for_each: ForEachSchema.optional(),
|
|
186
186
|
set: z.record(z.string(), z.unknown()).optional(),
|
|
187
|
+
always: z.boolean().optional(),
|
|
187
188
|
}),
|
|
188
189
|
) as z.ZodType<TestStep>;
|
|
189
190
|
|
|
@@ -220,6 +221,7 @@ const TestSuiteSchema = z.preprocess(
|
|
|
220
221
|
tags: z.array(z.string()).optional(),
|
|
221
222
|
base_url: z.string().optional(),
|
|
222
223
|
headers: z.record(z.string(), z.string()).optional(),
|
|
224
|
+
parameterize: z.record(z.string(), z.array(z.unknown()).min(1)).optional(),
|
|
223
225
|
config: SuiteConfigSchema,
|
|
224
226
|
tests: z.array(TestStepSchema).min(1),
|
|
225
227
|
}),
|
package/src/core/parser/types.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
|
2
2
|
|
|
3
3
|
export interface AssertionRule {
|
|
4
4
|
capture?: string;
|
|
5
|
-
type?: "string" | "integer" | "number" | "boolean" | "array" | "object";
|
|
5
|
+
type?: "string" | "integer" | "number" | "boolean" | "array" | "object" | "null";
|
|
6
6
|
equals?: unknown;
|
|
7
7
|
not_equals?: unknown;
|
|
8
8
|
contains?: string;
|
|
@@ -63,6 +63,12 @@ export interface TestStep {
|
|
|
63
63
|
retry_until?: RetryUntil;
|
|
64
64
|
for_each?: ForEach;
|
|
65
65
|
set?: Record<string, unknown>;
|
|
66
|
+
/**
|
|
67
|
+
* Run this step even when prior steps in the suite have failed assertions
|
|
68
|
+
* (so their captures are "tainted"). Designed for cleanup steps. Still
|
|
69
|
+
* skips if a referenced capture is genuinely missing from a response.
|
|
70
|
+
*/
|
|
71
|
+
always?: boolean;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
export interface SuiteConfig {
|
|
@@ -81,6 +87,9 @@ export interface TestSuite {
|
|
|
81
87
|
tags?: string[];
|
|
82
88
|
base_url?: string;
|
|
83
89
|
headers?: Record<string, string>;
|
|
90
|
+
/** Cross-product parameterisation: each key contributes one variable
|
|
91
|
+
* binding per array entry. Suite body runs once per combination. */
|
|
92
|
+
parameterize?: Record<string, unknown[]>;
|
|
84
93
|
config: SuiteConfig;
|
|
85
94
|
tests: TestStep[];
|
|
86
95
|
/** Absolute path to the source file, set by yaml-parser */
|