@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +132 -112
  2. package/README.md +3 -10
  3. package/package.json +4 -4
  4. package/src/cli/commands/ci-init.ts +12 -1
  5. package/src/cli/commands/coverage.ts +21 -1
  6. package/src/cli/commands/db.ts +121 -0
  7. package/src/cli/commands/describe.ts +60 -0
  8. package/src/cli/commands/export.ts +144 -0
  9. package/src/cli/commands/generate.ts +158 -0
  10. package/src/cli/commands/guide.ts +127 -0
  11. package/src/cli/commands/init.ts +57 -0
  12. package/src/cli/commands/request.ts +57 -0
  13. package/src/cli/commands/run.ts +74 -14
  14. package/src/cli/commands/serve.ts +62 -3
  15. package/src/cli/commands/sync.ts +240 -0
  16. package/src/cli/commands/validate.ts +18 -2
  17. package/src/cli/index.ts +258 -17
  18. package/src/cli/json-envelope.ts +19 -0
  19. package/src/core/diagnostics/db-analysis.ts +423 -0
  20. package/src/core/diagnostics/failure-hints.ts +40 -0
  21. package/src/core/exporter/postman.ts +963 -0
  22. package/src/core/generator/data-factory.ts +55 -9
  23. package/src/core/generator/describe.ts +250 -0
  24. package/src/core/generator/guide-builder.ts +20 -0
  25. package/src/core/generator/index.ts +1 -1
  26. package/src/core/generator/openapi-reader.ts +6 -0
  27. package/src/core/generator/serializer.ts +17 -2
  28. package/src/core/generator/suite-generator.ts +291 -29
  29. package/src/core/generator/types.ts +1 -0
  30. package/src/core/meta/meta-store.ts +78 -0
  31. package/src/core/meta/types.ts +21 -0
  32. package/src/core/parser/schema.ts +12 -2
  33. package/src/core/parser/types.ts +12 -1
  34. package/src/core/parser/variables.ts +3 -0
  35. package/src/core/parser/yaml-parser.ts +2 -1
  36. package/src/core/runner/assertions.ts +44 -20
  37. package/src/core/runner/execute-run.ts +31 -8
  38. package/src/core/runner/executor.ts +35 -8
  39. package/src/core/runner/http-client.ts +1 -1
  40. package/src/core/runner/send-request.ts +94 -0
  41. package/src/core/runner/types.ts +2 -0
  42. package/src/core/sync/spec-differ.ts +38 -0
  43. package/src/db/queries.ts +4 -2
  44. package/src/db/schema.ts +11 -3
  45. package/src/web/views/suites-tab.ts +1 -1
  46. package/src/cli/commands/mcp.ts +0 -16
  47. package/src/mcp/descriptions.ts +0 -71
  48. package/src/mcp/server.ts +0 -45
  49. package/src/mcp/tools/ci-init.ts +0 -54
  50. package/src/mcp/tools/coverage-analysis.ts +0 -141
  51. package/src/mcp/tools/describe-endpoint.ts +0 -242
  52. package/src/mcp/tools/generate-and-save.ts +0 -202
  53. package/src/mcp/tools/manage-server.ts +0 -86
  54. package/src/mcp/tools/query-db.ts +0 -300
  55. package/src/mcp/tools/run-tests.ts +0 -115
  56. package/src/mcp/tools/save-test-suite.ts +0 -218
  57. package/src/mcp/tools/send-request.ts +0 -97
  58. package/src/mcp/tools/set-work-dir.ts +0 -35
  59. package/src/mcp/tools/setup-api.ts +0 -88
