@kirrosh/zond 0.7.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 (102) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/package.json +53 -0
  5. package/src/bun-types.d.ts +5 -0
  6. package/src/cli/commands/add-api.ts +51 -0
  7. package/src/cli/commands/ai-generate.ts +106 -0
  8. package/src/cli/commands/chat.ts +43 -0
  9. package/src/cli/commands/ci-init.ts +163 -0
  10. package/src/cli/commands/collections.ts +41 -0
  11. package/src/cli/commands/compare.ts +129 -0
  12. package/src/cli/commands/coverage.ts +156 -0
  13. package/src/cli/commands/doctor.ts +127 -0
  14. package/src/cli/commands/init.ts +84 -0
  15. package/src/cli/commands/mcp.ts +16 -0
  16. package/src/cli/commands/run.ts +156 -0
  17. package/src/cli/commands/runs.ts +108 -0
  18. package/src/cli/commands/serve.ts +22 -0
  19. package/src/cli/commands/update.ts +142 -0
  20. package/src/cli/commands/validate.ts +18 -0
  21. package/src/cli/index.ts +529 -0
  22. package/src/cli/output.ts +24 -0
  23. package/src/cli/runtime.ts +7 -0
  24. package/src/core/agent/agent-loop.ts +116 -0
  25. package/src/core/agent/context-manager.ts +41 -0
  26. package/src/core/agent/system-prompt.ts +28 -0
  27. package/src/core/agent/tools/diagnose-failure.ts +51 -0
  28. package/src/core/agent/tools/explore-api.ts +40 -0
  29. package/src/core/agent/tools/index.ts +46 -0
  30. package/src/core/agent/tools/query-results.ts +40 -0
  31. package/src/core/agent/tools/run-tests.ts +38 -0
  32. package/src/core/agent/tools/send-request.ts +44 -0
  33. package/src/core/agent/tools/validate-tests.ts +23 -0
  34. package/src/core/agent/types.ts +22 -0
  35. package/src/core/diagnostics/failure-hints.ts +63 -0
  36. package/src/core/generator/ai/ai-generator.ts +61 -0
  37. package/src/core/generator/ai/llm-client.ts +159 -0
  38. package/src/core/generator/ai/output-parser.ts +307 -0
  39. package/src/core/generator/ai/prompt-builder.ts +153 -0
  40. package/src/core/generator/ai/types.ts +56 -0
  41. package/src/core/generator/chunker.ts +47 -0
  42. package/src/core/generator/coverage-scanner.ts +87 -0
  43. package/src/core/generator/data-factory.ts +115 -0
  44. package/src/core/generator/endpoint-warnings.ts +43 -0
  45. package/src/core/generator/index.ts +12 -0
  46. package/src/core/generator/openapi-reader.ts +143 -0
  47. package/src/core/generator/schema-utils.ts +52 -0
  48. package/src/core/generator/serializer.ts +189 -0
  49. package/src/core/generator/types.ts +48 -0
  50. package/src/core/parser/filter.ts +14 -0
  51. package/src/core/parser/index.ts +21 -0
  52. package/src/core/parser/schema.ts +175 -0
  53. package/src/core/parser/types.ts +52 -0
  54. package/src/core/parser/variables.ts +154 -0
  55. package/src/core/parser/yaml-parser.ts +85 -0
  56. package/src/core/reporter/console.ts +175 -0
  57. package/src/core/reporter/index.ts +23 -0
  58. package/src/core/reporter/json.ts +9 -0
  59. package/src/core/reporter/junit.ts +78 -0
  60. package/src/core/reporter/types.ts +12 -0
  61. package/src/core/runner/assertions.ts +173 -0
  62. package/src/core/runner/execute-run.ts +97 -0
  63. package/src/core/runner/executor.ts +183 -0
  64. package/src/core/runner/http-client.ts +69 -0
  65. package/src/core/runner/index.ts +12 -0
  66. package/src/core/runner/types.ts +48 -0
  67. package/src/core/setup-api.ts +113 -0
  68. package/src/core/utils.ts +9 -0
  69. package/src/db/queries.ts +774 -0
  70. package/src/db/schema.ts +159 -0
  71. package/src/mcp/descriptions.ts +88 -0
  72. package/src/mcp/server.ts +52 -0
  73. package/src/mcp/tools/ci-init.ts +54 -0
  74. package/src/mcp/tools/coverage-analysis.ts +141 -0
  75. package/src/mcp/tools/describe-endpoint.ts +241 -0
  76. package/src/mcp/tools/explore-api.ts +84 -0
  77. package/src/mcp/tools/generate-and-save.ts +129 -0
  78. package/src/mcp/tools/generate-missing-tests.ts +91 -0
  79. package/src/mcp/tools/generate-tests-guide.ts +391 -0
  80. package/src/mcp/tools/manage-server.ts +86 -0
  81. package/src/mcp/tools/query-db.ts +255 -0
  82. package/src/mcp/tools/run-tests.ts +71 -0
  83. package/src/mcp/tools/save-test-suite.ts +218 -0
  84. package/src/mcp/tools/send-request.ts +63 -0
  85. package/src/mcp/tools/set-work-dir.ts +35 -0
  86. package/src/mcp/tools/setup-api.ts +84 -0
  87. package/src/mcp/tools/validate-tests.ts +43 -0
  88. package/src/tui/chat-ui.ts +150 -0
  89. package/src/web/data/collection-state.ts +360 -0
  90. package/src/web/routes/api.ts +234 -0
  91. package/src/web/routes/dashboard.ts +313 -0
  92. package/src/web/routes/runs.ts +64 -0
  93. package/src/web/schemas.ts +121 -0
  94. package/src/web/server.ts +134 -0
  95. package/src/web/static/htmx.min.js +1 -0
  96. package/src/web/static/style.css +827 -0
  97. package/src/web/views/endpoints-tab.ts +170 -0
  98. package/src/web/views/health-strip.ts +92 -0
  99. package/src/web/views/layout.ts +48 -0
  100. package/src/web/views/results.ts +209 -0
  101. package/src/web/views/runs-tab.ts +126 -0
  102. package/src/web/views/suites-tab.ts +153 -0
