@kirrosh/zond 0.14.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -112
- package/README.md +3 -10
- package/package.json +4 -4
- package/src/cli/commands/ci-init.ts +12 -1
- package/src/cli/commands/coverage.ts +21 -1
- package/src/cli/commands/db.ts +121 -0
- package/src/cli/commands/describe.ts +60 -0
- package/src/cli/commands/export.ts +144 -0
- package/src/cli/commands/generate.ts +158 -0
- package/src/cli/commands/guide.ts +127 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/request.ts +57 -0
- package/src/cli/commands/run.ts +74 -14
- package/src/cli/commands/serve.ts +62 -3
- package/src/cli/commands/sync.ts +240 -0
- package/src/cli/commands/validate.ts +18 -2
- package/src/cli/index.ts +258 -17
- package/src/cli/json-envelope.ts +19 -0
- package/src/core/diagnostics/db-analysis.ts +423 -0
- package/src/core/diagnostics/failure-hints.ts +40 -0
- package/src/core/exporter/postman.ts +963 -0
- package/src/core/generator/data-factory.ts +55 -9
- package/src/core/generator/describe.ts +250 -0
- package/src/core/generator/guide-builder.ts +20 -0
- package/src/core/generator/index.ts +1 -1
- package/src/core/generator/openapi-reader.ts +6 -0
- package/src/core/generator/serializer.ts +17 -2
- package/src/core/generator/suite-generator.ts +291 -29
- package/src/core/generator/types.ts +1 -0
- package/src/core/meta/meta-store.ts +78 -0
- package/src/core/meta/types.ts +21 -0
- package/src/core/parser/schema.ts +12 -2
- package/src/core/parser/types.ts +12 -1
- package/src/core/parser/variables.ts +3 -0
- package/src/core/parser/yaml-parser.ts +2 -1
- package/src/core/runner/assertions.ts +44 -20
- package/src/core/runner/execute-run.ts +31 -8
- package/src/core/runner/executor.ts +35 -8
- package/src/core/runner/http-client.ts +1 -1
- package/src/core/runner/send-request.ts +94 -0
- package/src/core/runner/types.ts +2 -0
- package/src/core/sync/spec-differ.ts +38 -0
- package/src/db/queries.ts +4 -2
- package/src/db/schema.ts +11 -3
- package/src/web/views/suites-tab.ts +1 -1
- package/src/cli/commands/mcp.ts +0 -16
- package/src/mcp/descriptions.ts +0 -71
- package/src/mcp/server.ts +0 -45
- package/src/mcp/tools/ci-init.ts +0 -54
- package/src/mcp/tools/coverage-analysis.ts +0 -141
- package/src/mcp/tools/describe-endpoint.ts +0 -242
- package/src/mcp/tools/generate-and-save.ts +0 -202
- package/src/mcp/tools/manage-server.ts +0 -86
- package/src/mcp/tools/query-db.ts +0 -300
- package/src/mcp/tools/run-tests.ts +0 -115
- package/src/mcp/tools/save-test-suite.ts +0 -218
- package/src/mcp/tools/send-request.ts +0 -97
- package/src/mcp/tools/set-work-dir.ts +0 -35
- package/src/mcp/tools/setup-api.ts +0 -88
|
@@ -33,15 +33,18 @@ 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);
|
|
39
42
|
|
|
40
43
|
case "integer":
|
|
41
|
-
return guessIntPlaceholder(propertyName);
|
|
44
|
+
return guessIntPlaceholder(propertyName, schema);
|
|
42
45
|
|
|
43
46
|
case "number":
|
|
44
|
-
return
|
|
47
|
+
return 29.99;
|
|
45
48
|
|
|
46
49
|
case "boolean":
|
|
47
50
|
return true;
|
|
@@ -81,11 +84,41 @@ 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"
|
|
115
|
+
if (schema.format === "date-time") return "2025-01-01T00:00:00Z";
|
|
116
|
+
if (schema.format === "date") return "2025-01-01";
|
|
117
|
+
if (schema.format === "uri" || schema.format === "url") return "https://example.com/test";
|
|
118
|
+
if (schema.format === "hostname") return "example.com";
|
|
119
|
+
if (schema.format === "ipv4") return "192.168.1.1";
|
|
120
|
+
if (schema.format === "ipv6") return "::1";
|
|
121
|
+
if (schema.format === "password") return "TestPass123!";
|
|
89
122
|
|
|
90
123
|
// Name-based heuristics
|
|
91
124
|
if (name) {
|
|
@@ -99,17 +132,30 @@ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string):
|
|
|
99
132
|
if (lower === "name" || lower.endsWith("_name") || lower.endsWith("Name")) {
|
|
100
133
|
return "{{$randomName}}";
|
|
101
134
|
}
|
|
135
|
+
if (lower === "url" || lower.endsWith("_url") || lower === "uri" || lower === "href" || lower === "website") {
|
|
136
|
+
return "https://example.com/test";
|
|
137
|
+
}
|
|
138
|
+
if (lower === "password" || lower.endsWith("_password")) {
|
|
139
|
+
return "TestPass123!";
|
|
140
|
+
}
|
|
141
|
+
if (lower === "phone" || lower === "telephone" || lower.endsWith("_phone")) {
|
|
142
|
+
return "+1234567890";
|
|
143
|
+
}
|
|
102
144
|
}
|
|
103
145
|
|
|
104
146
|
return "{{$randomString}}";
|
|
105
147
|
}
|
|
106
148
|
|
|
107
|
-
function guessIntPlaceholder(name?: string): string {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
149
|
+
function guessIntPlaceholder(name?: string, schema?: OpenAPIV3.SchemaObject): number | string {
|
|
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;
|
|
113
159
|
}
|
|
114
160
|
return "{{$randomInt}}";
|
|
115
161
|
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
2
|
+
import { readOpenApiSpec } from "./openapi-reader.ts";
|
|
3
|
+
import { decycleSchema } from "./schema-utils.ts";
|
|
4
|
+
|
|
5
|
+
export interface DescribeEndpointResult {
|
|
6
|
+
method: string;
|
|
7
|
+
path: string;
|
|
8
|
+
operationId?: string;
|
|
9
|
+
summary?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
tags?: string[];
|
|
12
|
+
deprecated: boolean;
|
|
13
|
+
security: string[];
|
|
14
|
+
parameters: Record<string, object[]>;
|
|
15
|
+
requestBody?: object;
|
|
16
|
+
responses: Record<string, object>;
|
|
17
|
+
testSnippet: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CompactEndpoint {
|
|
21
|
+
method: string;
|
|
22
|
+
path: string;
|
|
23
|
+
operationId?: string;
|
|
24
|
+
summary?: string;
|
|
25
|
+
deprecated: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function generateTestSnippet(params: {
|
|
29
|
+
method: string;
|
|
30
|
+
path: string;
|
|
31
|
+
operationId?: string;
|
|
32
|
+
pathParams: string[];
|
|
33
|
+
queryParams: Array<{ name: string; required?: boolean }>;
|
|
34
|
+
requestBody?: { required?: boolean; schema?: OpenAPIV3.SchemaObject };
|
|
35
|
+
hasSecurity: boolean;
|
|
36
|
+
successStatus: string;
|
|
37
|
+
}): string {
|
|
38
|
+
const { method, path, operationId, queryParams, requestBody, hasSecurity, successStatus } = params;
|
|
39
|
+
|
|
40
|
+
const urlPath = path.replace(/\{([^}]+)\}/g, (_, name) => `{{${name}}}`);
|
|
41
|
+
const url = `{{base_url}}${urlPath}`;
|
|
42
|
+
|
|
43
|
+
const lines: string[] = [];
|
|
44
|
+
const testName = operationId ?? `${method} ${path}`;
|
|
45
|
+
lines.push(`- name: "${testName}"`);
|
|
46
|
+
lines.push(` ${method}: "${url}"`);
|
|
47
|
+
|
|
48
|
+
if (hasSecurity) {
|
|
49
|
+
lines.push(` headers:`);
|
|
50
|
+
lines.push(` Authorization: "Bearer {{auth_token}}"`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const requiredQuery = queryParams.filter(p => p.required);
|
|
54
|
+
if (requiredQuery.length > 0) {
|
|
55
|
+
lines.push(` query:`);
|
|
56
|
+
for (const p of requiredQuery) {
|
|
57
|
+
lines.push(` ${p.name}: "{{${p.name}}}"`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (requestBody && ["POST", "PUT", "PATCH"].includes(method)) {
|
|
62
|
+
const schema = requestBody.schema as OpenAPIV3.SchemaObject | undefined;
|
|
63
|
+
const required = Array.isArray(schema?.required) ? schema.required : [];
|
|
64
|
+
const properties = schema?.properties as Record<string, OpenAPIV3.SchemaObject> | undefined;
|
|
65
|
+
if (properties && Object.keys(properties).length > 0) {
|
|
66
|
+
lines.push(` json:`);
|
|
67
|
+
for (const [propName, propSchema] of Object.entries(properties)) {
|
|
68
|
+
if (!required.includes(propName)) continue;
|
|
69
|
+
const type = (propSchema as OpenAPIV3.SchemaObject).type ?? "string";
|
|
70
|
+
const placeholder = type === "integer" || type === "number" ? 0 : type === "boolean" ? false : `"{{${propName}}}"`;
|
|
71
|
+
lines.push(` ${propName}: ${placeholder}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
lines.push(` expect:`);
|
|
77
|
+
lines.push(` status: ${successStatus}`);
|
|
78
|
+
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function describeEndpoint(
|
|
83
|
+
specPath: string,
|
|
84
|
+
method: string,
|
|
85
|
+
endpointPath: string,
|
|
86
|
+
options?: { insecure?: boolean },
|
|
87
|
+
): Promise<DescribeEndpointResult> {
|
|
88
|
+
const doc = await readOpenApiSpec(specPath, options) as OpenAPIV3.Document;
|
|
89
|
+
|
|
90
|
+
const methodLower = method.toLowerCase() as OpenAPIV3.HttpMethods;
|
|
91
|
+
const normalizedPath = endpointPath.replace(/\/+$/, "") || "/";
|
|
92
|
+
|
|
93
|
+
let operation: OpenAPIV3.OperationObject | undefined;
|
|
94
|
+
let resolvedPath = normalizedPath;
|
|
95
|
+
const paths = doc.paths ?? {};
|
|
96
|
+
|
|
97
|
+
if (paths[normalizedPath]?.[methodLower]) {
|
|
98
|
+
operation = paths[normalizedPath][methodLower] as OpenAPIV3.OperationObject;
|
|
99
|
+
} else {
|
|
100
|
+
const lowerTarget = normalizedPath.toLowerCase();
|
|
101
|
+
for (const [p, pathItem] of Object.entries(paths)) {
|
|
102
|
+
if (p.toLowerCase() === lowerTarget && pathItem?.[methodLower]) {
|
|
103
|
+
operation = pathItem[methodLower] as OpenAPIV3.OperationObject;
|
|
104
|
+
resolvedPath = p;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!operation) {
|
|
111
|
+
const available = Object.entries(paths).flatMap(([p, pathItem]) =>
|
|
112
|
+
Object.keys(pathItem ?? {})
|
|
113
|
+
.filter(k => ["get","post","put","patch","delete","head","options","trace"].includes(k))
|
|
114
|
+
.map(k => `${k.toUpperCase()} ${p}`)
|
|
115
|
+
).sort();
|
|
116
|
+
throw new Error(`Endpoint ${method.toUpperCase()} ${endpointPath} not found in spec. Available: ${available.join(", ")}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const pathItem = paths[resolvedPath] ?? {};
|
|
120
|
+
|
|
121
|
+
const pathLevelParams = (pathItem.parameters ?? []) as OpenAPIV3.ParameterObject[];
|
|
122
|
+
const opLevelParams = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[];
|
|
123
|
+
|
|
124
|
+
const paramMap = new Map<string, OpenAPIV3.ParameterObject>();
|
|
125
|
+
for (const p of pathLevelParams) paramMap.set(`${p.in}:${p.name}`, p);
|
|
126
|
+
for (const p of opLevelParams) paramMap.set(`${p.in}:${p.name}`, p);
|
|
127
|
+
|
|
128
|
+
const grouped: Record<string, object[]> = { path: [], query: [], header: [], cookie: [] };
|
|
129
|
+
for (const p of paramMap.values()) {
|
|
130
|
+
const loc = p.in in grouped ? p.in : "query";
|
|
131
|
+
const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
|
|
132
|
+
grouped[loc]!.push({
|
|
133
|
+
name: p.name,
|
|
134
|
+
required: p.required ?? false,
|
|
135
|
+
...(schema?.type ? { type: schema.type } : {}),
|
|
136
|
+
...(schema?.format ? { format: schema.format } : {}),
|
|
137
|
+
...(schema?.enum ? { enum: schema.enum } : {}),
|
|
138
|
+
...(schema?.default !== undefined ? { default: schema.default } : {}),
|
|
139
|
+
...(p.description ? { description: p.description } : {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let requestBody: object | undefined;
|
|
144
|
+
if (operation.requestBody) {
|
|
145
|
+
const rb = operation.requestBody as OpenAPIV3.RequestBodyObject;
|
|
146
|
+
const contentTypes = Object.keys(rb.content ?? {});
|
|
147
|
+
const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
|
|
148
|
+
const mediaObj = preferredCt ? rb.content[preferredCt] : undefined;
|
|
149
|
+
requestBody = {
|
|
150
|
+
required: rb.required ?? false,
|
|
151
|
+
...(preferredCt ? { contentType: preferredCt } : {}),
|
|
152
|
+
...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
|
|
153
|
+
...(rb.description ? { description: rb.description } : {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const responses: Record<string, object> = {};
|
|
158
|
+
for (const [statusCode, respObj] of Object.entries(operation.responses ?? {})) {
|
|
159
|
+
const resp = respObj as OpenAPIV3.ResponseObject;
|
|
160
|
+
const contentTypes = Object.keys(resp.content ?? {});
|
|
161
|
+
const preferredCt = contentTypes.find(ct => ct.includes("application/json")) ?? contentTypes[0];
|
|
162
|
+
const mediaObj = preferredCt ? resp.content?.[preferredCt] : undefined;
|
|
163
|
+
|
|
164
|
+
const headers: Record<string, object> = {};
|
|
165
|
+
for (const [hName, hObj] of Object.entries(resp.headers ?? {})) {
|
|
166
|
+
const h = hObj as OpenAPIV3.HeaderObject;
|
|
167
|
+
headers[hName] = {
|
|
168
|
+
...(h.description ? { description: h.description } : {}),
|
|
169
|
+
...(h.schema ? { schema: h.schema } : {}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
responses[statusCode] = {
|
|
174
|
+
description: resp.description,
|
|
175
|
+
headers,
|
|
176
|
+
...(preferredCt ? { contentType: preferredCt } : {}),
|
|
177
|
+
...(mediaObj?.schema ? { schema: mediaObj.schema } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const docSecurity = (doc.security ?? []) as OpenAPIV3.SecurityRequirementObject[];
|
|
182
|
+
const opSecurity = (operation.security ?? docSecurity) as OpenAPIV3.SecurityRequirementObject[];
|
|
183
|
+
const securityNames = [...new Set(opSecurity.flatMap(req => Object.keys(req)))];
|
|
184
|
+
|
|
185
|
+
const responseCodes = Object.keys(operation.responses ?? {});
|
|
186
|
+
const successStatus = responseCodes.find(c => c.startsWith("2")) ?? responseCodes[0] ?? "200";
|
|
187
|
+
|
|
188
|
+
const pathParamNames = [...paramMap.values()]
|
|
189
|
+
.filter(p => p.in === "path")
|
|
190
|
+
.map(p => p.name);
|
|
191
|
+
const queryParamsList = [...paramMap.values()]
|
|
192
|
+
.filter(p => p.in === "query")
|
|
193
|
+
.map(p => ({ name: p.name, required: p.required }));
|
|
194
|
+
const reqBodyForSnippet = requestBody
|
|
195
|
+
? { required: (operation.requestBody as OpenAPIV3.RequestBodyObject)?.required, schema: (requestBody as any).schema }
|
|
196
|
+
: undefined;
|
|
197
|
+
|
|
198
|
+
const testSnippet = generateTestSnippet({
|
|
199
|
+
method: method.toUpperCase(),
|
|
200
|
+
path: resolvedPath,
|
|
201
|
+
operationId: operation.operationId,
|
|
202
|
+
pathParams: pathParamNames,
|
|
203
|
+
queryParams: queryParamsList,
|
|
204
|
+
requestBody: reqBodyForSnippet,
|
|
205
|
+
hasSecurity: securityNames.length > 0,
|
|
206
|
+
successStatus,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result: DescribeEndpointResult = {
|
|
210
|
+
method: method.toUpperCase(),
|
|
211
|
+
path: resolvedPath,
|
|
212
|
+
...(operation.operationId ? { operationId: operation.operationId } : {}),
|
|
213
|
+
...(operation.summary ? { summary: operation.summary } : {}),
|
|
214
|
+
...(operation.description ? { description: operation.description } : {}),
|
|
215
|
+
...(operation.tags?.length ? { tags: operation.tags } : {}),
|
|
216
|
+
deprecated: operation.deprecated ?? false,
|
|
217
|
+
security: securityNames,
|
|
218
|
+
parameters: grouped,
|
|
219
|
+
...(requestBody ? { requestBody } : {}),
|
|
220
|
+
responses,
|
|
221
|
+
testSnippet,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return decycleSchema(result) as DescribeEndpointResult;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function describeCompact(
|
|
228
|
+
specPath: string,
|
|
229
|
+
options?: { insecure?: boolean },
|
|
230
|
+
): Promise<CompactEndpoint[]> {
|
|
231
|
+
const doc = await readOpenApiSpec(specPath, options) as OpenAPIV3.Document;
|
|
232
|
+
const paths = doc.paths ?? {};
|
|
233
|
+
const result: CompactEndpoint[] = [];
|
|
234
|
+
|
|
235
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
236
|
+
for (const method of ["get","post","put","patch","delete","head","options","trace"]) {
|
|
237
|
+
const op = (pathItem as any)?.[method] as OpenAPIV3.OperationObject | undefined;
|
|
238
|
+
if (!op) continue;
|
|
239
|
+
result.push({
|
|
240
|
+
method: method.toUpperCase(),
|
|
241
|
+
path,
|
|
242
|
+
...(op.operationId ? { operationId: op.operationId } : {}),
|
|
243
|
+
...(op.summary ? { summary: op.summary } : {}),
|
|
244
|
+
deprecated: op.deprecated ?? false,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
@@ -196,6 +196,26 @@ Use spec paths with \`{param}\` placeholders in the path for coverage to match:
|
|
|
196
196
|
- Spec says \`GET /products/{id}\` → write \`GET: /products/1\` (hardcode the value)
|
|
197
197
|
- Coverage scanner matches test paths against spec paths automatically
|
|
198
198
|
|
|
199
|
+
### Suite variable isolation — IMPORTANT
|
|
200
|
+
Each suite runs in its own variable scope. Captured variables (via \`capture:\`) do NOT propagate between suites.
|
|
201
|
+
If multiple suites need auth, each suite must either:
|
|
202
|
+
- Include its own login step with \`capture: auth_token\`
|
|
203
|
+
- Or use \`auth_token\` from \`.env.yaml\` (pre-configured, no capture needed)
|
|
204
|
+
|
|
205
|
+
Do NOT create a separate "setup" suite expecting other suites to use its captures.
|
|
206
|
+
|
|
207
|
+
### ETag / Conditional Requests
|
|
208
|
+
If-Match and If-None-Match require escaped quotes around the ETag value:
|
|
209
|
+
\`\`\`yaml
|
|
210
|
+
- name: Update with ETag
|
|
211
|
+
PUT: /items/{{item_id}}
|
|
212
|
+
headers:
|
|
213
|
+
If-Match: "\\"{{etag}}\\""
|
|
214
|
+
json: { name: "updated" }
|
|
215
|
+
expect:
|
|
216
|
+
status: 200
|
|
217
|
+
\`\`\`
|
|
218
|
+
|
|
199
219
|
### CRITICAL: Never mask server errors
|
|
200
220
|
- If an endpoint returns 500 — do NOT change expect to \`status: 500\`. Keep \`status: 200\` and let the test fail.
|
|
201
221
|
- A failing test = signal about an API bug. The goal is NOT "all tests green" but "tests reflect expected behavior".
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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}- {}`);
|