@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.
- package/CHANGELOG.md +130 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/package.json +53 -0
- package/src/bun-types.d.ts +5 -0
- package/src/cli/commands/add-api.ts +51 -0
- package/src/cli/commands/ai-generate.ts +106 -0
- package/src/cli/commands/chat.ts +43 -0
- package/src/cli/commands/ci-init.ts +163 -0
- package/src/cli/commands/collections.ts +41 -0
- package/src/cli/commands/compare.ts +129 -0
- package/src/cli/commands/coverage.ts +156 -0
- package/src/cli/commands/doctor.ts +127 -0
- package/src/cli/commands/init.ts +84 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/run.ts +156 -0
- package/src/cli/commands/runs.ts +108 -0
- package/src/cli/commands/serve.ts +22 -0
- package/src/cli/commands/update.ts +142 -0
- package/src/cli/commands/validate.ts +18 -0
- package/src/cli/index.ts +529 -0
- package/src/cli/output.ts +24 -0
- package/src/cli/runtime.ts +7 -0
- package/src/core/agent/agent-loop.ts +116 -0
- package/src/core/agent/context-manager.ts +41 -0
- package/src/core/agent/system-prompt.ts +28 -0
- package/src/core/agent/tools/diagnose-failure.ts +51 -0
- package/src/core/agent/tools/explore-api.ts +40 -0
- package/src/core/agent/tools/index.ts +46 -0
- package/src/core/agent/tools/query-results.ts +40 -0
- package/src/core/agent/tools/run-tests.ts +38 -0
- package/src/core/agent/tools/send-request.ts +44 -0
- package/src/core/agent/tools/validate-tests.ts +23 -0
- package/src/core/agent/types.ts +22 -0
- package/src/core/diagnostics/failure-hints.ts +63 -0
- package/src/core/generator/ai/ai-generator.ts +61 -0
- package/src/core/generator/ai/llm-client.ts +159 -0
- package/src/core/generator/ai/output-parser.ts +307 -0
- package/src/core/generator/ai/prompt-builder.ts +153 -0
- package/src/core/generator/ai/types.ts +56 -0
- package/src/core/generator/chunker.ts +47 -0
- package/src/core/generator/coverage-scanner.ts +87 -0
- package/src/core/generator/data-factory.ts +115 -0
- package/src/core/generator/endpoint-warnings.ts +43 -0
- package/src/core/generator/index.ts +12 -0
- package/src/core/generator/openapi-reader.ts +143 -0
- package/src/core/generator/schema-utils.ts +52 -0
- package/src/core/generator/serializer.ts +189 -0
- package/src/core/generator/types.ts +48 -0
- package/src/core/parser/filter.ts +14 -0
- package/src/core/parser/index.ts +21 -0
- package/src/core/parser/schema.ts +175 -0
- package/src/core/parser/types.ts +52 -0
- package/src/core/parser/variables.ts +154 -0
- package/src/core/parser/yaml-parser.ts +85 -0
- package/src/core/reporter/console.ts +175 -0
- package/src/core/reporter/index.ts +23 -0
- package/src/core/reporter/json.ts +9 -0
- package/src/core/reporter/junit.ts +78 -0
- package/src/core/reporter/types.ts +12 -0
- package/src/core/runner/assertions.ts +173 -0
- package/src/core/runner/execute-run.ts +97 -0
- package/src/core/runner/executor.ts +183 -0
- package/src/core/runner/http-client.ts +69 -0
- package/src/core/runner/index.ts +12 -0
- package/src/core/runner/types.ts +48 -0
- package/src/core/setup-api.ts +113 -0
- package/src/core/utils.ts +9 -0
- package/src/db/queries.ts +774 -0
- package/src/db/schema.ts +159 -0
- package/src/mcp/descriptions.ts +88 -0
- package/src/mcp/server.ts +52 -0
- package/src/mcp/tools/ci-init.ts +54 -0
- package/src/mcp/tools/coverage-analysis.ts +141 -0
- package/src/mcp/tools/describe-endpoint.ts +241 -0
- package/src/mcp/tools/explore-api.ts +84 -0
- package/src/mcp/tools/generate-and-save.ts +129 -0
- package/src/mcp/tools/generate-missing-tests.ts +91 -0
- package/src/mcp/tools/generate-tests-guide.ts +391 -0
- package/src/mcp/tools/manage-server.ts +86 -0
- package/src/mcp/tools/query-db.ts +255 -0
- package/src/mcp/tools/run-tests.ts +71 -0
- package/src/mcp/tools/save-test-suite.ts +218 -0
- package/src/mcp/tools/send-request.ts +63 -0
- package/src/mcp/tools/set-work-dir.ts +35 -0
- package/src/mcp/tools/setup-api.ts +84 -0
- package/src/mcp/tools/validate-tests.ts +43 -0
- package/src/tui/chat-ui.ts +150 -0
- package/src/web/data/collection-state.ts +360 -0
- package/src/web/routes/api.ts +234 -0
- package/src/web/routes/dashboard.ts +313 -0
- package/src/web/routes/runs.ts +64 -0
- package/src/web/schemas.ts +121 -0
- package/src/web/server.ts +134 -0
- package/src/web/static/htmx.min.js +1 -0
- package/src/web/static/style.css +827 -0
- package/src/web/views/endpoints-tab.ts +170 -0
- package/src/web/views/health-strip.ts +92 -0
- package/src/web/views/layout.ts +48 -0
- package/src/web/views/results.ts +209 -0
- package/src/web/views/runs-tab.ts +126 -0
- package/src/web/views/suites-tab.ts +153 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { resolve, dirname } from "path";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
3
|
+
import { printSuccess, printError } from "../output.ts";
|
|
4
|
+
|
|
5
|
+
export interface CiInitOptions {
|
|
6
|
+
platform?: "github" | "gitlab";
|
|
7
|
+
force: boolean;
|
|
8
|
+
dir?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const GH_ACTIONS_TEMPLATE = `name: API Tests
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
branches: [main]
|
|
15
|
+
pull_request:
|
|
16
|
+
schedule:
|
|
17
|
+
- cron: "0 */6 * * *"
|
|
18
|
+
workflow_dispatch:
|
|
19
|
+
repository_dispatch:
|
|
20
|
+
types: [api-updated]
|
|
21
|
+
|
|
22
|
+
permissions:
|
|
23
|
+
contents: read
|
|
24
|
+
checks: write
|
|
25
|
+
pull-requests: write
|
|
26
|
+
|
|
27
|
+
jobs:
|
|
28
|
+
test:
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v4
|
|
32
|
+
|
|
33
|
+
- name: Install zond
|
|
34
|
+
run: curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh
|
|
35
|
+
|
|
36
|
+
- name: Check coverage
|
|
37
|
+
run: zond coverage --api myapi --fail-on-coverage 60
|
|
38
|
+
# Fails if coverage drops below 60% — adjust threshold as needed
|
|
39
|
+
|
|
40
|
+
- name: Run smoke tests (read-only, safe for production)
|
|
41
|
+
run: |
|
|
42
|
+
mkdir -p test-results
|
|
43
|
+
zond run apis/ --tag smoke --safe --report junit --no-db > test-results/smoke.xml
|
|
44
|
+
# Use --env-var "API_KEY=\${{ secrets.API_KEY }}" to inject secrets without writing to disk
|
|
45
|
+
continue-on-error: true
|
|
46
|
+
|
|
47
|
+
- name: Run CRUD tests (staging only)
|
|
48
|
+
run: |
|
|
49
|
+
zond run apis/ --tag crud --env staging --report junit --no-db > test-results/crud.xml
|
|
50
|
+
# Add --env-var "BASE_URL=\${{ secrets.STAGING_URL }}" for staging URL
|
|
51
|
+
continue-on-error: true
|
|
52
|
+
|
|
53
|
+
- name: Publish test results
|
|
54
|
+
uses: EnricoMi/publish-unit-test-result-action@v2
|
|
55
|
+
if: always()
|
|
56
|
+
with:
|
|
57
|
+
files: test-results/*.xml
|
|
58
|
+
|
|
59
|
+
- uses: actions/upload-artifact@v4
|
|
60
|
+
if: always()
|
|
61
|
+
with:
|
|
62
|
+
name: test-results
|
|
63
|
+
path: test-results/
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const GITLAB_CI_TEMPLATE = `# Trigger via API: curl -X POST --form ref=main --form token=TRIGGER_TOKEN $CI_API_V4_URL/projects/$CI_PROJECT_ID/trigger/pipeline
|
|
67
|
+
|
|
68
|
+
variables:
|
|
69
|
+
# Set API_KEY in GitLab CI/CD → Settings → Variables
|
|
70
|
+
API_KEY: ""
|
|
71
|
+
|
|
72
|
+
api-coverage:
|
|
73
|
+
image: ubuntu:latest
|
|
74
|
+
before_script:
|
|
75
|
+
- apt-get update -qq && apt-get install -y -qq curl
|
|
76
|
+
- curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh
|
|
77
|
+
script:
|
|
78
|
+
- zond coverage --api myapi --fail-on-coverage 60
|
|
79
|
+
|
|
80
|
+
api-smoke:
|
|
81
|
+
image: ubuntu:latest
|
|
82
|
+
before_script:
|
|
83
|
+
- apt-get update -qq && apt-get install -y -qq curl
|
|
84
|
+
- curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh
|
|
85
|
+
script:
|
|
86
|
+
- mkdir -p test-results
|
|
87
|
+
# Use --env-var to inject secrets without writing to disk
|
|
88
|
+
- zond run apis/ --tag smoke --safe --report junit --no-db --env-var "API_KEY=$API_KEY" > test-results/smoke.xml
|
|
89
|
+
allow_failure:
|
|
90
|
+
exit_codes: 1
|
|
91
|
+
artifacts:
|
|
92
|
+
when: always
|
|
93
|
+
reports:
|
|
94
|
+
junit: test-results/smoke.xml
|
|
95
|
+
|
|
96
|
+
api-crud:
|
|
97
|
+
image: ubuntu:latest
|
|
98
|
+
before_script:
|
|
99
|
+
- apt-get update -qq && apt-get install -y -qq curl
|
|
100
|
+
- curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh
|
|
101
|
+
script:
|
|
102
|
+
- mkdir -p test-results
|
|
103
|
+
- zond run apis/ --tag crud --env staging --report junit --no-db > test-results/crud.xml
|
|
104
|
+
allow_failure:
|
|
105
|
+
exit_codes: 1
|
|
106
|
+
artifacts:
|
|
107
|
+
when: always
|
|
108
|
+
reports:
|
|
109
|
+
junit: test-results/crud.xml
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
function writeIfMissing(filePath: string, content: string, force: boolean): boolean {
|
|
113
|
+
if (!force && existsSync(filePath)) {
|
|
114
|
+
console.log(` Skipped ${filePath} (already exists, use --force to overwrite)`);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const dir = dirname(filePath);
|
|
118
|
+
if (!existsSync(dir)) {
|
|
119
|
+
mkdirSync(dir, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
writeFileSync(filePath, content, "utf-8");
|
|
122
|
+
console.log(` Created ${filePath}`);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function detectPlatform(cwd: string): "github" | "gitlab" | undefined {
|
|
127
|
+
if (existsSync(resolve(cwd, ".github"))) return "github";
|
|
128
|
+
if (existsSync(resolve(cwd, ".gitlab-ci.yml"))) return "gitlab";
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function ciInitCommand(options: CiInitOptions): Promise<number> {
|
|
133
|
+
const cwd = options.dir ? resolve(options.dir) : process.cwd();
|
|
134
|
+
let platform = options.platform;
|
|
135
|
+
|
|
136
|
+
if (!platform) {
|
|
137
|
+
platform = detectPlatform(cwd);
|
|
138
|
+
if (!platform) {
|
|
139
|
+
platform = "github";
|
|
140
|
+
console.log("No CI platform detected, defaulting to GitHub Actions.\n");
|
|
141
|
+
} else {
|
|
142
|
+
console.log(`Detected ${platform === "github" ? "GitHub Actions" : "GitLab CI"}.\n`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log(`Generating ${platform === "github" ? "GitHub Actions" : "GitLab CI"} workflow...\n`);
|
|
147
|
+
|
|
148
|
+
let created = false;
|
|
149
|
+
|
|
150
|
+
if (platform === "github") {
|
|
151
|
+
const targetPath = resolve(cwd, ".github/workflows/api-tests.yml");
|
|
152
|
+
created = writeIfMissing(targetPath, GH_ACTIONS_TEMPLATE, options.force);
|
|
153
|
+
} else {
|
|
154
|
+
const targetPath = resolve(cwd, ".gitlab-ci.yml");
|
|
155
|
+
created = writeIfMissing(targetPath, GITLAB_CI_TEMPLATE, options.force);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (created) {
|
|
159
|
+
printSuccess("CI workflow created. Commit and push to activate.");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getDb } from "../../db/schema.ts";
|
|
2
|
+
import { listCollections } from "../../db/queries.ts";
|
|
3
|
+
import { formatDuration } from "../../core/reporter/console.ts";
|
|
4
|
+
|
|
5
|
+
export function collectionsCommand(dbPath?: string): number {
|
|
6
|
+
getDb(dbPath);
|
|
7
|
+
const collections = listCollections();
|
|
8
|
+
|
|
9
|
+
if (collections.length === 0) {
|
|
10
|
+
console.log("No collections found.");
|
|
11
|
+
console.log("Hint: use `zond generate --from <spec>` to create a collection automatically.");
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Print table header
|
|
16
|
+
const header = [
|
|
17
|
+
"ID".padEnd(5),
|
|
18
|
+
"Name".padEnd(30),
|
|
19
|
+
"Runs".padEnd(6),
|
|
20
|
+
"Pass Rate".padEnd(11),
|
|
21
|
+
"Last Run".padEnd(20),
|
|
22
|
+
].join(" ");
|
|
23
|
+
|
|
24
|
+
console.log(header);
|
|
25
|
+
console.log("-".repeat(header.length));
|
|
26
|
+
|
|
27
|
+
for (const c of collections) {
|
|
28
|
+
const passRate = c.total_runs > 0 ? `${c.pass_rate}%` : "-";
|
|
29
|
+
const lastRun = c.last_run_at ?? "-";
|
|
30
|
+
const row = [
|
|
31
|
+
String(c.id).padEnd(5),
|
|
32
|
+
c.name.slice(0, 30).padEnd(30),
|
|
33
|
+
String(c.total_runs).padEnd(6),
|
|
34
|
+
passRate.padEnd(11),
|
|
35
|
+
lastRun.slice(0, 20).padEnd(20),
|
|
36
|
+
].join(" ");
|
|
37
|
+
console.log(row);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { getDb } from "../../db/schema.ts";
|
|
2
|
+
import { getRunById, getResultsByRunId } from "../../db/queries.ts";
|
|
3
|
+
import { printError } from "../output.ts";
|
|
4
|
+
|
|
5
|
+
const RESET = "\x1b[0m";
|
|
6
|
+
const GREEN = "\x1b[32m";
|
|
7
|
+
const RED = "\x1b[31m";
|
|
8
|
+
const YELLOW = "\x1b[33m";
|
|
9
|
+
const BOLD = "\x1b[1m";
|
|
10
|
+
|
|
11
|
+
function useColor(): boolean {
|
|
12
|
+
return process.stdout.isTTY ?? false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CompareOptions {
|
|
16
|
+
runA: number;
|
|
17
|
+
runB: number;
|
|
18
|
+
dbPath?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function compareCommand(options: CompareOptions): Promise<number> {
|
|
22
|
+
const { runA, runB, dbPath } = options;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
getDb(dbPath);
|
|
26
|
+
|
|
27
|
+
const runARecord = getRunById(runA);
|
|
28
|
+
const runBRecord = getRunById(runB);
|
|
29
|
+
|
|
30
|
+
if (!runARecord) {
|
|
31
|
+
printError(`Run #${runA} not found`);
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
if (!runBRecord) {
|
|
35
|
+
printError(`Run #${runB} not found`);
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const resultsA = getResultsByRunId(runA);
|
|
40
|
+
const resultsB = getResultsByRunId(runB);
|
|
41
|
+
|
|
42
|
+
// Build lookup maps: "suite_name::test_name" → status
|
|
43
|
+
const mapA = new Map<string, string>();
|
|
44
|
+
const mapB = new Map<string, string>();
|
|
45
|
+
|
|
46
|
+
for (const r of resultsA) {
|
|
47
|
+
mapA.set(`${r.suite_name}::${r.test_name}`, r.status);
|
|
48
|
+
}
|
|
49
|
+
for (const r of resultsB) {
|
|
50
|
+
mapB.set(`${r.suite_name}::${r.test_name}`, r.status);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const regressions: Array<{ suite: string; test: string; before: string; after: string }> = [];
|
|
54
|
+
const fixes: Array<{ suite: string; test: string; before: string; after: string }> = [];
|
|
55
|
+
const unchanged: number[] = [];
|
|
56
|
+
let newTests = 0;
|
|
57
|
+
let removedTests = 0;
|
|
58
|
+
|
|
59
|
+
// Check all keys from B (current run)
|
|
60
|
+
for (const [key, statusB] of mapB) {
|
|
61
|
+
const statusA = mapA.get(key);
|
|
62
|
+
if (statusA === undefined) {
|
|
63
|
+
newTests++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const wasPass = statusA === "pass";
|
|
67
|
+
const isPass = statusB === "pass";
|
|
68
|
+
const wasFail = statusA === "fail" || statusA === "error";
|
|
69
|
+
const isFail = statusB === "fail" || statusB === "error";
|
|
70
|
+
|
|
71
|
+
const [suite, test] = key.split("::") as [string, string];
|
|
72
|
+
|
|
73
|
+
if (wasPass && isFail) {
|
|
74
|
+
regressions.push({ suite, test, before: statusA, after: statusB });
|
|
75
|
+
} else if (wasFail && isPass) {
|
|
76
|
+
fixes.push({ suite, test, before: statusA, after: statusB });
|
|
77
|
+
} else {
|
|
78
|
+
unchanged.push(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Count removed tests
|
|
83
|
+
for (const key of mapA.keys()) {
|
|
84
|
+
if (!mapB.has(key)) removedTests++;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const color = useColor();
|
|
88
|
+
|
|
89
|
+
// Header
|
|
90
|
+
console.log(`\nComparing run #${runA} (${runARecord.started_at.slice(0, 19)}) → run #${runB} (${runBRecord.started_at.slice(0, 19)})\n`);
|
|
91
|
+
|
|
92
|
+
// Summary line
|
|
93
|
+
const parts = [
|
|
94
|
+
`${color ? BOLD : ""}${regressions.length} regressions${color ? RESET : ""}`,
|
|
95
|
+
`${fixes.length} fixes`,
|
|
96
|
+
`${unchanged.length} unchanged`,
|
|
97
|
+
];
|
|
98
|
+
if (newTests > 0) parts.push(`${newTests} new`);
|
|
99
|
+
if (removedTests > 0) parts.push(`${removedTests} removed`);
|
|
100
|
+
console.log(parts.join(" | ") + "\n");
|
|
101
|
+
|
|
102
|
+
// Regressions
|
|
103
|
+
if (regressions.length > 0) {
|
|
104
|
+
console.log(`${color ? RED + BOLD : ""}Regressions (pass → fail):${color ? RESET : ""}`);
|
|
105
|
+
for (const r of regressions) {
|
|
106
|
+
console.log(` ${color ? RED : ""}✗${color ? RESET : ""} [${r.suite}] ${r.test} (${r.before} → ${r.after})`);
|
|
107
|
+
}
|
|
108
|
+
console.log("");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fixes
|
|
112
|
+
if (fixes.length > 0) {
|
|
113
|
+
console.log(`${color ? GREEN : ""}Fixes (fail → pass):${color ? RESET : ""}`);
|
|
114
|
+
for (const f of fixes) {
|
|
115
|
+
console.log(` ${color ? GREEN : ""}✓${color ? RESET : ""} [${f.suite}] ${f.test} (${f.before} → ${f.after})`);
|
|
116
|
+
}
|
|
117
|
+
console.log("");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (regressions.length === 0 && fixes.length === 0) {
|
|
121
|
+
console.log(`${color ? GREEN : ""}No regressions detected.${color ? RESET : ""}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return regressions.length > 0 ? 1 : 0;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
printError(err instanceof Error ? err.message : String(err));
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { readOpenApiSpec, extractEndpoints, scanCoveredEndpoints, filterUncoveredEndpoints, normalizePath, specPathToRegex, analyzeEndpoints } from "../../core/generator/index.ts";
|
|
2
|
+
import { getDb } from "../../db/schema.ts";
|
|
3
|
+
import { getResultsByRunId, getRunById } from "../../db/queries.ts";
|
|
4
|
+
import { printError, printSuccess } from "../output.ts";
|
|
5
|
+
|
|
6
|
+
export interface CoverageOptions {
|
|
7
|
+
spec: string;
|
|
8
|
+
tests: string;
|
|
9
|
+
failOnCoverage?: number;
|
|
10
|
+
runId?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const RESET = "\x1b[0m";
|
|
14
|
+
const GREEN = "\x1b[32m";
|
|
15
|
+
const RED = "\x1b[31m";
|
|
16
|
+
const YELLOW = "\x1b[33m";
|
|
17
|
+
const DIM = "\x1b[2m";
|
|
18
|
+
|
|
19
|
+
function useColor(): boolean {
|
|
20
|
+
return process.stdout.isTTY ?? false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function extractPathFromUrl(url: string): string | null {
|
|
24
|
+
try {
|
|
25
|
+
return new URL(url).pathname;
|
|
26
|
+
} catch {
|
|
27
|
+
return url.startsWith("/") ? url : null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function coverageCommand(options: CoverageOptions): Promise<number> {
|
|
32
|
+
const { spec, tests } = options;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const doc = await readOpenApiSpec(spec);
|
|
36
|
+
const allEndpoints = extractEndpoints(doc);
|
|
37
|
+
|
|
38
|
+
if (allEndpoints.length === 0) {
|
|
39
|
+
printError("No endpoints found in the OpenAPI spec");
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const covered = await scanCoveredEndpoints(tests);
|
|
44
|
+
const uncovered = filterUncoveredEndpoints(allEndpoints, covered);
|
|
45
|
+
const coveredCount = allEndpoints.length - uncovered.length;
|
|
46
|
+
const percentage = Math.round((coveredCount / allEndpoints.length) * 100);
|
|
47
|
+
|
|
48
|
+
const color = useColor();
|
|
49
|
+
|
|
50
|
+
// Enriched mode with run results
|
|
51
|
+
if (options.runId != null) {
|
|
52
|
+
getDb();
|
|
53
|
+
const run = getRunById(options.runId);
|
|
54
|
+
if (!run) {
|
|
55
|
+
printError(`Run #${options.runId} not found`);
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const results = getResultsByRunId(options.runId);
|
|
60
|
+
|
|
61
|
+
// Build endpoint → status map
|
|
62
|
+
const endpointStatus = new Map<string, "passing" | "api_error" | "test_failed">();
|
|
63
|
+
for (const r of results) {
|
|
64
|
+
if (!r.request_url || !r.request_method) continue;
|
|
65
|
+
const urlPath = extractPathFromUrl(r.request_url);
|
|
66
|
+
if (!urlPath) continue;
|
|
67
|
+
const normalizedUrl = normalizePath(urlPath);
|
|
68
|
+
|
|
69
|
+
for (const ep of allEndpoints) {
|
|
70
|
+
const regex = specPathToRegex(ep.path);
|
|
71
|
+
if (r.request_method === ep.method && regex.test(normalizedUrl)) {
|
|
72
|
+
const key = `${ep.method} ${ep.path}`;
|
|
73
|
+
const existing = endpointStatus.get(key);
|
|
74
|
+
|
|
75
|
+
if (r.response_status !== null && r.response_status >= 500) {
|
|
76
|
+
endpointStatus.set(key, "api_error");
|
|
77
|
+
} else if (r.status === "fail" || r.status === "error") {
|
|
78
|
+
if (existing !== "api_error") {
|
|
79
|
+
endpointStatus.set(key, "test_failed");
|
|
80
|
+
}
|
|
81
|
+
} else if (!existing) {
|
|
82
|
+
endpointStatus.set(key, "passing");
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let passing = 0;
|
|
90
|
+
let apiError = 0;
|
|
91
|
+
let testFailed = 0;
|
|
92
|
+
for (const status of endpointStatus.values()) {
|
|
93
|
+
if (status === "passing") passing++;
|
|
94
|
+
else if (status === "api_error") apiError++;
|
|
95
|
+
else if (status === "test_failed") testFailed++;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%) — Run #${options.runId}`);
|
|
99
|
+
console.log("");
|
|
100
|
+
|
|
101
|
+
if (passing > 0) {
|
|
102
|
+
console.log(` ${color ? GREEN : ""}✅ ${passing} covered and passing${color ? RESET : ""}`);
|
|
103
|
+
}
|
|
104
|
+
if (apiError > 0) {
|
|
105
|
+
console.log(` ${color ? YELLOW : ""}⚠️ ${apiError} covered but returning 5xx (possibly broken API)${color ? RESET : ""}`);
|
|
106
|
+
}
|
|
107
|
+
if (testFailed > 0) {
|
|
108
|
+
console.log(` ${color ? RED : ""}❌ ${testFailed} covered, test assertions failed${color ? RESET : ""}`);
|
|
109
|
+
}
|
|
110
|
+
if (uncovered.length > 0) {
|
|
111
|
+
console.log(` ${color ? DIM : ""}⬜ ${uncovered.length} not covered${color ? RESET : ""}`);
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// Standard mode
|
|
115
|
+
console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%)`);
|
|
116
|
+
console.log("");
|
|
117
|
+
|
|
118
|
+
// Covered endpoints
|
|
119
|
+
if (coveredCount > 0) {
|
|
120
|
+
console.log(`${color ? GREEN : ""}Covered:${color ? RESET : ""}`);
|
|
121
|
+
for (const ep of allEndpoints) {
|
|
122
|
+
if (!uncovered.includes(ep)) {
|
|
123
|
+
console.log(` ${color ? GREEN : ""}✓${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
console.log("");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Uncovered endpoints
|
|
130
|
+
if (uncovered.length > 0) {
|
|
131
|
+
console.log(`${color ? RED : ""}Uncovered:${color ? RESET : ""}`);
|
|
132
|
+
for (const ep of uncovered) {
|
|
133
|
+
console.log(` ${color ? RED : ""}✗${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Static warnings (always shown)
|
|
139
|
+
const warnings = analyzeEndpoints(allEndpoints);
|
|
140
|
+
if (warnings.length > 0) {
|
|
141
|
+
console.log("");
|
|
142
|
+
console.log(`${color ? YELLOW : ""}Spec warnings:${color ? RESET : ""}`);
|
|
143
|
+
for (const w of warnings) {
|
|
144
|
+
console.log(` ${color ? YELLOW : ""}⚠${color ? RESET : ""} ${w.method.padEnd(7)} ${w.path}: ${w.warnings.join(", ")}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (options.failOnCoverage !== undefined) {
|
|
149
|
+
return percentage < options.failOnCoverage ? 1 : 0;
|
|
150
|
+
}
|
|
151
|
+
return uncovered.length > 0 ? 1 : 0;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
printError(err instanceof Error ? err.message : String(err));
|
|
154
|
+
return 2;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { getDb, closeDb } from "../../db/schema.ts";
|
|
4
|
+
|
|
5
|
+
export interface DoctorOptions {
|
|
6
|
+
dbPath?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Check {
|
|
10
|
+
label: string;
|
|
11
|
+
ok: boolean;
|
|
12
|
+
detail: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function doctorCommand(options: DoctorOptions): Promise<number> {
|
|
16
|
+
const checks: Check[] = [];
|
|
17
|
+
|
|
18
|
+
// 1. Database
|
|
19
|
+
checks.push(checkDatabase(options.dbPath));
|
|
20
|
+
|
|
21
|
+
// 2. Test files
|
|
22
|
+
checks.push(checkTestFiles());
|
|
23
|
+
|
|
24
|
+
// 3. OpenAPI spec
|
|
25
|
+
checks.push(checkOpenApiSpec());
|
|
26
|
+
|
|
27
|
+
// 4. Environment files
|
|
28
|
+
checks.push(checkEnvFiles());
|
|
29
|
+
|
|
30
|
+
// 5. Ollama
|
|
31
|
+
checks.push(await checkOllama());
|
|
32
|
+
|
|
33
|
+
// Print results
|
|
34
|
+
console.log("\nzond doctor\n");
|
|
35
|
+
|
|
36
|
+
let hasFailure = false;
|
|
37
|
+
for (const check of checks) {
|
|
38
|
+
const icon = check.ok ? "\u2713" : "\u2717";
|
|
39
|
+
console.log(` ${icon} ${check.label}: ${check.detail}`);
|
|
40
|
+
if (!check.ok) hasFailure = true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log("");
|
|
44
|
+
if (hasFailure) {
|
|
45
|
+
console.log("Some checks failed. See details above.");
|
|
46
|
+
} else {
|
|
47
|
+
console.log("All checks passed.");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return hasFailure ? 1 : 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checkDatabase(dbPath?: string): Check {
|
|
54
|
+
const path = dbPath ? resolve(dbPath) : resolve(process.cwd(), "zond.db");
|
|
55
|
+
try {
|
|
56
|
+
const db = getDb(path);
|
|
57
|
+
const runs = (db.query("SELECT COUNT(*) as cnt FROM runs").get() as { cnt: number }).cnt;
|
|
58
|
+
const envs = (db.query("SELECT COUNT(*) as cnt FROM environments").get() as { cnt: number }).cnt;
|
|
59
|
+
closeDb();
|
|
60
|
+
return { label: "Database", ok: true, detail: `${path} (${runs} runs, ${envs} environments)` };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { label: "Database", ok: false, detail: `Cannot open ${path}: ${(err as Error).message}` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkTestFiles(): Check {
|
|
67
|
+
const dirs = [".", "tests", "test"];
|
|
68
|
+
const found: string[] = [];
|
|
69
|
+
|
|
70
|
+
for (const dir of dirs) {
|
|
71
|
+
const full = resolve(process.cwd(), dir);
|
|
72
|
+
if (!existsSync(full)) continue;
|
|
73
|
+
try {
|
|
74
|
+
const glob = new Bun.Glob("**/*.yaml");
|
|
75
|
+
for (const file of glob.scanSync({ cwd: full, absolute: false })) {
|
|
76
|
+
if (!file.startsWith(".env.")) {
|
|
77
|
+
found.push(`${dir}/${file}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch { /* ignore */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (found.length > 0) {
|
|
84
|
+
return { label: "Test files", ok: true, detail: `${found.length} YAML file(s) found` };
|
|
85
|
+
}
|
|
86
|
+
return { label: "Test files", ok: false, detail: "No YAML test files found in cwd or tests/" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function checkOpenApiSpec(): Check {
|
|
90
|
+
const candidates = ["openapi.yaml", "openapi.json", "openapi.yml", "swagger.yaml", "swagger.json"];
|
|
91
|
+
for (const name of candidates) {
|
|
92
|
+
const full = resolve(process.cwd(), name);
|
|
93
|
+
if (existsSync(full)) {
|
|
94
|
+
return { label: "OpenAPI spec", ok: true, detail: name };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { label: "OpenAPI spec", ok: false, detail: "No openapi.yaml/json found (optional)" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checkEnvFiles(): Check {
|
|
101
|
+
const found: string[] = [];
|
|
102
|
+
try {
|
|
103
|
+
const glob = new Bun.Glob(".env.*.yaml");
|
|
104
|
+
for (const file of glob.scanSync({ cwd: process.cwd(), absolute: false })) {
|
|
105
|
+
found.push(file);
|
|
106
|
+
}
|
|
107
|
+
} catch { /* ignore */ }
|
|
108
|
+
|
|
109
|
+
if (found.length > 0) {
|
|
110
|
+
return { label: "Environment files", ok: true, detail: found.join(", ") };
|
|
111
|
+
}
|
|
112
|
+
return { label: "Environment files", ok: false, detail: "No .env.*.yaml files found (optional)" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function checkOllama(): Promise<Check> {
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(3000) });
|
|
118
|
+
if (res.ok) {
|
|
119
|
+
const data = await res.json() as { models?: { name: string }[] };
|
|
120
|
+
const count = data.models?.length ?? 0;
|
|
121
|
+
return { label: "Ollama", ok: true, detail: `Running (${count} model(s) available)` };
|
|
122
|
+
}
|
|
123
|
+
return { label: "Ollama", ok: false, detail: `Responded with status ${res.status}` };
|
|
124
|
+
} catch {
|
|
125
|
+
return { label: "Ollama", ok: false, detail: "Not reachable at localhost:11434 (optional, needed for chat)" };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { resolve, dirname } from "path";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
3
|
+
import { printSuccess } from "../output.ts";
|
|
4
|
+
|
|
5
|
+
export interface InitCommandOptions {
|
|
6
|
+
force: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const EXAMPLE_TEST = `name: Example Smoke Test
|
|
10
|
+
base_url: "{{base_url}}"
|
|
11
|
+
|
|
12
|
+
tests:
|
|
13
|
+
- name: "List posts"
|
|
14
|
+
GET: /posts
|
|
15
|
+
expect:
|
|
16
|
+
status: 200
|
|
17
|
+
body:
|
|
18
|
+
id: { type: integer }
|
|
19
|
+
|
|
20
|
+
- name: "Get single post"
|
|
21
|
+
GET: /posts/1
|
|
22
|
+
expect:
|
|
23
|
+
status: 200
|
|
24
|
+
body:
|
|
25
|
+
id: { equals: 1 }
|
|
26
|
+
title: { type: string }
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const ENV_DEV = `base_url: https://jsonplaceholder.typicode.com
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
const MCP_CONFIG = `{
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"zond": {
|
|
35
|
+
"command": "zond",
|
|
36
|
+
"args": ["mcp"]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
function writeIfMissing(filePath: string, content: string, force: boolean): boolean {
|
|
43
|
+
if (!force && existsSync(filePath)) {
|
|
44
|
+
console.log(` Skipped ${filePath} (already exists)`);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const dir = dirname(filePath);
|
|
48
|
+
if (!existsSync(dir)) {
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
writeFileSync(filePath, content, "utf-8");
|
|
52
|
+
console.log(` Created ${filePath}`);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isClaudeCodeAvailable(): boolean {
|
|
57
|
+
try {
|
|
58
|
+
const result = Bun.spawnSync(["claude", "--version"], {
|
|
59
|
+
stdout: "ignore",
|
|
60
|
+
stderr: "ignore",
|
|
61
|
+
});
|
|
62
|
+
return result.exitCode === 0;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function initCommand(options: InitCommandOptions): Promise<number> {
|
|
69
|
+
const cwd = process.cwd();
|
|
70
|
+
|
|
71
|
+
console.log("Initializing zond project...\n");
|
|
72
|
+
|
|
73
|
+
writeIfMissing(resolve(cwd, "tests/example.yaml"), EXAMPLE_TEST, options.force);
|
|
74
|
+
writeIfMissing(resolve(cwd, ".env.dev.yaml"), ENV_DEV, options.force);
|
|
75
|
+
|
|
76
|
+
// Create .mcp.json if Claude Code is detected
|
|
77
|
+
if (isClaudeCodeAvailable()) {
|
|
78
|
+
writeIfMissing(resolve(cwd, ".mcp.json"), MCP_CONFIG, options.force);
|
|
79
|
+
printSuccess("Claude Code detected — .mcp.json created");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log("\nReady! Run: zond run tests/");
|
|
83
|
+
return 0;
|
|
84
|
+
}
|