@kirrosh/zond 0.11.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirrosh/zond",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "API testing platform — define tests in YAML, run from CLI or WebUI, generate from OpenAPI specs",
5
5
  "license": "MIT",
6
6
  "module": "index.ts",
@@ -12,4 +12,4 @@ export { compressEndpointsWithSchemas, buildGenerationGuide } from "./guide-buil
12
12
  export type { GuideOptions } from "./guide-builder.ts";
13
13
  export type { EndpointWarning, WarningCode } from "./endpoint-warnings.ts";
14
14
  export type { EndpointInfo, ResponseInfo, GenerateOptions, SecuritySchemeInfo, CrudGroup } from "./types.ts";
15
- export { generateSuites, generateStep, detectCrudGroups, generateCrudSuite } from "./suite-generator.ts";
15
+ export { generateSuites, generateStep, detectCrudGroups, generateCrudSuite, findUnresolvedVars } from "./suite-generator.ts";
@@ -292,6 +292,31 @@ export function generateCrudSuite(
292
292
  return suite;
293
293
  }
294
294
 
295
+ /** Find unresolved template variables in a suite (excluding known globals and captured vars) */
296
+ export function findUnresolvedVars(suite: RawSuite): string[] {
297
+ const KNOWN = new Set(["base_url", "auth_token", "api_key"]);
298
+ const captured = new Set<string>();
299
+ for (const step of suite.tests) {
300
+ if (step.expect?.body) {
301
+ for (const val of Object.values(step.expect.body)) {
302
+ if (val && typeof val === "object" && "capture" in val) captured.add((val as any).capture);
303
+ }
304
+ }
305
+ }
306
+ const vars = new Set<string>();
307
+ const scan = (obj: unknown) => {
308
+ if (typeof obj === "string") {
309
+ for (const m of obj.matchAll(/\{\{([^$}][^}]*)\}\}/g)) {
310
+ if (!KNOWN.has(m[1]) && !captured.has(m[1])) vars.add(m[1]);
311
+ }
312
+ } else if (obj && typeof obj === "object") {
313
+ for (const v of Object.values(obj)) scan(v);
314
+ }
315
+ };
316
+ scan(suite);
317
+ return [...vars];
318
+ }
319
+
295
320
  /** Main entry point: generate all suites from endpoints */