@@ -3,20 +3,79 @@ import { startServer } from "../../web/server.ts";
3
3
  export interface ServeOptions {
4
4
  port?: number;
5
5
  host?: string;
6
- openapiSpec?: string;
7
- testsDir?: string;
8
6
  dbPath?: string;
9
7
  watch?: boolean;
8
+ open?: boolean;
9
+ }
10
+
11
+ /** Kill any existing process listening on the given port (Windows + Unix) */
12
+ async function killPortHolder(port: number): Promise<void> {
13
+ const isWin = process.platform === "win32";
14
+ try {
15
+ if (isWin) {
16
+ // PowerShell: find PID on port, then kill it
17
+ const find = Bun.spawn(["powershell", "-NoProfile", "-Command",
18
+ `(Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue).OwningProcess`], {
19
+ stdout: "pipe", stderr: "ignore",
20
+ });
21
+ const out = await new Response(find.stdout).text();
22
+ const pids = [...new Set(out.trim().split(/\s+/).filter(s => /^\d+$/.test(s) && s !== "0"))];
23
+ for (const pid of pids) {
24
+ Bun.spawn(["powershell", "-NoProfile", "-Command",
25
+ `Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`], {
26
+ stdout: "ignore", stderr: "ignore",
27
+ });
28
+ }
29
+ if (pids.length > 0) {
30
+ // Give OS time to release the port
31
+ await Bun.sleep(500);
32
+ }
33
+ } else {
34
+ // Unix: lsof + kill
35
+ const find = Bun.spawn(["lsof", "-ti", `:${port}`], {
36
+ stdout: "pipe", stderr: "ignore",
37
+ });
38
+ const out = await new Response(find.stdout).text();
39
+ const pids = out.trim().split(/\s+/).filter(s => /^\d+$/.test(s));
40
+ for (const pid of pids) {
41
+ Bun.spawn(["kill", "-9", pid], { stdout: "ignore", stderr: "ignore" });
42
+ }
43
+ if (pids.length > 0) {
44
+ await Bun.sleep(300);
45
+ }
46
+ }
47
+ } catch {
48
+ // Best effort — if we can't kill, startServer will fail with port-in-use
49
+ }
10
50
  }
11
51
 
12
52
  export async function serveCommand(options: ServeOptions): Promise<number> {
53
+ const port = options.port ?? 8080;
54
+
55
+ // Kill previous instance on the same port
56
+ await killPortHolder(port);
57
+
13
58
  await startServer({
14
- port: options.port,
59
+ port,
15
60
  host: options.host,
16
61
  dbPath: options.dbPath,
17
62
  dev: options.watch,
18
63
  });
19
64
 
65
+ // Open browser if requested
66
+ if (options.open) {
67
+ const host = options.host === "0.0.0.0" || !options.host ? "localhost" : options.host;
68
+ const url = `http://${host}:${port}`;
69
+ try {
70
+ const cmd = process.platform === "win32" ? ["cmd", "/c", "start", url]
71
+ : process.platform === "darwin" ? ["open", url]
72
+ : ["xdg-open", url];
73
+ Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" });
74
+ } catch {
75
+ // Best effort — if browser can't open, server still runs
76
+ }
77
+ }
78
+
20
79
  // Keep running — Bun.serve keeps the process alive
21
80
  return 0;
22
81
  }
