@kirrosh/apitool 0.4.3 → 0.5.1
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/APITOOL.md +48 -2
- package/package.json +1 -1
- package/scripts/run-mocked-tests.ts +0 -2
- package/src/cli/commands/ci-init.ts +48 -11
- package/src/cli/commands/compare.ts +129 -0
- package/src/cli/commands/coverage.ts +4 -0
- package/src/cli/commands/run.ts +28 -9
- package/src/cli/index.ts +53 -6
- package/src/core/generator/openapi-reader.ts +1 -1
- package/src/core/parser/types.ts +2 -0
- package/src/core/parser/yaml-parser.ts +1 -1
- package/src/core/runner/execute-run.ts +26 -3
- package/src/core/runner/executor.ts +17 -3
- package/src/db/schema.ts +6 -0
- package/src/mcp/server.ts +6 -1
- package/src/mcp/tools/coverage-analysis.ts +4 -1
- package/src/mcp/tools/describe-endpoint.ts +159 -0
- package/src/mcp/tools/generate-missing-tests.ts +9 -2
- package/src/mcp/tools/generate-tests-guide.ts +42 -2
- package/src/mcp/tools/query-db.ts +71 -3
- package/src/mcp/tools/run-tests.ts +5 -1
- package/src/mcp/tools/save-test-suite.ts +183 -127
- package/src/mcp/tools/set-work-dir.ts +38 -0
- package/src/mcp/tools/setup-api.ts +26 -0
- package/src/web/routes/dashboard.ts +4 -4
- package/src/web/server.ts +1 -1
- package/tests/agent/tools/manage-environment.test.ts +2 -2
- package/tests/core/generator/schema-utils.test.ts +1 -1
- package/tests/core/runner/root-body-assertions.test.ts +1 -1
- package/tests/db/chat-schema.test.ts +1 -1
- package/tests/db/schema.test.ts +1 -1
- package/tests/integration/auth-flow.test.ts +3 -58
- package/tests/mcp/setup-api.test.ts +1 -1
package/APITOOL.md
CHANGED
|
@@ -39,7 +39,7 @@ src/
|
|
|
39
39
|
│ ├── reporter/ Console, JSON, JUnit XML
|
|
40
40
|
│ └── agent/ AI Chat (AI SDK v6, tool calling)
|
|
41
41
|
├── db/ SQLite (runs, collections, environments)
|
|
42
|
-
├── mcp/ MCP Server (
|
|
42
|
+
├── mcp/ MCP Server (17 tools)
|
|
43
43
|
├── web/ Hono + HTMX dashboard
|
|
44
44
|
└── cli/ 16 CLI commands
|
|
45
45
|
```
|
|
@@ -74,12 +74,56 @@ SQLite auto-created. Tables: `collections`, `runs`, `results`, `environments`, `
|
|
|
74
74
|
Single-page dashboard: API selector → env selector → Run Tests → results + coverage + history. JUnit/JSON export. Hono + HTMX.
|
|
75
75
|
|
|
76
76
|
### MCP Server
|
|
77
|
-
|
|
77
|
+
17 tools for AI agent integration. Primary test generation flow:
|
|
78
78
|
|
|
79
79
|
```
|
|
80
80
|
generate_tests_guide → [agent writes YAML] → save_test_suite → run_tests → diagnose_failure → ci_init
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
### Safe Test Coverage Workflow
|
|
84
|
+
|
|
85
|
+
**When the user asks to "safely cover", "test without breaking anything", or "start with read-only tests" — follow this 4-phase approach:**
|
|
86
|
+
|
|
87
|
+
**Step 0 (required for npx MCP — single shared server):**
|
|
88
|
+
```
|
|
89
|
+
set_work_dir(workDir: "<absolute path to project root>")
|
|
90
|
+
```
|
|
91
|
+
Call this once at the start of the session so `apitool.db` and all relative paths resolve to your project directory.
|
|
92
|
+
|
|
93
|
+
**Phase 0 — Register + static analysis (zero requests)**
|
|
94
|
+
```
|
|
95
|
+
setup_api(...)
|
|
96
|
+
coverage_analysis(specPath, testsDir) ← baseline, no HTTP
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Phase 1 — Smoke tests (GET-only, safe for production)**
|
|
100
|
+
```
|
|
101
|
+
generate_tests_guide(specPath, methodFilter: ["GET"]) ← GET endpoints only
|
|
102
|
+
save_test_suite(...) ← tags: [smoke]
|
|
103
|
+
run_tests(testPath, safe: true) ← --safe enforces GET-only
|
|
104
|
+
```
|
|
105
|
+
Stop here if the user hasn't explicitly confirmed a staging/test environment.
|
|
106
|
+
|
|
107
|
+
**Phase 2 — CRUD tests (only with explicit user confirmation + staging env)**
|
|
108
|
+
```
|
|
109
|
+
run_tests(testPath, tag: ["crud"], dryRun: true) ← show requests first, no sending
|
|
110
|
+
[show user what would be sent, ask confirmation]
|
|
111
|
+
run_tests(testPath, tag: ["crud"], envName: "staging") ← only after confirmation
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Phase 3 — Regression tracking**
|
|
115
|
+
```
|
|
116
|
+
query_db(action: "compare_runs", runId: prev, runIdB: curr)
|
|
117
|
+
ci_init()
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Key safety rules:**
|
|
121
|
+
- `safe: true` on `run_tests` → only GET requests execute, write ops are skipped
|
|
122
|
+
- `dryRun: true` on `run_tests` → shows all requests without sending any
|
|
123
|
+
- `methodFilter: ["GET"]` on `generate_tests_guide` → only generates GET test stubs
|
|
124
|
+
- Always use `tags: [smoke]` for GET-only suites, `tags: [crud]` for write operations
|
|
125
|
+
- Never run CRUD tests unless user confirmed environment is safe (staging/test)
|
|
126
|
+
|
|
83
127
|
### CI/CD
|
|
84
128
|
`apitool ci init` scaffolds GitHub Actions or GitLab CI workflow. Supports schedule, repository_dispatch, manual triggers. See [docs/ci.md](docs/ci.md).
|
|
85
129
|
|
|
@@ -102,6 +146,8 @@ generate_tests_guide → [agent writes YAML] → save_test_suite → run_tests
|
|
|
102
146
|
| `manage_environment` | CRUD for environments |
|
|
103
147
|
| `manage_server` | Start/stop WebUI server |
|
|
104
148
|
| `ci_init` | Generate CI/CD workflow (GitHub Actions / GitLab CI) |
|
|
149
|
+
| `set_work_dir` | Set project root for the session (call FIRST with npx MCP) |
|
|
150
|
+
| `describe_endpoint` | Full details for one endpoint: params, schemas, response headers, security |
|
|
105
151
|
|
|
106
152
|
## CLI Commands
|
|
107
153
|
|
package/package.json
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
const MOCKED_FILES = [
|
|
7
7
|
"tests/agent/tools/diagnose-failure.test.ts",
|
|
8
8
|
"tests/agent/tools/explore-api.test.ts",
|
|
9
|
-
"tests/agent/tools/generate-tests.test.ts",
|
|
10
9
|
"tests/agent/tools/manage-environment.test.ts",
|
|
11
10
|
"tests/agent/tools/query-results.test.ts",
|
|
12
11
|
"tests/agent/tools/run-tests.test.ts",
|
|
@@ -15,7 +14,6 @@ const MOCKED_FILES = [
|
|
|
15
14
|
"tests/mcp/coverage-analysis.test.ts",
|
|
16
15
|
"tests/mcp/explore-api.test.ts",
|
|
17
16
|
"tests/mcp/send-request.test.ts",
|
|
18
|
-
"tests/cli/request.test.ts",
|
|
19
17
|
"tests/cli/coverage.test.ts",
|
|
20
18
|
];
|
|
21
19
|
|
|
@@ -33,43 +33,80 @@ jobs:
|
|
|
33
33
|
- name: Install apitool
|
|
34
34
|
run: curl -fsSL https://raw.githubusercontent.com/kirrosh/apitool/master/install.sh | sh
|
|
35
35
|
|
|
36
|
-
- name:
|
|
36
|
+
- name: Check coverage
|
|
37
|
+
run: apitool 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)
|
|
37
41
|
run: |
|
|
38
42
|
mkdir -p test-results
|
|
39
|
-
apitool run apis/ --report junit --no-db > test-results/
|
|
40
|
-
#
|
|
43
|
+
apitool 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
|
+
apitool 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
|
|
41
51
|
continue-on-error: true
|
|
42
52
|
|
|
43
53
|
- name: Publish test results
|
|
44
54
|
uses: EnricoMi/publish-unit-test-result-action@v2
|
|
45
55
|
if: always()
|
|
46
56
|
with:
|
|
47
|
-
files: test-results
|
|
57
|
+
files: test-results/*.xml
|
|
48
58
|
|
|
49
59
|
- uses: actions/upload-artifact@v4
|
|
50
60
|
if: always()
|
|
51
61
|
with:
|
|
52
62
|
name: test-results
|
|
53
|
-
path: test-results/
|
|
63
|
+
path: test-results/
|
|
54
64
|
`;
|
|
55
65
|
|
|
56
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
|
|
57
67
|
|
|
58
|
-
|
|
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/apitool/master/install.sh | sh
|
|
77
|
+
script:
|
|
78
|
+
- apitool 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/apitool/master/install.sh | sh
|
|
85
|
+
script:
|
|
86
|
+
- mkdir -p test-results
|
|
87
|
+
# Use --env-var to inject secrets without writing to disk
|
|
88
|
+
- apitool 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:
|
|
59
97
|
image: ubuntu:latest
|
|
60
98
|
before_script:
|
|
61
99
|
- apt-get update -qq && apt-get install -y -qq curl
|
|
62
100
|
- curl -fsSL https://raw.githubusercontent.com/kirrosh/apitool/master/install.sh | sh
|
|
63
101
|
script:
|
|
64
102
|
- mkdir -p test-results
|
|
65
|
-
- apitool run apis/ --report junit --no-db > test-results/
|
|
66
|
-
# Add --env <name> to load .env.<name>.yaml from test directory
|
|
103
|
+
- apitool run apis/ --tag crud --env staging --report junit --no-db > test-results/crud.xml
|
|
67
104
|
allow_failure:
|
|
68
105
|
exit_codes: 1
|
|
69
106
|
artifacts:
|
|
70
107
|
when: always
|
|
71
108
|
reports:
|
|
72
|
-
junit: test-results/
|
|
109
|
+
junit: test-results/crud.xml
|
|
73
110
|
`;
|
|
74
111
|
|
|
75
112
|
function writeIfMissing(filePath: string, content: string, force: boolean): boolean {
|
|
@@ -86,10 +123,10 @@ function writeIfMissing(filePath: string, content: string, force: boolean): bool
|
|
|
86
123
|
return true;
|
|
87
124
|
}
|
|
88
125
|
|
|
89
|
-
function detectPlatform(cwd: string): "github" | "gitlab" |
|
|
126
|
+
function detectPlatform(cwd: string): "github" | "gitlab" | undefined {
|
|
90
127
|
if (existsSync(resolve(cwd, ".github"))) return "github";
|
|
91
128
|
if (existsSync(resolve(cwd, ".gitlab-ci.yml"))) return "gitlab";
|
|
92
|
-
return
|
|
129
|
+
return undefined;
|
|
93
130
|
}
|
|
94
131
|
|
|
95
132
|
export async function ciInitCommand(options: CiInitOptions): Promise<number> {
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,7 @@ import { printError, printSuccess } from "../output.ts";
|
|
|
4
4
|
export interface CoverageOptions {
|
|
5
5
|
spec: string;
|
|
6
6
|
tests: string;
|
|
7
|
+
failOnCoverage?: number;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
const RESET = "\x1b[0m";
|
|
@@ -57,6 +58,9 @@ export async function coverageCommand(options: CoverageOptions): Promise<number>
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
if (options.failOnCoverage !== undefined) {
|
|
62
|
+
return percentage < options.failOnCoverage ? 1 : 0;
|
|
63
|
+
}
|
|
60
64
|
return uncovered.length > 0 ? 1 : 0;
|
|
61
65
|
} catch (err) {
|
|
62
66
|
printError(err instanceof Error ? err.message : String(err));
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dirname } from "path";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
2
3
|
import { parse } from "../../core/parser/yaml-parser.ts";
|
|
3
4
|
import { loadEnvironment } from "../../core/parser/variables.ts";
|
|
4
5
|
import { filterSuitesByTags } from "../../core/parser/filter.ts";
|
|
@@ -22,6 +23,8 @@ export interface RunOptions {
|
|
|
22
23
|
authToken?: string;
|
|
23
24
|
safe?: boolean;
|
|
24
25
|
tag?: string[];
|
|
26
|
+
envVars?: string[];
|
|
27
|
+
dryRun?: boolean;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export async function runCommand(options: RunOptions): Promise<number> {
|
|
@@ -61,12 +64,16 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
// 2. Load environment (resolve collection for scoped envs)
|
|
64
|
-
|
|
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);
|
|
65
70
|
let collectionForEnv: { id: number } | null = null;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
if (!options.noDb) {
|
|
72
|
+
try {
|
|
73
|
+
getDb(options.dbPath);
|
|
74
|
+
collectionForEnv = findCollectionByTestPath(options.path);
|
|
75
|
+
} catch { /* DB not available — OK */ }
|
|
76
|
+
}
|
|
70
77
|
|
|
71
78
|
let env: Record<string, string> = {};
|
|
72
79
|
try {
|
|
@@ -81,6 +88,16 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
81
88
|
env.auth_token = options.authToken;
|
|
82
89
|
}
|
|
83
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
|
+
|
|
84
101
|
// Warn if --env was explicitly set but file was not found (empty env)
|
|
85
102
|
if (options.env && Object.keys(env).length === 0) {
|
|
86
103
|
printWarning(`Environment file .env.${options.env}.yaml not found in ${searchDir}`);
|
|
@@ -95,18 +112,19 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
95
112
|
|
|
96
113
|
// 4. Run suites
|
|
97
114
|
const results: TestRunResult[] = [];
|
|
115
|
+
const dryRun = options.dryRun === true;
|
|
98
116
|
if (options.bail) {
|
|
99
117
|
// Sequential with bail at suite level
|
|
100
118
|
for (const suite of suites) {
|
|
101
|
-
const result = await runSuite(suite, env);
|
|
119
|
+
const result = await runSuite(suite, env, dryRun);
|
|
102
120
|
results.push(result);
|
|
103
|
-
if (result.failed > 0 || result.steps.some((s) => s.status === "error")) {
|
|
121
|
+
if (!dryRun && (result.failed > 0 || result.steps.some((s) => s.status === "error"))) {
|
|
104
122
|
break;
|
|
105
123
|
}
|
|
106
124
|
}
|
|
107
125
|
} else {
|
|
108
126
|
// Parallel
|
|
109
|
-
const all = await Promise.all(suites.map((suite) => runSuite(suite, env)));
|
|
127
|
+
const all = await Promise.all(suites.map((suite) => runSuite(suite, env, dryRun)));
|
|
110
128
|
results.push(...all);
|
|
111
129
|
}
|
|
112
130
|
|
|
@@ -131,7 +149,8 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
131
149
|
}
|
|
132
150
|
}
|
|
133
151
|
|
|
134
|
-
// 7. Exit code
|
|
152
|
+
// 7. Exit code (always 0 in dry-run mode)
|
|
153
|
+
if (dryRun) return 0;
|
|
135
154
|
const hasFailures = results.some((r) => r.failed > 0 || r.steps.some((s) => s.status === "error"));
|
|
136
155
|
return hasFailures ? 1 : 0;
|
|
137
156
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -15,13 +15,14 @@ import { coverageCommand } from "./commands/coverage.ts";
|
|
|
15
15
|
import { doctorCommand } from "./commands/doctor.ts";
|
|
16
16
|
import { addApiCommand } from "./commands/add-api.ts";
|
|
17
17
|
import { ciInitCommand } from "./commands/ci-init.ts";
|
|
18
|
+
import { compareCommand } from "./commands/compare.ts";
|
|
18
19
|
import { printError } from "./output.ts";
|
|
19
20
|
import { getRuntimeInfo } from "./runtime.ts";
|
|
20
21
|
import { getDb } from "../db/schema.ts";
|
|
21
22
|
import { findCollectionByNameOrId } from "../db/queries.ts";
|
|
22
23
|
import type { ReporterName } from "../core/reporter/types.ts";
|
|
23
24
|
|
|
24
|
-
export const VERSION = "0.
|
|
25
|
+
export const VERSION = "0.5.0";
|
|
25
26
|
|
|
26
27
|
export interface ParsedArgs {
|
|
27
28
|
command: string | undefined;
|
|
@@ -88,6 +89,7 @@ Usage:
|
|
|
88
89
|
apitool mcp Start MCP server (stdio transport for AI agents)
|
|
89
90
|
--dir <path> Set working directory (relative paths resolve here)
|
|
90
91
|
apitool chat Start interactive AI chat for API testing
|
|
92
|
+
apitool compare <runA> <runB> Compare two test runs (regressions/fixes)
|
|
91
93
|
apitool doctor Run diagnostic checks
|
|
92
94
|
apitool update Update to latest version
|
|
93
95
|
|
|
@@ -117,12 +119,19 @@ Options for 'runs':
|
|
|
117
119
|
runs <id> Show run details with step results
|
|
118
120
|
--limit <n> Number of runs to show (default: 20)
|
|
119
121
|
|
|
122
|
+
Options for 'compare':
|
|
123
|
+
compare <runA> <runB> Compare two run IDs
|
|
124
|
+
Exit code 1 if regressions found, 0 otherwise
|
|
125
|
+
|
|
120
126
|
Options for 'coverage':
|
|
121
127
|
--api <name> Use API collection (auto-resolves spec and tests dir)
|
|
122
128
|
--spec <path> Path to OpenAPI spec (required unless --api used)
|
|
123
129
|
--tests <dir> Path to test files directory (required unless --api used)
|
|
130
|
+
--fail-on-coverage N Exit 1 when coverage percentage is below N (0–100)
|
|
124
131
|
|
|
125
132
|
Options for 'run':
|
|
133
|
+
--dry-run Show requests without sending them (exit code always 0)
|
|
134
|
+
--env-var KEY=VALUE Inject env variable (repeatable, overrides env file)
|
|
126
135
|
--api <name> Use API collection (resolves test path automatically)
|
|
127
136
|
--env <name> Use environment file (.env.<name>.yaml)
|
|
128
137
|
--report <format> Output format: console, json, junit (default: console)
|
|
@@ -248,15 +257,22 @@ async function main(): Promise<number> {
|
|
|
248
257
|
}
|
|
249
258
|
}
|
|
250
259
|
|
|
251
|
-
// Collect all --tag flags (parseArgs only stores last one, so re-parse)
|
|
260
|
+
// Collect all --tag and --env-var flags (parseArgs only stores last one, so re-parse)
|
|
252
261
|
const tagValues: string[] = [];
|
|
262
|
+
const envVarValues: string[] = [];
|
|
253
263
|
const rawRunArgs = process.argv.slice(2);
|
|
254
264
|
for (let i = 0; i < rawRunArgs.length; i++) {
|
|
255
|
-
|
|
265
|
+
const arg = rawRunArgs[i]!;
|
|
266
|
+
if (arg === "--tag" && rawRunArgs[i + 1]) {
|
|
256
267
|
tagValues.push(rawRunArgs[i + 1]!);
|
|
257
268
|
i++;
|
|
258
|
-
} else if (
|
|
259
|
-
tagValues.push(
|
|
269
|
+
} else if (arg.startsWith("--tag=")) {
|
|
270
|
+
tagValues.push(arg.slice("--tag=".length));
|
|
271
|
+
} else if (arg === "--env-var" && rawRunArgs[i + 1]) {
|
|
272
|
+
envVarValues.push(rawRunArgs[i + 1]!);
|
|
273
|
+
i++;
|
|
274
|
+
} else if (arg.startsWith("--env-var=")) {
|
|
275
|
+
envVarValues.push(arg.slice("--env-var=".length));
|
|
260
276
|
}
|
|
261
277
|
}
|
|
262
278
|
// Support comma-separated: --tag smoke,crud → ["smoke", "crud"]
|
|
@@ -273,6 +289,8 @@ async function main(): Promise<number> {
|
|
|
273
289
|
authToken: typeof flags["auth-token"] === "string" ? flags["auth-token"] : undefined,
|
|
274
290
|
safe: flags["safe"] === true,
|
|
275
291
|
tag: tags.length > 0 ? tags : undefined,
|
|
292
|
+
envVars: envVarValues.length > 0 ? envVarValues : undefined,
|
|
293
|
+
dryRun: flags["dry-run"] === true,
|
|
276
294
|
});
|
|
277
295
|
}
|
|
278
296
|
|
|
@@ -440,6 +458,26 @@ async function main(): Promise<number> {
|
|
|
440
458
|
});
|
|
441
459
|
}
|
|
442
460
|
|
|
461
|
+
case "compare": {
|
|
462
|
+
const rawA = positional[0];
|
|
463
|
+
const rawB = positional[1];
|
|
464
|
+
if (!rawA || !rawB) {
|
|
465
|
+
printError("Usage: apitool compare <runA> <runB>");
|
|
466
|
+
return 2;
|
|
467
|
+
}
|
|
468
|
+
const runA = parseInt(rawA, 10);
|
|
469
|
+
const runB = parseInt(rawB, 10);
|
|
470
|
+
if (isNaN(runA) || isNaN(runB)) {
|
|
471
|
+
printError("Run IDs must be integers");
|
|
472
|
+
return 2;
|
|
473
|
+
}
|
|
474
|
+
return compareCommand({
|
|
475
|
+
runA,
|
|
476
|
+
runB,
|
|
477
|
+
dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
443
481
|
case "doctor": {
|
|
444
482
|
return doctorCommand({
|
|
445
483
|
dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
|
|
@@ -472,7 +510,16 @@ async function main(): Promise<number> {
|
|
|
472
510
|
printError("Missing --tests <dir>. Usage: apitool coverage --spec <path> --tests <dir>");
|
|
473
511
|
return 2;
|
|
474
512
|
}
|
|
475
|
-
|
|
513
|
+
const failOnCoverageRaw = flags["fail-on-coverage"];
|
|
514
|
+
let failOnCoverage: number | undefined;
|
|
515
|
+
if (typeof failOnCoverageRaw === "string") {
|
|
516
|
+
failOnCoverage = parseInt(failOnCoverageRaw, 10);
|
|
517
|
+
if (isNaN(failOnCoverage) || failOnCoverage < 0 || failOnCoverage > 100) {
|
|
518
|
+
printError(`Invalid --fail-on-coverage value: ${failOnCoverageRaw} (must be 0–100)`);
|
|
519
|
+
return 2;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return coverageCommand({ spec, tests, failOnCoverage });
|
|
476
523
|
}
|
|
477
524
|
|
|
478
525
|
default: {
|
|
@@ -10,7 +10,7 @@ export async function readOpenApiSpec(specPath: string): Promise<OpenAPIV3.Docum
|
|
|
10
10
|
const resp = await fetch(specPath);
|
|
11
11
|
if (!resp.ok) throw new Error(`Failed to fetch spec: ${resp.status} ${resp.statusText}`);
|
|
12
12
|
const spec = await resp.json();
|
|
13
|
-
const api = await dereference(spec);
|
|
13
|
+
const api = await dereference(spec as string);
|
|
14
14
|
return api as OpenAPIV3.Document;
|
|
15
15
|
}
|
|
16
16
|
const api = await dereference(specPath);
|
package/src/core/parser/types.ts
CHANGED
|
@@ -19,7 +19,7 @@ export async function parseFile(filePath: string): Promise<TestSuite> {
|
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
21
|
const suite = validateSuite(raw);
|
|
22
|
-
|
|
22
|
+
suite.filePath = filePath;
|
|
23
23
|
return suite;
|
|
24
24
|
} catch (err) {
|
|
25
25
|
throw new Error(`Validation error in ${filePath}: ${(err as Error).message}`);
|
|
@@ -15,6 +15,8 @@ export interface ExecuteRunOptions {
|
|
|
15
15
|
dbPath?: string;
|
|
16
16
|
safe?: boolean;
|
|
17
17
|
tag?: string[];
|
|
18
|
+
envVars?: Record<string, string>;
|
|
19
|
+
dryRun?: boolean;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export interface ExecuteRunResult {
|
|
@@ -50,7 +52,8 @@ export async function executeRun(options: ExecuteRunOptions): Promise<ExecuteRun
|
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
const fileStat = await stat(testPath).catch(() => null);
|
|
53
|
-
const
|
|
55
|
+
const isDirectory = fileStat?.isDirectory() ?? false;
|
|
56
|
+
const envDir = isDirectory ? testPath : dirname(testPath);
|
|
54
57
|
|
|
55
58
|
getDb(dbPath);
|
|
56
59
|
const resolvedPath = resolve(testPath);
|
|
@@ -59,8 +62,28 @@ export async function executeRun(options: ExecuteRunOptions): Promise<ExecuteRun
|
|
|
59
62
|
|
|
60
63
|
// If no envName given but a collection exists, fall back to "default" for DB lookup
|
|
61
64
|
const effectiveEnvName = envName ?? (collection ? "default" : undefined);
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
|
|
66
|
+
// Helper: load env with optional --env-var overrides merged on top
|
|
67
|
+
async function loadEnvWithOverrides(dir: string): Promise<Record<string, string>> {
|
|
68
|
+
const env = await loadEnvironment(effectiveEnvName, dir, collection?.id);
|
|
69
|
+
if (options.envVars && Object.keys(options.envVars).length > 0) {
|
|
70
|
+
Object.assign(env, options.envVars);
|
|
71
|
+
}
|
|
72
|
+
return env;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let results: Awaited<ReturnType<typeof runSuite>>[];
|
|
76
|
+
if (isDirectory) {
|
|
77
|
+
// Per-suite env: load env from each suite's own directory
|
|
78
|
+
results = await Promise.all(suites.map(async (s) => {
|
|
79
|
+
const suiteDir = s.filePath ? dirname(s.filePath) : envDir;
|
|
80
|
+
const env = await loadEnvWithOverrides(suiteDir);
|
|
81
|
+
return runSuite(s, env, options.dryRun);
|
|
82
|
+
}));
|
|
83
|
+
} else {
|
|
84
|
+
const env = await loadEnvWithOverrides(envDir);
|
|
85
|
+
results = await Promise.all(suites.map((s) => runSuite(s, env, options.dryRun)));
|
|
86
|
+
}
|
|
64
87
|
|
|
65
88
|
const runId = createRun({
|
|
66
89
|
started_at: results[0]?.started_at ?? new Date().toISOString(),
|