296
321
  export function generateSuites(opts: {
297
322
  endpoints: EndpointInfo[];
@@ -17,6 +17,7 @@ export interface ExecuteRunOptions {
17
17
  tag?: string[];
18
18
  envVars?: Record<string, string>;
19
19
  dryRun?: boolean;
20
+ rerunFilter?: Set<string>; // "suite_name::test_name" keys to rerun
20
21
  }
21
22
 
22
23
  export interface ExecuteRunResult {
@@ -40,6 +41,17 @@ export async function executeRun(options: ExecuteRunOptions): Promise<ExecuteRun
40
41
  }
41
42
  }
42
43
 
44
+ // Rerun filter: keep only specific failed tests
45
+ if (options.rerunFilter && options.rerunFilter.size > 0) {
46
+ for (const suite of suites) {
47
+ suite.tests = suite.tests.filter(t => options.rerunFilter!.has(`${suite.name}::${t.name}`));
48
+ }
49
+ suites = suites.filter(s => s.tests.length > 0);
50
+ if (suites.length === 0) {
51
+ throw new Error("No matching tests found for rerun filter");
52
+ }
53
+ }
54
+
43
55
  // Safe mode: filter to GET-only tests
44
56
  if (safe) {
45
57
  for (const suite of suites) {
@@ -14,7 +14,7 @@ function toYaml(vars: Record<string, string>): string {
14
14
  }
15
15
 
16
16
  export interface SetupApiOptions {
17
- name: string;
17
+ name?: string;
18
18
  spec?: string;
19
19
  dir?: string;
20
20
  envVars?: Record<string, string>;
@@ -29,13 +29,49 @@ export interface SetupApiResult {
29
29
  testPath: string;
30
30
  baseUrl: string;
31
31
  specEndpoints: number;
32
+ pathParams?: Record<string, string>;
32
33
  }
33
34
 
34
35
  export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult> {
35
- const { name, spec, dbPath } = options;
36
+ const { spec, dbPath } = options;
36
37
 
37
38
  getDb(dbPath);
38
39
 
40
+ // Try to load and validate spec, extract base_url
41
+ let openapiSpec: string | null = null;
42
+ let baseUrl = "";
43
+ let endpointCount = 0;
44
+ const pathParams = new Map<string, string>();
45
+ let specTitle: string | undefined;
46
+ if (spec) {
47
+ const doc = await readOpenApiSpec(spec);
48
+ openapiSpec = spec;
49
+ if ((doc as any).servers?.[0]?.url) {
50
+ baseUrl = (doc as any).servers[0].url;
51
+ }
52
+ specTitle = (doc as any).info?.title;
53
+ const endpoints = extractEndpoints(doc);
54
+ endpointCount = endpoints.length;
55
+
56
+ // Collect unique path parameters with default values
57
+ for (const ep of endpoints) {
58
+ for (const param of (ep.parameters ?? []).filter(p => p.in === "path")) {
59
+ if (pathParams.has(param.name)) continue;
60
+ const schema = param.schema as any;
61
+ if (param.example !== undefined) pathParams.set(param.name, String(param.example));
62
+ else if (schema?.example !== undefined) pathParams.set(param.name, String(schema.example));
63
+ else if (schema?.type === "integer" || schema?.type === "number") pathParams.set(param.name, "1");
64
+ else pathParams.set(param.name, "example");
65
+ }
66
+ }
67
+ }
68
+
69
+ // Derive name: explicit > spec title > filename
70
+ const name = options.name
71
+ ?? specTitle?.replace(/[^a-zA-Z0-9_\-\.]/g, "-").toLowerCase()
72
+ ?? spec?.split(/[/\\]/).pop()?.replace(/\.\w+$/, "")
73
+ ?? "api";
74
+
39
75
  // Validate name uniqueness (or force-replace)
40
76
  const existing = findCollectionByNameOrId(name);
41
77
  if (existing) {
@@ -54,22 +90,13 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
54
90
  // Create directories
55
91
  mkdirSync(testPath, { recursive: true });
56
92
 
57
- // Try to load and validate spec, extract base_url
58
- let openapiSpec: string | null = null;
59
- let baseUrl = "";
60
- let endpointCount = 0;
61
- if (spec) {
62
- const doc = await readOpenApiSpec(spec);
63
- openapiSpec = spec;
64
- if ((doc as any).servers?.[0]?.url) {
65
- baseUrl = (doc as any).servers[0].url;
66
- }
67
- endpointCount = extractEndpoints(doc).length;
68
- }
69
-
70
93
  // Build environment variables
71
94
  const envVars: Record<string, string> = {};
72
95
  if (baseUrl) envVars.base_url = baseUrl;
96
+ // Add path parameter defaults (before user overrides)
97
+ for (const [k, v] of pathParams) {
98
+ if (!(k in envVars)) envVars[k] = v;
99
+ }
73
100
  if (options.envVars) {
74
101
  Object.assign(envVars, options.envVars);
75
102
  }
@@ -102,6 +129,8 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
102
129
  openapi_spec: openapiSpec ?? undefined,
103
130
  });
104
131
 
132
+ const pathParamsObj = pathParams.size > 0 ? Object.fromEntries(pathParams) : undefined;
133
+
105
134
  return {
106
135
  created: true,
107
136
  collectionId,
@@ -109,5 +138,6 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
109
138
  testPath: normalizedTestPath,
110
139
  baseUrl,
111
140
  specEndpoints: endpointCount,
141
+ ...(pathParamsObj ? { pathParams: pathParamsObj } : {}),
112
142
  };
113
143
  }
@@ -9,6 +9,7 @@ import {
9
9
  filterUncoveredEndpoints,
10
10
  serializeSuite,
11
11
  generateSuites,
12
+ findUnresolvedVars,
12
13
  } from "../../core/generator/index.ts";
13
14
  import { compressEndpointsWithSchemas, buildGenerationGuide } from "../../core/generator/guide-builder.ts";
14
15
  import { planChunks, filterByTag } from "../../core/generator/chunker.ts";
@@ -130,10 +131,18 @@ export function registerGenerateAndSaveTool(server: McpServer) {
130
131
  });
131
132
  }
132
133
 
134
+ const warnings: string[] = [];
135
+ for (const suite of suites) {
136
+ const unresolved = findUnresolvedVars(suite);
137
+ if (unresolved.length > 0)
138
+ warnings.push(`${suite.fileStem ?? suite.name}.yaml: unresolved [${unresolved.join(", ")}]`);
139
+ }
140
+
133
141
  const response: Record<string, unknown> = {
134
142
  mode: "generate",
135
143
  suitesGenerated: suites.length,
136
144
  files,
145
+ ...(warnings.length > 0 ? { warnings } : {}),
137
146
  hint: files.some(f => !f.saved)
138
147
  ? "Some files were not saved (already exist?). Use overwrite: true to replace."
139
148
  : "Files saved. Run run_tests to verify. Use mode: 'guide' for LLM-crafted tests with more detail.",
@@ -16,6 +16,27 @@ function parseBodySafe(raw: string | null | undefined): unknown {
16
16
  }
17
17
  }
18
18
 
19
+ const USEFUL_HEADERS = new Set([
20
+ "content-type", "content-length", "location", "retry-after",
21
+ "www-authenticate", "allow",
22
+ ]);
23
+ const USEFUL_PREFIXES = ["x-", "ratelimit"];
24
+
25
+ function filterHeaders(raw: string | null | undefined): Record<string, string> | undefined {
26
+ if (!raw) return undefined;
27
+ try {
28
+ const h = JSON.parse(raw) as Record<string, string>;
29
+ const out: Record<string, string> = {};
30
+ for (const [k, v] of Object.entries(h)) {
31
+ const l = k.toLowerCase();
32
+ if (USEFUL_HEADERS.has(l) || USEFUL_PREFIXES.some(p => l.startsWith(p))) {
33
+ out[k] = v;
34
+ }
35
+ }
36
+ return Object.keys(out).length > 0 ? out : undefined;
37
+ } catch { return undefined; }
38
+ }
39
+
19
40
  export function registerQueryDbTool(server: McpServer, dbPath?: string) {
20
41
  server.registerTool("query_db", {
21
42
  description: TOOL_DESCRIPTIONS.query_db,
@@ -137,9 +158,7 @@ export function registerQueryDbTool(server: McpServer, dbPath?: string) {
137
158
  ...(hint ? { hint } : {}),
138
159
  ...(sHint ? { schema_hint: sHint } : {}),
139
160
  response_body: parseBodySafe(r.response_body),
140
- response_headers: r.response_headers
141
- ? JSON.parse(r.response_headers)
142
- : undefined,
161
+ response_headers: filterHeaders(r.response_headers),
143
162
  assertions: r.assertions,
144
163
  duration_ms: r.duration_ms,
145
164
  };
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { executeRun } from "../../core/runner/execute-run.ts";
4
+ import { getDb } from "../../db/schema.ts";
5
+ import { getResultsByRunId } from "../../db/queries.ts";
4
6
  import { TOOL_DESCRIPTIONS } from "../descriptions.js";
5
7
 
6
8
  export function registerRunTestsTool(server: McpServer, dbPath?: string) {
@@ -13,8 +15,24 @@ export function registerRunTestsTool(server: McpServer, dbPath?: string) {
13
15
  tag: z.optional(z.array(z.string())).describe("Filter suites by tag (OR logic)"),
14
16
  envVars: z.optional(z.record(z.string(), z.string())).describe("Environment variables to inject (override env file, e.g. {\"TOKEN\": \"xxx\"})"),
15
17
  dryRun: z.optional(z.boolean()).describe("Show requests without sending them (always exits 0)"),
18
+ rerunFrom: z.optional(z.number().int()).describe("Re-run only tests that failed/errored in this run ID"),
16
19
  },
17
- }, async ({ testPath, envName, safe, tag, envVars, dryRun }) => {
20
+ }, async ({ testPath, envName, safe, tag, envVars, dryRun, rerunFrom }) => {
21
+ // Build filter from previous failed run
22
+ let rerunFilter: Set<string> | undefined;
23
+ if (rerunFrom != null) {
24
+ getDb(dbPath);
25
+ const prevResults = getResultsByRunId(rerunFrom);
26
+ const failed = prevResults.filter(r => r.status === "fail" || r.status === "error");
27
+ if (failed.length === 0) {
28
+ return {
29
+ content: [{ type: "text" as const, text: JSON.stringify({ error: `Run ${rerunFrom} has no failures to rerun` }, null, 2) }],
30
+ isError: true,
31
+ };
32
+ }
33
+ rerunFilter = new Set(failed.map(r => `${r.suite_name}::${r.test_name}`));
34
+ }
35
+
18
36
  const { runId, results } = await executeRun({
19
37
  testPath,
20
38
  envName,
@@ -24,6 +42,7 @@ export function registerRunTestsTool(server: McpServer, dbPath?: string) {
24
42
  tag,
25
43
  envVars,
26
44
  dryRun,
45
+ rerunFilter,
27
46
  });
28
47
 
29
48
  const total = results.reduce((s, r) => s + r.total, 0);
@@ -55,9 +74,6 @@ export function registerRunTestsTool(server: McpServer, dbPath?: string) {
55
74
  );
56
75
  }
57
76
  }
58
- hints.push("Use manage_server(action: 'start') to launch the Web UI and view results visually in a browser at http://localhost:8080");
59
- hints.push("Ask the user if they want to set up CI/CD to run these tests automatically on push. If yes, use ci_init to generate a workflow and help them push to GitHub/GitLab.");
60
-
61
77
  const summary = {
62
78
  runId,
63
79
  total,
@@ -22,7 +22,7 @@ export function registerSetupApiTool(server: McpServer, dbPath?: string) {
22
22
  server.registerTool("setup_api", {
23
23
  description: TOOL_DESCRIPTIONS.setup_api,
24
24
  inputSchema: {
25
- name: z.string().describe("API name (e.g. 'petstore')"),
25
+ name: z.optional(z.string()).describe("API name (auto-detected from spec title if omitted)"),
26
26
  specPath: z.optional(z.string()).describe("Path or URL to OpenAPI spec"),
27
27
  dir: z.optional(z.string()).describe("Base directory (default: ./apis/<name>/)"),
28
28
  envVars: z.optional(z.string()).describe("Environment variables as JSON string (e.g. '{\"base_url\": \"...\", \"token\": \"...\"}')"),