@@ -0,0 +1,240 @@
1
+ import { join } from "path";
2
+ import { mkdir } from "fs/promises";
3
+ import {
4
+ readOpenApiSpec,
5
+ extractEndpoints,
6
+ extractSecuritySchemes,
7
+ serializeSuite,
8
+ } from "../../core/generator/index.ts";
9
+ import { generateSuites } from "../../core/generator/suite-generator.ts";
10
+ import { filterByTag } from "../../core/generator/chunker.ts";
11
+ import { readMeta, writeMeta, hashSpec, buildFileMeta } from "../../core/meta/meta-store.ts";
12
+ import { diffEndpoints } from "../../core/sync/spec-differ.ts";
13
+ import { printError, printSuccess, printWarning } from "../output.ts";
14
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
15
+ import { version as ZOND_VERSION } from "../../../package.json";
16
+ import { getDb } from "../../db/schema.ts";
17
+ import { findCollectionByTestPath, updateCollection } from "../../db/queries.ts";
18
+
19
+ export interface SyncOptions {
20
+ specPath: string;
21
+ testsDir: string;
22
+ dryRun?: boolean;
23
+ tag?: string;
24
+ json?: boolean;
25
+ }
26
+
27
+ export async function syncCommand(options: SyncOptions): Promise<number> {
28
+ try {
29
+ // Load existing metadata
30
+ const meta = await readMeta(options.testsDir);
31
+ if (!meta) {
32
+ const msg =
33
+ "No .zond-meta.json found. Run `zond generate <spec> --output <dir>` first to initialize metadata.";
34
+ if (options.json) {
35
+ printJson(jsonError("sync", [msg]));
36
+ } else {
37
+ printError(msg);
38
+ }
39
+ return 2;
40
+ }
41
+
42
+ // Load current spec
43
+ const doc = await readOpenApiSpec(options.specPath);
44
+ const specContent = JSON.stringify(doc);
45
+ const currentHash = hashSpec(specContent);
46
+
47
+ if (currentHash === meta.specHash) {
48
+ const msg = "Spec unchanged — nothing to sync.";
49
+ if (options.json) {
50
+ printJson(jsonOk("sync", { newEndpoints: [], generatedFiles: [], removedKeys: [], specChanged: false }, [msg]));
51
+ } else {
52
+ console.log(msg);
53
+ }
54
+ return 0;
55
+ }
56
+
57
+ // Extract current endpoints
58
+ let currentEndpoints = extractEndpoints(doc);
59
+ const securitySchemes = extractSecuritySchemes(doc);
60
+
61
+ if (options.tag) {
62
+ currentEndpoints = filterByTag(currentEndpoints, options.tag);
63
+ }
64
+
65
+ // Collect all previously known endpoint keys from meta
66
+ const prevKeys = Object.values(meta.files).flatMap((f) => f.endpoints);
67
+
68
+ // Compute diff
69
+ const { newEndpoints, removedKeys } = diffEndpoints(prevKeys, currentEndpoints);
70
+
71
+ const warnings: string[] = [];
72
+
73
+ if (removedKeys.length > 0) {
74
+ for (const key of removedKeys) {
75
+ warnings.push(`Removed endpoint not deleted from tests (review manually): ${key}`);
76
+ }
77
+ }
78
+
79
+ if (newEndpoints.length === 0) {
80
+ const msg = "Spec changed (hash differs) but no new endpoints detected. Existing tests may need manual review.";
81
+ warnings.push(msg);
82
+ if (options.json) {
83
+ printJson(jsonOk("sync", {
84
+ newEndpoints: [],
85
+ removedKeys,
86
+ generatedFiles: [],
87
+ specChanged: true,
88
+ }, warnings));
89
+ } else {
90
+ console.log(msg);
91
+ for (const w of warnings) {
92
+ printWarning(w);
93
+ }
94
+ }
95
+ return 0;
96
+ }
97
+
98
+ // Generate suites for new endpoints only
99
+ const suites = generateSuites({ endpoints: newEndpoints, securitySchemes });
100
+
101
+ if (options.dryRun) {
102
+ const newEndpointKeys = newEndpoints.map((ep) => `${ep.method.toUpperCase()} ${ep.path}`);
103
+ const plannedFiles = suites.map((s) => ({
104
+ file: `${s.fileStem ?? s.name}.yaml`,
105
+ suite: s.name,
106
+ tests: s.tests.length,
107
+ }));
108
+
109
+ if (options.json) {
110
+ printJson(jsonOk("sync", {
111
+ dryRun: true,
112
+ newEndpoints: newEndpointKeys,
113
+ removedKeys,
114
+ plannedFiles,
115
+ specChanged: true,
116
+ }, warnings));
117
+ } else {
118
+ console.log(`[dry-run] Detected ${newEndpoints.length} new endpoint(s):`);
119
+ for (const ep of newEndpoints) {
120
+ console.log(` + ${ep.method.toUpperCase()} ${ep.path}`);
121
+ }
122
+ console.log(`\nWould generate ${suites.length} new suite file(s):`);
123
+ for (const f of plannedFiles) {
124
+ console.log(` ${f.file} (${f.tests} tests)`);
125
+ }
126
+ if (removedKeys.length > 0) {
127
+ console.log("\nRemoved endpoints (not deleted — review tests):");
128
+ for (const key of removedKeys) {
129
+ console.log(` - ${key}`);
130
+ }
131
+ }
132
+ console.log("\nNo files written (dry-run).");
133
+ }
134
+ return 0;
135
+ }
136
+
137
+ // Write new files (skip if file already exists)
138
+ await mkdir(options.testsDir, { recursive: true });
139
+
140
+ const generatedFiles: Array<{ file: string; suite: string; tests: number }> = [];
141
+ const skippedFiles: string[] = [];
142
+ const updatedMetaFiles: Record<string, import("../../core/meta/types.ts").FileMeta> = {};
143
+
144
+ for (const suite of suites) {
145
+ const fileName = `${suite.fileStem ?? suite.name}.yaml`;
146
+ const filePath = join(options.testsDir, fileName);
147
+ const existing = Bun.file(filePath);
148
+
149
+ if (await existing.exists()) {
150
+ skippedFiles.push(fileName);
151
+ warnings.push(`Skipped ${fileName} (already exists — add new endpoints manually)`);
152
+ continue;
153
+ }
154
+
155
+ const yaml = serializeSuite(suite);
156
+ await Bun.write(filePath, yaml);
157
+ generatedFiles.push({ file: filePath, suite: suite.name, tests: suite.tests.length });
158
+ updatedMetaFiles[fileName] = buildFileMeta(suite, ZOND_VERSION);
159
+ }
160
+
161
+ // Update metadata: merge new file entries, update hash and timestamp
162
+ await writeMeta(options.testsDir, {
163
+ zondVersion: ZOND_VERSION,
164
+ lastSyncedAt: new Date().toISOString(),
165
+ specUrl: options.specPath,
166
+ specHash: currentHash,
167
+ files: { ...meta.files, ...updatedMetaFiles },
168
+ });
169
+
170
+ // Sync DB collection if one is registered for this tests directory
171
+ try {
172
+ getDb();
173
+ const collection = findCollectionByTestPath(options.testsDir);
174
+ if (collection && collection.openapi_spec !== options.specPath) {
175
+ updateCollection(collection.id, { openapi_spec: options.specPath });
176
+ warnings.push(`Updated collection '${collection.name}' spec reference: ${collection.openapi_spec ?? "(none)"} → ${options.specPath}`);
177
+ }
178
+ } catch {
179
+ // DB unavailable (e.g. no zond.db yet) — not a fatal error for sync
180
+ }
181
+
182
+ const newEndpointKeys = newEndpoints.map((ep) => `${ep.method.toUpperCase()} ${ep.path}`);
183
+
184
+ if (options.json) {
185
+ printJson(jsonOk("sync", {
186
+ newEndpoints: newEndpointKeys,
187
+ removedKeys,
188
+ generatedFiles,
189
+ skippedFiles,
190
+ specChanged: true,
191
+ }, warnings));
192
+ } else {
193
+ console.log(`Spec changed. Detected ${newEndpoints.length} new endpoint(s):`);
194
+ for (const ep of newEndpoints) {
195
+ console.log(` + ${ep.method.toUpperCase()} ${ep.path}`);
196
+ }
197
+
198
+ if (generatedFiles.length > 0) {
199
+ console.log(`\nGenerated ${generatedFiles.length} new suite file(s):`);
200
+ for (const f of generatedFiles) {
201
+ console.log(` ${f.file} (${f.tests} tests)`);
202
+ }
203
+ }
204
+
205
+ if (skippedFiles.length > 0) {
206
+ console.log("\nSkipped (file exists, review manually):");
207
+ for (const f of skippedFiles) {
208
+ console.log(` ${f}`);
209
+ }
210
+ }
211
+
212
+ if (removedKeys.length > 0) {
213
+ console.log("\nRemoved endpoints (not deleted — review tests):");
214
+ for (const key of removedKeys) {
215
+ console.log(` - ${key}`);
216
+ }
217
+ }
218
+
219
+ if (generatedFiles.length > 0) {
220
+ printSuccess(`\nSync complete. ${generatedFiles.length} file(s) written.`);
221
+ } else {
222
+ printWarning("No new files written — all target files already exist.");
223
+ }
224
+
225
+ for (const w of warnings) {
226
+ printWarning(w);
227
+ }
228
+ }
229
+
230
+ return 0;
231
+ } catch (err) {
232
+ const message = err instanceof Error ? err.message : String(err);
233
+ if (options.json) {
234
+ printJson(jsonError("sync", [message]));
235
+ } else {
236
+ printError(message);
237
+ }
238
+ return 2;
239
+ }
240
+ }
@@ -1,18 +1,34 @@
1
1
  import { parse } from "../../core/parser/yaml-parser.ts";
