@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 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 (15 tools)
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
- 15 tools for AI agent integration. Primary test generation flow:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirrosh/apitool",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "API testing platform — define tests in YAML, run from CLI or WebUI, generate from OpenAPI specs",
5
5
  "license": "MIT",
6
6
  "module": "index.ts",
@@ -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: Run tests
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/junit.xml
40
- # Add --env <name> to load .env.<name>.yaml from test directory
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/junit.xml
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/junit.xml
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
- api-tests:
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/junit.xml
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/junit.xml
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" | null {
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 null;
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));
@@ -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
- const searchDir = dirname(options.path);
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
- try {
67
- getDb(options.dbPath);
68
- collectionForEnv = findCollectionByTestPath(options.path);
69
- } catch { /* DB not available — OK */ }
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.3.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
- if (rawRunArgs[i] === "--tag" && rawRunArgs[i + 1]) {
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 (rawRunArgs[i]?.startsWith("--tag=")) {
259
- tagValues.push(rawRunArgs[i]!.slice("--tag=".length));
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
- return coverageCommand({ spec, tests });
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);
@@ -45,6 +45,8 @@ export interface TestSuite {
45
45
  headers?: Record<string, string>;
46
46
  config: SuiteConfig;
47
47
  tests: TestStep[];
48
+ /** Absolute path to the source file, set by yaml-parser */
49
+ filePath?: string;
48
50
  }
49
51
 
50
52
  export type Environment = Record<string, string>;
@@ -19,7 +19,7 @@ export async function parseFile(filePath: string): Promise<TestSuite> {
19
19
 
20
20
  try {
21
21
  const suite = validateSuite(raw);
22
- (suite as any)._source = filePath;
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 envDir = fileStat?.isDirectory() ? testPath : dirname(testPath);
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
- const env = await loadEnvironment(effectiveEnvName, envDir, collection?.id);
63
- const results = await Promise.all(suites.map((s) => runSuite(s, env)));
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(),