@kirrosh/apitool 0.4.3
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/.github/workflows/ci.yml +27 -0
- package/.github/workflows/release.yml +97 -0
- package/.mcp.json +9 -0
- package/APITOOL.md +195 -0
- package/BACKLOG.md +62 -0
- package/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/bun.lock +291 -0
- package/docs/GLOSSARY.md +182 -0
- package/docs/INDEX.md +21 -0
- package/docs/agent.md +135 -0
- package/docs/archive/APITOOL-pre-M22.md +831 -0
- package/docs/archive/BACKLOG-AI-NATIVE.md +56 -0
- package/docs/archive/M1-M2-parser-runner.md +216 -0
- package/docs/archive/M4-M7-reporter-cli.md +179 -0
- package/docs/archive/M5-M7-storage-junit.md +300 -0
- package/docs/archive/M6-webui.md +339 -0
- package/docs/ci.md +274 -0
- package/docs/generation-issues.md +67 -0
- package/generated/.env.yaml +3 -0
- package/install.ps1 +80 -0
- package/install.sh +113 -0
- package/package.json +46 -0
- package/scripts/run-mocked-tests.ts +45 -0
- package/seed-demo.ts +53 -0
- package/self-tests/auth.yaml +18 -0
- package/self-tests/collections-crud.yaml +46 -0
- package/self-tests/environments-crud.yaml +48 -0
- package/self-tests/export.yaml +32 -0
- package/self-tests/runs.yaml +16 -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 +126 -0
- package/src/cli/commands/collections.ts +41 -0
- package/src/cli/commands/coverage.ts +65 -0
- package/src/cli/commands/doctor.ts +127 -0
- package/src/cli/commands/envs.ts +218 -0
- package/src/cli/commands/init.ts +84 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/run.ts +137 -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 +500 -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 +33 -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 +48 -0
- package/src/core/agent/tools/manage-environment.ts +40 -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/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/coverage-scanner.ts +87 -0
- package/src/core/generator/data-factory.ts +115 -0
- package/src/core/generator/index.ts +10 -0
- package/src/core/generator/openapi-reader.ts +142 -0
- package/src/core/generator/schema-utils.ts +52 -0
- package/src/core/generator/serializer.ts +189 -0
- package/src/core/generator/types.ts +47 -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 +50 -0
- package/src/core/parser/variables.ts +146 -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 +172 -0
- package/src/core/runner/execute-run.ts +75 -0
- package/src/core/runner/executor.ts +150 -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 +97 -0
- package/src/core/utils.ts +9 -0
- package/src/db/queries.ts +868 -0
- package/src/db/schema.ts +215 -0
- package/src/mcp/server.ts +47 -0
- package/src/mcp/tools/ci-init.ts +57 -0
- package/src/mcp/tools/coverage-analysis.ts +58 -0
- package/src/mcp/tools/explore-api.ts +84 -0
- package/src/mcp/tools/generate-missing-tests.ts +80 -0
- package/src/mcp/tools/generate-tests-guide.ts +353 -0
- package/src/mcp/tools/manage-environment.ts +123 -0
- package/src/mcp/tools/manage-server.ts +87 -0
- package/src/mcp/tools/query-db.ts +141 -0
- package/src/mcp/tools/run-tests.ts +66 -0
- package/src/mcp/tools/save-test-suite.ts +164 -0
- package/src/mcp/tools/send-request.ts +53 -0
- package/src/mcp/tools/setup-api.ts +49 -0
- package/src/mcp/tools/validate-tests.ts +42 -0
- package/src/tui/chat-ui.ts +150 -0
- package/src/web/routes/api.ts +234 -0
- package/src/web/routes/dashboard.ts +348 -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 +265 -0
- package/src/web/views/layout.ts +46 -0
- package/src/web/views/results.ts +209 -0
- package/tests/agent/agent-loop.test.ts +61 -0
- package/tests/agent/context-manager.test.ts +59 -0
- package/tests/agent/system-prompt.test.ts +42 -0
- package/tests/agent/tools/diagnose-failure.test.ts +85 -0
- package/tests/agent/tools/explore-api.test.ts +59 -0
- package/tests/agent/tools/manage-environment.test.ts +78 -0
- package/tests/agent/tools/query-results.test.ts +77 -0
- package/tests/agent/tools/run-tests.test.ts +89 -0
- package/tests/agent/tools/send-request.test.ts +78 -0
- package/tests/agent/tools/validate-tests.test.ts +59 -0
- package/tests/ai/ai-generator.integration.test.ts +131 -0
- package/tests/ai/llm-client.test.ts +145 -0
- package/tests/ai/output-parser.test.ts +132 -0
- package/tests/ai/prompt-builder.test.ts +67 -0
- package/tests/ai/types.test.ts +55 -0
- package/tests/cli/args.test.ts +63 -0
- package/tests/cli/chat.test.ts +38 -0
- package/tests/cli/ci-init.test.ts +112 -0
- package/tests/cli/commands.test.ts +316 -0
- package/tests/cli/coverage.test.ts +58 -0
- package/tests/cli/doctor.test.ts +39 -0
- package/tests/cli/envs.test.ts +181 -0
- package/tests/cli/init.test.ts +80 -0
- package/tests/cli/runs.test.ts +94 -0
- package/tests/cli/safe-run.test.ts +103 -0
- package/tests/cli/update.test.ts +32 -0
- package/tests/core/generator/schema-utils.test.ts +108 -0
- package/tests/core/parser/nested-assertions.test.ts +80 -0
- package/tests/core/runner/root-body-assertions.test.ts +70 -0
- package/tests/db/chat-queries.test.ts +88 -0
- package/tests/db/chat-schema.test.ts +37 -0
- package/tests/db/environments.test.ts +131 -0
- package/tests/db/queries.test.ts +409 -0
- package/tests/db/schema.test.ts +141 -0
- package/tests/fixtures/.env.yaml +3 -0
- package/tests/fixtures/auth-token-test.yaml +8 -0
- package/tests/fixtures/bail/suite-a.yaml +6 -0
- package/tests/fixtures/bail/suite-b.yaml +6 -0
- package/tests/fixtures/crud.yaml +35 -0
- package/tests/fixtures/invalid-missing-name.yaml +5 -0
- package/tests/fixtures/invalid-no-method.yaml +6 -0
- package/tests/fixtures/petstore-auth.json +295 -0
- package/tests/fixtures/petstore-simple.json +151 -0
- package/tests/fixtures/post-only.yaml +12 -0
- package/tests/fixtures/simple.yaml +6 -0
- package/tests/fixtures/valid/.env.yaml +1 -0
- package/tests/fixtures/valid/a.yaml +5 -0
- package/tests/fixtures/valid/b.yml +5 -0
- package/tests/generator/coverage-scanner.test.ts +129 -0
- package/tests/generator/data-factory.test.ts +133 -0
- package/tests/generator/openapi-reader.test.ts +131 -0
- package/tests/integration/auth-flow.test.ts +217 -0
- package/tests/mcp/coverage-analysis.test.ts +64 -0
- package/tests/mcp/explore-api-schemas.test.ts +105 -0
- package/tests/mcp/explore-api.test.ts +49 -0
- package/tests/mcp/generate-missing-tests.test.ts +69 -0
- package/tests/mcp/manage-environment.test.ts +89 -0
- package/tests/mcp/save-test-suite.test.ts +116 -0
- package/tests/mcp/send-request.test.ts +79 -0
- package/tests/mcp/setup-api.test.ts +106 -0
- package/tests/mcp/tools.test.ts +248 -0
- package/tests/parser/schema.test.ts +134 -0
- package/tests/parser/variables.test.ts +227 -0
- package/tests/parser/yaml-parser.test.ts +69 -0
- package/tests/reporter/console.test.ts +256 -0
- package/tests/reporter/json.test.ts +98 -0
- package/tests/reporter/junit.test.ts +284 -0
- package/tests/runner/assertions.test.ts +262 -0
- package/tests/runner/executor.test.ts +310 -0
- package/tests/runner/http-client.test.ts +138 -0
- package/tests/web/routes.test.ts +160 -0
- package/tsconfig.json +31 -0
|
@@ -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("\napitool 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(), "apitool.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,218 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { getDb } from "../../db/schema.ts";
|
|
4
|
+
import {
|
|
5
|
+
listEnvironmentRecords,
|
|
6
|
+
getEnvironment,
|
|
7
|
+
upsertEnvironment,
|
|
8
|
+
deleteEnvironment,
|
|
9
|
+
getEnvironmentById,
|
|
10
|
+
findCollectionByNameOrId,
|
|
11
|
+
} from "../../db/queries.ts";
|
|
12
|
+
import { printError, printSuccess } from "../output.ts";
|
|
13
|
+
|
|
14
|
+
export interface EnvsOptions {
|
|
15
|
+
action: "list" | "get" | "set" | "delete" | "import" | "export";
|
|
16
|
+
name?: string;
|
|
17
|
+
pairs?: string[];
|
|
18
|
+
file?: string;
|
|
19
|
+
api?: string;
|
|
20
|
+
dbPath?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseKeyValuePairs(pairs: string[]): Record<string, string> {
|
|
24
|
+
const result: Record<string, string> = {};
|
|
25
|
+
for (const pair of pairs) {
|
|
26
|
+
const idx = pair.indexOf("=");
|
|
27
|
+
if (idx === -1) continue;
|
|
28
|
+
const key = pair.slice(0, idx).trim();
|
|
29
|
+
const value = pair.slice(idx + 1).trim();
|
|
30
|
+
if (key) result[key] = value;
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function envsCommand(options: EnvsOptions): number {
|
|
36
|
+
const { action, name, pairs, dbPath } = options;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
getDb(dbPath);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
printError(`Failed to open database: ${(err as Error).message}`);
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Resolve --api to collection_id
|
|
46
|
+
let collectionId: number | undefined;
|
|
47
|
+
if (options.api) {
|
|
48
|
+
const col = findCollectionByNameOrId(options.api);
|
|
49
|
+
if (!col) {
|
|
50
|
+
printError(`API '${options.api}' not found`);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
collectionId = col.id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
switch (action) {
|
|
57
|
+
case "list": {
|
|
58
|
+
const envs = listEnvironmentRecords(collectionId);
|
|
59
|
+
if (envs.length === 0) {
|
|
60
|
+
console.log("No environments found.");
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Print table
|
|
65
|
+
const nameWidth = Math.max(4, ...envs.map(e => e.name.length));
|
|
66
|
+
const scopeWidth = 8;
|
|
67
|
+
const header = `${"NAME".padEnd(nameWidth)} ${"SCOPE".padEnd(scopeWidth)} VARIABLES`;
|
|
68
|
+
console.log(header);
|
|
69
|
+
console.log("-".repeat(header.length + 10));
|
|
70
|
+
for (const env of envs) {
|
|
71
|
+
const scope = env.collection_id ? `api:${env.collection_id}` : "global";
|
|
72
|
+
const varKeys = Object.keys(env.variables).join(", ");
|
|
73
|
+
console.log(`${env.name.padEnd(nameWidth)} ${scope.padEnd(scopeWidth)} ${varKeys}`);
|
|
74
|
+
}
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case "get": {
|
|
79
|
+
if (!name) {
|
|
80
|
+
printError("Missing environment name. Usage: apitool envs get <name>");
|
|
81
|
+
return 2;
|
|
82
|
+
}
|
|
83
|
+
const variables = getEnvironment(name, collectionId);
|
|
84
|
+
if (!variables) {
|
|
85
|
+
printError(`Environment '${name}' not found`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const keyWidth = Math.max(3, ...Object.keys(variables).map(k => k.length));
|
|
90
|
+
for (const [k, v] of Object.entries(variables)) {
|
|
91
|
+
console.log(`${k.padEnd(keyWidth)} ${v}`);
|
|
92
|
+
}
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case "set": {
|
|
97
|
+
if (!name) {
|
|
98
|
+
printError("Missing environment name. Usage: apitool envs set <name> KEY=VALUE ...");
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
if (!pairs || pairs.length === 0) {
|
|
102
|
+
printError("Missing KEY=VALUE pairs. Usage: apitool envs set <name> KEY=VALUE ...");
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
const variables = parseKeyValuePairs(pairs);
|
|
106
|
+
if (Object.keys(variables).length === 0) {
|
|
107
|
+
printError("No valid KEY=VALUE pairs provided");
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Merge with existing (scoped if --api provided)
|
|
112
|
+
const existing = getEnvironment(name, collectionId) ?? {};
|
|
113
|
+
const merged = { ...existing, ...variables };
|
|
114
|
+
upsertEnvironment(name, merged, collectionId);
|
|
115
|
+
const scope = collectionId ? ` (scoped to api:${collectionId})` : "";
|
|
116
|
+
printSuccess(`Environment '${name}' updated${scope} (${Object.keys(variables).length} variable(s) set)`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case "delete": {
|
|
121
|
+
if (!name) {
|
|
122
|
+
printError("Missing environment name. Usage: apitool envs delete <name>");
|
|
123
|
+
return 2;
|
|
124
|
+
}
|
|
125
|
+
// Find by name (and scope) to get ID
|
|
126
|
+
const envs = listEnvironmentRecords(collectionId);
|
|
127
|
+
const env = collectionId
|
|
128
|
+
? envs.find(e => e.name === name && e.collection_id === collectionId)
|
|
129
|
+
: envs.find(e => e.name === name && e.collection_id === null);
|
|
130
|
+
if (!env) {
|
|
131
|
+
printError(`Environment '${name}' not found`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
deleteEnvironment(env.id);
|
|
135
|
+
printSuccess(`Environment '${name}' deleted`);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case "import": {
|
|
140
|
+
if (!name) {
|
|
141
|
+
printError("Missing environment name. Usage: apitool envs import <name> <file>");
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
const file = options.file;
|
|
145
|
+
if (!file) {
|
|
146
|
+
printError("Missing file path. Usage: apitool envs import <name> <file>");
|
|
147
|
+
return 2;
|
|
148
|
+
}
|
|
149
|
+
const filePath = resolve(file);
|
|
150
|
+
if (!existsSync(filePath)) {
|
|
151
|
+
printError(`File not found: ${filePath}`);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const content = readFileSync(filePath, "utf-8");
|
|
156
|
+
const parsed = parseYamlEnv(content);
|
|
157
|
+
if (!parsed || Object.keys(parsed).length === 0) {
|
|
158
|
+
printError("No variables found in YAML file");
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
upsertEnvironment(name, parsed, collectionId);
|
|
162
|
+
printSuccess(`Environment '${name}' imported (${Object.keys(parsed).length} variable(s))`);
|
|
163
|
+
return 0;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
printError(`Failed to import: ${(err as Error).message}`);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case "export": {
|
|
171
|
+
if (!name) {
|
|
172
|
+
printError("Missing environment name. Usage: apitool envs export <name>");
|
|
173
|
+
return 2;
|
|
174
|
+
}
|
|
175
|
+
const variables = getEnvironment(name, collectionId);
|
|
176
|
+
if (!variables) {
|
|
177
|
+
printError(`Environment '${name}' not found`);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
console.log(toYaml(variables));
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
default:
|
|
185
|
+
printError(`Unknown action: ${action}`);
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Parse a simple YAML key:value file into a flat Record */
|
|
191
|
+
export function parseYamlEnv(content: string): Record<string, string> {
|
|
192
|
+
const result: Record<string, string> = {};
|
|
193
|
+
for (const line of content.split("\n")) {
|
|
194
|
+
const trimmed = line.trim();
|
|
195
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
196
|
+
const idx = trimmed.indexOf(":");
|
|
197
|
+
if (idx === -1) continue;
|
|
198
|
+
const key = trimmed.slice(0, idx).trim();
|
|
199
|
+
let value = trimmed.slice(idx + 1).trim();
|
|
200
|
+
// Strip surrounding quotes
|
|
201
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
202
|
+
value = value.slice(1, -1);
|
|
203
|
+
}
|
|
204
|
+
if (key) result[key] = value;
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Serialize a flat Record as simple YAML */
|
|
210
|
+
export function toYaml(vars: Record<string, string>): string {
|
|
211
|
+
const lines: string[] = [];
|
|
212
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
213
|
+
// Quote values that contain special chars
|
|
214
|
+
const needsQuote = /[:#\[\]{}&*!|>'"@`,%]/.test(v) || v.includes(" ") || v === "";
|
|
215
|
+
lines.push(`${k}: ${needsQuote ? `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : v}`);
|
|
216
|
+
}
|
|
217
|
+
return lines.join("\n");
|
|
218
|
+
}
|
|
@@ -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
|
+
"apitool": {
|
|
35
|
+
"command": "apitool",
|
|
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 apitool 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: apitool run tests/");
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
@@ -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,137 @@
|
|
|
1
|
+
import { dirname } from "path";
|
|
2
|
+
import { parse } from "../../core/parser/yaml-parser.ts";
|
|
3
|
+
import { loadEnvironment } from "../../core/parser/variables.ts";
|
|
4
|
+
import { filterSuitesByTags } from "../../core/parser/filter.ts";
|
|
5
|
+
import { runSuite } from "../../core/runner/executor.ts";
|
|
6
|
+
import { getReporter } from "../../core/reporter/index.ts";
|
|
7
|
+
import type { ReporterName } from "../../core/reporter/types.ts";
|
|
8
|
+
import type { TestSuite } from "../../core/parser/types.ts";
|
|
9
|
+
import type { TestRunResult } from "../../core/runner/types.ts";
|
|
10
|
+
import { printError, printWarning } from "../output.ts";
|
|
11
|
+
import { getDb } from "../../db/schema.ts";
|
|
12
|
+
import { createRun, finalizeRun, saveResults, findCollectionByTestPath } from "../../db/queries.ts";
|
|
13
|
+
|
|
14
|
+
export interface RunOptions {
|
|
15
|
+
path: string;
|
|
16
|
+
env?: string;
|
|
17
|
+
report: ReporterName;
|
|
18
|
+
timeout?: number;
|
|
19
|
+
bail: boolean;
|
|
20
|
+
noDb?: boolean;
|
|
21
|
+
dbPath?: string;
|
|
22
|
+
authToken?: string;
|
|
23
|
+
safe?: boolean;
|
|
24
|
+
tag?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function runCommand(options: RunOptions): Promise<number> {
|
|
28
|
+
// 1. Parse test files
|
|
29
|
+
let suites: TestSuite[];
|
|
30
|
+
try {
|
|
31
|
+
suites = await parse(options.path);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
printError(err instanceof Error ? err.message : String(err));
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (suites.length === 0) {
|
|
38
|
+
printWarning(`No test files found in ${options.path}`);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 1b. Tag filter
|
|
43
|
+
if (options.tag && options.tag.length > 0) {
|
|
44
|
+
suites = filterSuitesByTags(suites, options.tag);
|
|
45
|
+
if (suites.length === 0) {
|
|
46
|
+
printWarning("No suites match the specified tags");
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 1c. Safe mode: filter to GET-only tests
|
|
52
|
+
if (options.safe) {
|
|
53
|
+
for (const suite of suites) {
|
|
54
|
+
suite.tests = suite.tests.filter(t => t.method === "GET");
|
|
55
|
+
}
|
|
56
|
+
suites = suites.filter(s => s.tests.length > 0);
|
|
57
|
+
if (suites.length === 0) {
|
|
58
|
+
printWarning("No GET tests found. Nothing to run in safe mode.");
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. Load environment (resolve collection for scoped envs)
|
|
64
|
+
const searchDir = dirname(options.path);
|
|
65
|
+
let collectionForEnv: { id: number } | null = null;
|
|
66
|
+
try {
|
|
67
|
+
getDb(options.dbPath);
|
|
68
|
+
collectionForEnv = findCollectionByTestPath(options.path);
|
|
69
|
+
} catch { /* DB not available — OK */ }
|
|
70
|
+
|
|
71
|
+
let env: Record<string, string> = {};
|
|
72
|
+
try {
|
|
73
|
+
env = await loadEnvironment(options.env, searchDir, collectionForEnv?.id);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
printError(`Failed to load environment: ${(err as Error).message}`);
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Inject CLI auth token — overrides env file value
|
|
80
|
+
if (options.authToken) {
|
|
81
|
+
env.auth_token = options.authToken;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Warn if --env was explicitly set but file was not found (empty env)
|
|
85
|
+
if (options.env && Object.keys(env).length === 0) {
|
|
86
|
+
printWarning(`Environment file .env.${options.env}.yaml not found in ${searchDir}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 3. Apply timeout override
|
|
90
|
+
if (options.timeout !== undefined) {
|
|
91
|
+
for (const suite of suites) {
|
|
92
|
+
suite.config.timeout = options.timeout;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 4. Run suites
|
|
97
|
+
const results: TestRunResult[] = [];
|
|
98
|
+
if (options.bail) {
|
|
99
|
+
// Sequential with bail at suite level
|
|
100
|
+
for (const suite of suites) {
|
|
101
|
+
const result = await runSuite(suite, env);
|
|
102
|
+
results.push(result);
|
|
103
|
+
if (result.failed > 0 || result.steps.some((s) => s.status === "error")) {
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
// Parallel
|
|
109
|
+
const all = await Promise.all(suites.map((suite) => runSuite(suite, env)));
|
|
110
|
+
results.push(...all);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 5. Report
|
|
114
|
+
const reporter = getReporter(options.report);
|
|
115
|
+
reporter.report(results);
|
|
116
|
+
|
|
117
|
+
// 6. Save to DB
|
|
118
|
+
if (!options.noDb) {
|
|
119
|
+
try {
|
|
120
|
+
getDb(options.dbPath);
|
|
121
|
+
const collection = findCollectionByTestPath(options.path);
|
|
122
|
+
const runId = createRun({
|
|
123
|
+
started_at: results[0]?.started_at ?? new Date().toISOString(),
|
|
124
|
+
environment: options.env,
|
|
125
|
+
collection_id: collection?.id,
|
|
126
|
+
});
|
|
127
|
+
finalizeRun(runId, results);
|
|
128
|
+
saveResults(runId, results);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
printWarning(`Failed to save results to DB: ${(err as Error).message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 7. Exit code
|
|
135
|
+
const hasFailures = results.some((r) => r.failed > 0 || r.steps.some((s) => s.status === "error"));
|
|
136
|
+
return hasFailures ? 1 : 0;
|
|
137
|
+
}
|
|
@@ -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
|
+
}
|