@@ -0,0 +1,16 @@
1
+ import { startMcpServer } from "../../mcp/server.ts";
2
+ import { resolve } from "node:path";
3
+
4
+ export interface McpCommandOptions {
5
+ dbPath?: string;
6
+ dir?: string;
7
+ }
8
+
9
+ export async function mcpCommand(options: McpCommandOptions): Promise<number> {
10
+ if (options.dir) {
11
+ process.chdir(resolve(options.dir));
12
+ }
13
+ await startMcpServer({ dbPath: options.dbPath });
14
+ // Server runs until stdin closes — this promise never resolves during normal operation
15
+ return 0;
16
+ }
@@ -0,0 +1,156 @@
1
+ import { dirname } from "path";
2
+ import { stat } from "node:fs/promises";
3
+ import { parse } from "../../core/parser/yaml-parser.ts";
4
+ import { loadEnvironment } from "../../core/parser/variables.ts";
5
+ import { filterSuitesByTags } from "../../core/parser/filter.ts";
6
+ import { runSuite } from "../../core/runner/executor.ts";
7
+ import { getReporter } from "../../core/reporter/index.ts";
8
+ import type { ReporterName } from "../../core/reporter/types.ts";
9
+ import type { TestSuite } from "../../core/parser/types.ts";
10
+ import type { TestRunResult } from "../../core/runner/types.ts";
11
+ import { printError, printWarning } from "../output.ts";
12
+ import { getDb } from "../../db/schema.ts";
13
+ import { createRun, finalizeRun, saveResults, findCollectionByTestPath } from "../../db/queries.ts";
14
+
15
+ export interface RunOptions {
16
+ path: string;
17
+ env?: string;
18
+ report: ReporterName;
19
+ timeout?: number;
20
+ bail: boolean;
21
+ noDb?: boolean;
22
+ dbPath?: string;
23
+ authToken?: string;
24
+ safe?: boolean;
25
+ tag?: string[];
26
+ envVars?: string[];
27
+ dryRun?: boolean;
28
+ }
29
+
30
+ export async function runCommand(options: RunOptions): Promise<number> {
31
+ // 1. Parse test files
32
+ let suites: TestSuite[];
33
+ try {
34
+ suites = await parse(options.path);
35
+ } catch (err) {
36
+ printError(err instanceof Error ? err.message : String(err));
37
+ return 2;
38
+ }
39
+
40
+ if (suites.length === 0) {
41
+ printWarning(`No test files found in ${options.path}`);
42
+ return 0;
43
+ }
44
+
45
+ // 1b. Tag filter
46
+ if (options.tag && options.tag.length > 0) {
47
+ suites = filterSuitesByTags(suites, options.tag);
48
+ if (suites.length === 0) {
49
+ printWarning("No suites match the specified tags");
50
+ return 0;
51
+ }
52
+ }
53
+
54
+ // 1c. Safe mode: filter to GET-only tests
55
+ if (options.safe) {
56
+ for (const suite of suites) {
57
+ suite.tests = suite.tests.filter(t => t.method === "GET");
58
+ }
59
+ suites = suites.filter(s => s.tests.length > 0);
60
+ if (suites.length === 0) {
61
+ printWarning("No GET tests found. Nothing to run in safe mode.");
62
+ return 0;
63
+ }
64
+ }
65
+
66
+ // 2. Load environment (resolve collection for scoped envs)
67
+ // Use path itself as searchDir if it's a directory; dirname() on a dir path gives the parent
68
+ const pathStat = await stat(options.path).catch(() => null);
69
+ const searchDir = pathStat?.isDirectory() ? options.path : dirname(options.path);
70
+ let collectionForEnv: { id: number } | null = null;
71
+ if (!options.noDb) {
72
+ try {
73
+ getDb(options.dbPath);
74
+ collectionForEnv = findCollectionByTestPath(options.path);
75
+ } catch { /* DB not available — OK */ }
76
+ }
77
+
78
+ let env: Record<string, string> = {};
79
+ try {
80
+ env = await loadEnvironment(options.env, searchDir);
81
+ } catch (err) {
82
+ printError(`Failed to load environment: ${(err as Error).message}`);
83
+ return 2;
84
+ }
85
+
86
+ // Inject CLI auth token — overrides env file value
87
+ if (options.authToken) {
88
+ env.auth_token = options.authToken;
89
+ }
90
+
91
+ // Inject --env-var KEY=VALUE overrides (highest priority)
92
+ if (options.envVars && options.envVars.length > 0) {
93
+ for (const pair of options.envVars) {
94
+ const eqIdx = pair.indexOf("=");
95
+ if (eqIdx > 0) {
96
+ env[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
97
+ }
98
+ }
99
+ }
100
+
101
+ // Warn if --env was explicitly set but file was not found (empty env)
102
+ if (options.env && Object.keys(env).length === 0) {
103
+ printWarning(`Environment file .env.${options.env}.yaml not found in ${searchDir}`);
104
+ }
105
+
106
+ // 3. Apply timeout override
107
+ if (options.timeout !== undefined) {
108
+ for (const suite of suites) {
109
+ suite.config.timeout = options.timeout;
110
+ }
111
+ }
112
+
113
+ // 4. Run suites
114
+ const results: TestRunResult[] = [];
115
+ const dryRun = options.dryRun === true;
116
+ if (options.bail) {
117
+ // Sequential with bail at suite level
118
+ for (const suite of suites) {
119
+ const result = await runSuite(suite, env, dryRun);
120
+ results.push(result);
121
+ if (!dryRun && (result.failed > 0 || result.steps.some((s) => s.status === "error"))) {
122
+ break;
123
+ }
124
+ }
125
+ } else {
126
+ // Parallel
127
+ const all = await Promise.all(suites.map((suite) => runSuite(suite, env, dryRun)));
128
+ results.push(...all);
129
+ }
130
+
131
+ // 5. Report
132
+ const reporter = getReporter(options.report);
133
+ reporter.report(results);
134
+
135
+ // 6. Save to DB
136
+ if (!options.noDb) {
137
+ try {
138
+ getDb(options.dbPath);
139
+ const collection = findCollectionByTestPath(options.path);
140
+ const runId = createRun({
141
+ started_at: results[0]?.started_at ?? new Date().toISOString(),
142
+ environment: options.env,
143
+ collection_id: collection?.id,
144
+ });
145
+ finalizeRun(runId, results);
146
+ saveResults(runId, results);
147
+ } catch (err) {
148
+ printWarning(`Failed to save results to DB: ${(err as Error).message}`);
149
+ }
150
+ }
151
+
152
+ // 7. Exit code (always 0 in dry-run mode)
153
+ if (dryRun) return 0;
154
+ const hasFailures = results.some((r) => r.failed > 0 || r.steps.some((s) => s.status === "error"));
155
+ return hasFailures ? 1 : 0;
156
+ }
@@ -0,0 +1,108 @@
1
+ import { getDb } from "../../db/schema.ts";
2
+ import { listRuns, getRunById, getResultsByRunId } from "../../db/queries.ts";
3
+ import { printError } from "../output.ts";
4
+
5
+ export interface RunsOptions {
6
+ runId?: number;
7
+ limit?: number;
8
+ dbPath?: string;
9
+ }
10
+
11
+ const RESET = "\x1b[0m";
12
+ const GREEN = "\x1b[32m";
13
+ const RED = "\x1b[31m";
14
+ const YELLOW = "\x1b[33m";
15
+
16
+ function useColor(): boolean {
17
+ return process.stdout.isTTY ?? false;
18
+ }
19
+
20
+ function statusIcon(passed: number, failed: number): string {
21
+ const color = useColor();
22
+ if (failed === 0) return color ? `${GREEN}PASS${RESET}` : "PASS";
23
+ return color ? `${RED}FAIL${RESET}` : "FAIL";
24
+ }
25
+
26
+ export function runsCommand(options: RunsOptions): number {
27
+ const { runId, limit = 20, dbPath } = options;
28
+
29
+ try {
30
+ getDb(dbPath);
31
+ } catch (err) {
32
+ printError(`Failed to open database: ${(err as Error).message}`);
33
+ return 2;
34
+ }
35
+
36
+ if (runId !== undefined) {
37
+ return showRunDetail(runId);
38
+ }
39
+ return showRunList(limit);
40
+ }
41
+
42
+ function showRunList(limit: number): number {
43
+ const runs = listRuns(limit);
44
+
45
+ if (runs.length === 0) {
46
+ console.log("No runs found.");
47
+ return 0;
48
+ }
49
+
50
+ // Print table
51
+ const header = "ID STATUS TOTAL PASS FAIL ENV DURATION STARTED";
52
+ console.log(header);
53
+ console.log("-".repeat(header.length));
54
+
55
+ for (const run of runs) {
56
+ const status = statusIcon(run.passed, run.failed);
57
+ const env = (run.environment ?? "-").slice(0, 10).padEnd(10);
58
+ const duration = run.duration_ms != null ? `${run.duration_ms}ms` : "-";
59
+ const started = run.started_at.slice(0, 19).replace("T", " ");
60
+ console.log(
61
+ `${String(run.id).padEnd(6)} ${status.padEnd(useColor() ? 14 : 6)} ${String(run.total).padEnd(5)} ${String(run.passed).padEnd(4)} ${String(run.failed).padEnd(4)} ${env} ${duration.padEnd(8)} ${started}`,
62
+ );
63
+ }
64
+
65
+ return 0;
66
+ }
67
+
68
+ function showRunDetail(runId: number): number {
69
+ const run = getRunById(runId);
70
+ if (!run) {
71
+ printError(`Run #${runId} not found`);
72
+ return 1;
73
+ }
74
+
75
+ const color = useColor();
76
+
77
+ console.log(`Run #${run.id}`);
78
+ console.log(` Started: ${run.started_at}`);
79
+ if (run.finished_at) console.log(` Finished: ${run.finished_at}`);
80
+ if (run.environment) console.log(` Environment: ${run.environment}`);
81
+ if (run.duration_ms != null) console.log(` Duration: ${run.duration_ms}ms`);
82
+ console.log(` Total: ${run.total} Passed: ${run.passed} Failed: ${run.failed} Skipped: ${run.skipped}`);
83
+
84
+ const results = getResultsByRunId(runId);
85
+ if (results.length === 0) {
86
+ console.log("\nNo step results recorded.");
87
+ return 0;
88
+ }
89
+
90
+ console.log("\nSteps:");
91
+ for (const r of results) {
92
+ let statusStr: string;
93
+ if (r.status === "pass") {
94
+ statusStr = color ? `${GREEN}PASS${RESET}` : "PASS";
95
+ } else if (r.status === "fail" || r.status === "error") {
96
+ statusStr = color ? `${RED}${r.status.toUpperCase()}${RESET}` : r.status.toUpperCase();
97
+ } else {
98
+ statusStr = color ? `${YELLOW}SKIP${RESET}` : "SKIP";
99
+ }
100
+
101
+ console.log(` ${statusStr} ${r.test_name} (${r.duration_ms}ms)`);
102
+ if (r.error_message) {
103
+ console.log(` ${color ? RED : ""}${r.error_message}${color ? RESET : ""}`);
104
+ }
105
+ }
106
+
107
+ return 0;
108
+ }
@@ -0,0 +1,22 @@
1
+ import { startServer } from "../../web/server.ts";
2
+
3
+ export interface ServeOptions {
4
+ port?: number;
5
+ host?: string;
6
+ openapiSpec?: string;
7
+ testsDir?: string;
8
+ dbPath?: string;
9
+ watch?: boolean;
10
+ }
11
+
12
+ export async function serveCommand(options: ServeOptions): Promise<number> {
13
+ await startServer({
14
+ port: options.port,
15
+ host: options.host,
16
+ dbPath: options.dbPath,
17
+ dev: options.watch,
18
+ });
19
+
20
+ // Keep running — Bun.serve keeps the process alive
21
+ return 0;
22
+ }
@@ -0,0 +1,142 @@
1
+ import { tmpdir } from "os";
2
+ import { join } from "path";
3
+ import { existsSync, unlinkSync, renameSync, copyFileSync, chmodSync } from "fs";
4
+ import { VERSION } from "../index.ts";
5
+ import { isCompiledBinary } from "../runtime.ts";
6
+
7
+ export interface UpdateCommandOptions {
8
+ force?: boolean;
9
+ }
10
+
11
+ export function detectTarget(): { target: string; archive: "tar.gz" | "zip" } {
12
+ const platform = process.platform;
13
+ const arch = process.arch;
14
+
15
+ const os = platform === "win32" ? "win" : platform;
16
+ const archSuffix = arch === "arm64" ? "arm64" : "x64";
17
+ const target = `${os}-${archSuffix}`;
18
+ const archive = platform === "win32" ? "zip" : ("tar.gz" as const);
19
+
20
+ return { target, archive };
21
+ }
22
+
23
+ export function parseVersion(tag: string): string {
24
+ return tag.replace(/^v/, "");
25
+ }
26
+
27
+ export function compareVersions(a: string, b: string): number {
28
+ const pa = a.split(".").map(Number);
29
+ const pb = b.split(".").map(Number);
30
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
31
+ const na = pa[i] ?? 0;
32
+ const nb = pb[i] ?? 0;
33
+ if (na !== nb) return na - nb;
34
+ }
35
+ return 0;
36
+ }
37
+
38
+ export async function updateCommand(options: UpdateCommandOptions): Promise<number> {
39
+ if (!isCompiledBinary()) {
40
+ console.log("Running from source — use git pull to update.");
41
+ return 0;
42
+ }
43
+
44
+ console.log("Checking for updates...");
45
+
46
+ // Fetch latest release
47
+ const res = await fetch("https://api.github.com/repos/kirrosh/zond/releases/latest", {
48
+ headers: { "User-Agent": "zond-updater" },
49
+ });
50
+
51
+ if (!res.ok) {
52
+ console.error(`Failed to check for updates: HTTP ${res.status}`);
53
+ return 1;
54
+ }
55
+
56
+ const release = (await res.json()) as { tag_name: string };
57
+ const latestVersion = parseVersion(release.tag_name);
58
+ const currentVersion = VERSION;
59
+
60
+ if (!options.force && compareVersions(currentVersion, latestVersion) >= 0) {
61
+ console.log(`Already up to date (v${currentVersion}).`);
62
+ return 0;
63
+ }
64
+
65
+ console.log(`Updating v${currentVersion} → v${latestVersion}...`);
66
+
67
+ const { target, archive } = detectTarget();
68
+ const assetName = `zond-${target}.${archive}`;
69
+ const downloadUrl = `https://github.com/kirrosh/zond/releases/download/${release.tag_name}/${assetName}`;
70
+
71
+ // Download artifact
72
+ const dlRes = await fetch(downloadUrl);
73
+ if (!dlRes.ok) {
74
+ console.error(`Failed to download ${assetName}: HTTP ${dlRes.status}`);
75
+ return 1;
76
+ }
77
+
78
+ const tempDir = tmpdir();
79
+ const archivePath = join(tempDir, assetName);
80
+ const archiveBytes = new Uint8Array(await dlRes.arrayBuffer());
81
+ await Bun.write(archivePath, archiveBytes);
82
+
83
+ // Extract
84
+ const extractDir = join(tempDir, `zond-update-${Date.now()}`);
85
+ const mkdirResult = Bun.spawnSync(["mkdir", "-p", extractDir]);
86
+ if (mkdirResult.exitCode !== 0) {
87
+ // Fallback for Windows
88
+ const { mkdirSync } = await import("fs");
89
+ mkdirSync(extractDir, { recursive: true });
90
+ }
91
+
92
+ if (archive === "tar.gz") {
93
+ const tar = Bun.spawnSync(["tar", "-xzf", archivePath, "-C", extractDir]);
94
+ if (tar.exitCode !== 0) {
95
+ console.error("Failed to extract archive.");
96
+ return 1;
97
+ }
98
+ } else {
99
+ // Windows zip — use tar (Windows 10+ includes bsdtar)
100
+ const tar = Bun.spawnSync(["tar", "-xf", archivePath, "-C", extractDir]);
101
+ if (tar.exitCode !== 0) {
102
+ console.error("Failed to extract archive.");
103
+ return 1;
104
+ }
105
+ }
106
+
107
+ // Find extracted binary
108
+ const binaryName = process.platform === "win32" ? "zond.exe" : "zond";
109
+ const newBinary = join(extractDir, binaryName);
110
+
111
+ if (!existsSync(newBinary)) {
112
+ console.error(`Expected binary not found: ${newBinary}`);
113
+ return 1;
114
+ }
115
+
116
+ // Replace current binary
117
+ const currentBinary = process.execPath;
118
+
119
+ if (process.platform === "win32") {
120
+ // Windows: can't overwrite running exe — rename current to .old, copy new
121
+ const oldPath = currentBinary + ".old";
122
+ try {
123
+ if (existsSync(oldPath)) unlinkSync(oldPath);
124
+ } catch { /* ignore */ }
125
+ renameSync(currentBinary, oldPath);
126
+ copyFileSync(newBinary, currentBinary);
127
+ } else {
128
+ // Unix: rename new over current (atomic on same filesystem, but we copy across)
129
+ unlinkSync(currentBinary);
130
+ copyFileSync(newBinary, currentBinary);
131
+ chmodSync(currentBinary, 0o755);
132
+ }
133
+
134
+ // Cleanup
135
+ try {
136
+ unlinkSync(archivePath);
137
+ unlinkSync(newBinary);
138
+ } catch { /* best effort */ }
139
+
140
+ console.log(`Updated to v${latestVersion}.`);
141
+ return 0;
142
+ }
@@ -0,0 +1,18 @@
1
+ import { parse } from "../../core/parser/yaml-parser.ts";
2
+ import { printError, printSuccess } from "../output.ts";
3
+
4
+ export interface ValidateOptions {
5
+ path: string;
6
+ }
7
+
8
+ export async function validateCommand(options: ValidateOptions): Promise<number> {
9
+ try {
10
+ const suites = await parse(options.path);
11
+ const totalSteps = suites.reduce((sum, s) => sum + s.tests.length, 0);
12
+ printSuccess(`OK: ${suites.length} suite(s), ${totalSteps} test(s) validated successfully`);
13
+ return 0;
14
+ } catch (err) {
15
+ printError(err instanceof Error ? err.message : String(err));
16
+ return 2;
17
+ }
18
+ }