2
2
  import { printError, printSuccess } from "../output.ts";
3
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
3
4
 
4
5
  export interface ValidateOptions {
5
6
  path: string;
7
+ json?: boolean;
6
8
  }
7
9
 
8
10
  export async function validateCommand(options: ValidateOptions): Promise<number> {
9
11
  try {
10
12
  const suites = await parse(options.path);
11
13
  const totalSteps = suites.reduce((sum, s) => sum + s.tests.length, 0);
12
- printSuccess(`OK: ${suites.length} suite(s), ${totalSteps} test(s) validated successfully`);
14
+ if (options.json) {
15
+ printJson(jsonOk("validate", {
16
+ files: suites.length,
17
+ suites: suites.length,
18
+ tests: totalSteps,
19
+ valid: true,
20
+ }));
21
+ } else {
22
+ printSuccess(`OK: ${suites.length} suite(s), ${totalSteps} test(s) validated successfully`);
23
+ }
13
24
  return 0;
14
25
  } catch (err) {
15
- printError(err instanceof Error ? err.message : String(err));
26
+ const message = err instanceof Error ? err.message : String(err);
27
+ if (options.json) {
28
+ printJson(jsonError("validate", [message]));
29
+ } else {
30
+ printError(message);
31
+ }
16
32
  return 2;
17
33
  }
18
34
  }