@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,316 @@
|
|
|
1
|
+
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { existsSync, unlinkSync } from "fs";
|
|
5
|
+
import { runCommand } from "../../src/cli/commands/run.ts";
|
|
6
|
+
import { validateCommand } from "../../src/cli/commands/validate.ts";
|
|
7
|
+
import { parseArgs } from "../../src/cli/index.ts";
|
|
8
|
+
import { closeDb } from "../../src/db/schema.ts";
|
|
9
|
+
|
|
10
|
+
function tryUnlink(path: string): void {
|
|
11
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
12
|
+
try { unlinkSync(path + suffix); } catch { /* ignore on Windows */ }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const FIXTURES = `${import.meta.dir}/../fixtures`;
|
|
17
|
+
const originalFetch = globalThis.fetch;
|
|
18
|
+
|
|
19
|
+
// Helper to mock fetch responses
|
|
20
|
+
function mockFetchResponses(responses: Array<{ status: number; body: unknown; headers?: Record<string, string> }>) {
|
|
21
|
+
let callIndex = 0;
|
|
22
|
+
globalThis.fetch = mock(async () => {
|
|
23
|
+
const resp = responses[callIndex++] ?? { status: 500, body: { error: "unexpected call" } };
|
|
24
|
+
return new Response(JSON.stringify(resp.body), {
|
|
25
|
+
status: resp.status,
|
|
26
|
+
headers: { "Content-Type": "application/json", ...resp.headers },
|
|
27
|
+
});
|
|
28
|
+
}) as unknown as typeof fetch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function restoreFetch() {
|
|
32
|
+
globalThis.fetch = originalFetch;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Suppress stdout/stderr during tests
|
|
36
|
+
function suppressOutput() {
|
|
37
|
+
const origOut = process.stdout.write;
|
|
38
|
+
const origErr = process.stderr.write;
|
|
39
|
+
process.stdout.write = mock(() => true) as typeof process.stdout.write;
|
|
40
|
+
process.stderr.write = mock(() => true) as typeof process.stderr.write;
|
|
41
|
+
return () => {
|
|
42
|
+
process.stdout.write = origOut;
|
|
43
|
+
process.stderr.write = origErr;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tmpDb(): string {
|
|
48
|
+
return join(tmpdir(), `apitool-cmd-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("runCommand", () => {
|
|
52
|
+
let restore: () => void;
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
restore?.();
|
|
56
|
+
restoreFetch();
|
|
57
|
+
closeDb();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("returns 0 when all tests pass", async () => {
|
|
61
|
+
mockFetchResponses([{ status: 200, body: { id: 1 } }]);
|
|
62
|
+
restore = suppressOutput();
|
|
63
|
+
|
|
64
|
+
const code = await runCommand({
|
|
65
|
+
path: `${FIXTURES}/simple.yaml`,
|
|
66
|
+
report: "console",
|
|
67
|
+
bail: false,
|
|
68
|
+
noDb: true,
|
|
69
|
+
});
|
|
70
|
+
expect(code).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns 1 when a test fails", async () => {
|
|
74
|
+
// simple.yaml expects status 200, we return 500
|
|
75
|
+
mockFetchResponses([{ status: 500, body: { error: "fail" } }]);
|
|
76
|
+
restore = suppressOutput();
|
|
77
|
+
|
|
78
|
+
const code = await runCommand({
|
|
79
|
+
path: `${FIXTURES}/simple.yaml`,
|
|
80
|
+
report: "console",
|
|
81
|
+
bail: false,
|
|
82
|
+
noDb: true,
|
|
83
|
+
});
|
|
84
|
+
expect(code).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("returns 2 for invalid path", async () => {
|
|
88
|
+
restore = suppressOutput();
|
|
89
|
+
|
|
90
|
+
const code = await runCommand({
|
|
91
|
+
path: `${FIXTURES}/nonexistent.yaml`,
|
|
92
|
+
report: "console",
|
|
93
|
+
bail: false,
|
|
94
|
+
noDb: true,
|
|
95
|
+
});
|
|
96
|
+
expect(code).toBe(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("--timeout overrides suite config", async () => {
|
|
100
|
+
mockFetchResponses([{ status: 200, body: {} }]);
|
|
101
|
+
restore = suppressOutput();
|
|
102
|
+
|
|
103
|
+
const code = await runCommand({
|
|
104
|
+
path: `${FIXTURES}/simple.yaml`,
|
|
105
|
+
report: "json",
|
|
106
|
+
timeout: 1000,
|
|
107
|
+
bail: false,
|
|
108
|
+
noDb: true,
|
|
109
|
+
});
|
|
110
|
+
expect(code).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("works with json reporter", async () => {
|
|
114
|
+
mockFetchResponses([{ status: 200, body: {} }]);
|
|
115
|
+
|
|
116
|
+
let output = "";
|
|
117
|
+
const origLog = console.log;
|
|
118
|
+
const origErr = process.stderr.write;
|
|
119
|
+
console.log = mock((...args: unknown[]) => {
|
|
120
|
+
output += args.map(String).join(" ") + "\n";
|
|
121
|
+
});
|
|
122
|
+
process.stderr.write = mock(() => true) as typeof process.stderr.write;
|
|
123
|
+
restore = () => {
|
|
124
|
+
console.log = origLog;
|
|
125
|
+
process.stderr.write = origErr;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const code = await runCommand({
|
|
129
|
+
path: `${FIXTURES}/simple.yaml`,
|
|
130
|
+
report: "json",
|
|
131
|
+
bail: false,
|
|
132
|
+
noDb: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(code).toBe(0);
|
|
136
|
+
const parsed = JSON.parse(output.trim());
|
|
137
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
138
|
+
expect(parsed[0].suite_name).toBe("Health Check");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("works with junit reporter", async () => {
|
|
142
|
+
mockFetchResponses([{ status: 200, body: {} }]);
|
|
143
|
+
|
|
144
|
+
let output = "";
|
|
145
|
+
const origLog = console.log;
|
|
146
|
+
const origErr = process.stderr.write;
|
|
147
|
+
console.log = mock((...args: unknown[]) => {
|
|
148
|
+
output += args.map(String).join(" ") + "\n";
|
|
149
|
+
});
|
|
150
|
+
process.stderr.write = mock(() => true) as typeof process.stderr.write;
|
|
151
|
+
restore = () => {
|
|
152
|
+
console.log = origLog;
|
|
153
|
+
process.stderr.write = origErr;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const code = await runCommand({
|
|
157
|
+
path: `${FIXTURES}/simple.yaml`,
|
|
158
|
+
report: "junit",
|
|
159
|
+
bail: false,
|
|
160
|
+
noDb: true,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(code).toBe(0);
|
|
164
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
|
165
|
+
expect(output).toContain("<testsuites");
|
|
166
|
+
expect(output).toContain("Health Check");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("bail stops after first failed suite", async () => {
|
|
170
|
+
// bail/ directory has 2 YAML files, each expects status 200
|
|
171
|
+
// We return 500 so both would fail, but bail should stop after first
|
|
172
|
+
let fetchCallCount = 0;
|
|
173
|
+
globalThis.fetch = mock(async () => {
|
|
174
|
+
fetchCallCount++;
|
|
175
|
+
return new Response(JSON.stringify({ error: "fail" }), {
|
|
176
|
+
status: 500,
|
|
177
|
+
headers: { "Content-Type": "application/json" },
|
|
178
|
+
});
|
|
179
|
+
}) as unknown as typeof fetch;
|
|
180
|
+
|
|
181
|
+
restore = suppressOutput();
|
|
182
|
+
|
|
183
|
+
const code = await runCommand({
|
|
184
|
+
path: `${FIXTURES}/bail`,
|
|
185
|
+
report: "console",
|
|
186
|
+
bail: true,
|
|
187
|
+
noDb: true,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(code).toBe(1);
|
|
191
|
+
// With bail, only the first suite should have run
|
|
192
|
+
expect(fetchCallCount).toBe(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("saves results to DB when noDb is false", async () => {
|
|
196
|
+
mockFetchResponses([{ status: 200, body: {} }]);
|
|
197
|
+
const db = tmpDb();
|
|
198
|
+
restore = suppressOutput();
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const code = await runCommand({
|
|
202
|
+
path: `${FIXTURES}/simple.yaml`,
|
|
203
|
+
report: "console",
|
|
204
|
+
bail: false,
|
|
205
|
+
noDb: false,
|
|
206
|
+
dbPath: db,
|
|
207
|
+
});
|
|
208
|
+
expect(code).toBe(0);
|
|
209
|
+
expect(existsSync(db)).toBe(true);
|
|
210
|
+
|
|
211
|
+
const { getDb } = await import("../../src/db/schema.ts");
|
|
212
|
+
const runs = getDb(db).query("SELECT * FROM runs").all();
|
|
213
|
+
expect(runs).toHaveLength(1);
|
|
214
|
+
} finally {
|
|
215
|
+
closeDb();
|
|
216
|
+
tryUnlink(db);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("--no-db skips DB creation", async () => {
|
|
221
|
+
mockFetchResponses([{ status: 200, body: {} }]);
|
|
222
|
+
const db = tmpDb();
|
|
223
|
+
restore = suppressOutput();
|
|
224
|
+
|
|
225
|
+
const code = await runCommand({
|
|
226
|
+
path: `${FIXTURES}/simple.yaml`,
|
|
227
|
+
report: "console",
|
|
228
|
+
bail: false,
|
|
229
|
+
noDb: true,
|
|
230
|
+
dbPath: db,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(code).toBe(0);
|
|
234
|
+
expect(existsSync(db)).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("validateCommand", () => {
|
|
239
|
+
let restore: () => void;
|
|
240
|
+
|
|
241
|
+
afterEach(() => {
|
|
242
|
+
restore?.();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("returns 0 for valid YAML", async () => {
|
|
246
|
+
restore = suppressOutput();
|
|
247
|
+
const code = await validateCommand({ path: `${FIXTURES}/simple.yaml` });
|
|
248
|
+
expect(code).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("returns 2 for invalid YAML", async () => {
|
|
252
|
+
restore = suppressOutput();
|
|
253
|
+
const code = await validateCommand({ path: `${FIXTURES}/invalid-missing-name.yaml` });
|
|
254
|
+
expect(code).toBe(2);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("returns 0 for valid directory", async () => {
|
|
258
|
+
restore = suppressOutput();
|
|
259
|
+
const code = await validateCommand({ path: `${FIXTURES}/valid` });
|
|
260
|
+
expect(code).toBe(0);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("returns 2 for nonexistent path", async () => {
|
|
264
|
+
restore = suppressOutput();
|
|
265
|
+
const code = await validateCommand({ path: `${FIXTURES}/nonexistent.yaml` });
|
|
266
|
+
expect(code).toBe(2);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("parseArgs", () => {
|
|
271
|
+
test("parses --auth-token flag", () => {
|
|
272
|
+
const result = parseArgs(["bun", "script.ts", "run", "tests/", "--auth-token", "my-secret-token"]);
|
|
273
|
+
expect(result.command).toBe("run");
|
|
274
|
+
expect(result.positional).toEqual(["tests/"]);
|
|
275
|
+
expect(result.flags["auth-token"]).toBe("my-secret-token");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("parses --auth-token with = syntax", () => {
|
|
279
|
+
const result = parseArgs(["bun", "script.ts", "run", "tests/", "--auth-token=my-token"]);
|
|
280
|
+
expect(result.flags["auth-token"]).toBe("my-token");
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("runCommand with --auth-token", () => {
|
|
285
|
+
let restore: () => void;
|
|
286
|
+
|
|
287
|
+
afterEach(() => {
|
|
288
|
+
restore?.();
|
|
289
|
+
restoreFetch();
|
|
290
|
+
closeDb();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("auth token is injected into requests via env", async () => {
|
|
294
|
+
let capturedHeaders: Record<string, string> = {};
|
|
295
|
+
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
296
|
+
capturedHeaders = Object.fromEntries(Object.entries(init?.headers ?? {}));
|
|
297
|
+
return new Response(JSON.stringify({ status: "ok" }), {
|
|
298
|
+
status: 200,
|
|
299
|
+
headers: { "Content-Type": "application/json" },
|
|
300
|
+
});
|
|
301
|
+
}) as unknown as typeof fetch;
|
|
302
|
+
restore = suppressOutput();
|
|
303
|
+
|
|
304
|
+
const code = await runCommand({
|
|
305
|
+
path: `${FIXTURES}/auth-token-test.yaml`,
|
|
306
|
+
report: "console",
|
|
307
|
+
bail: false,
|
|
308
|
+
noDb: true,
|
|
309
|
+
authToken: "test-jwt-token-123",
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// The test file should use {{auth_token}} in Authorization header
|
|
313
|
+
expect(capturedHeaders["Authorization"]).toBe("Bearer test-jwt-token-123");
|
|
314
|
+
expect(code).toBe(0);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// Mock the generator modules
|
|
4
|
+
mock.module("../../src/core/generator/openapi-reader.ts", () => ({
|
|
5
|
+
readOpenApiSpec: mock(() => Promise.resolve({
|
|
6
|
+
info: { title: "Test API", version: "1.0" },
|
|
7
|
+
paths: {},
|
|
8
|
+
})),
|
|
9
|
+
extractEndpoints: mock(() => [
|
|
10
|
+
{ method: "GET", path: "/users", tags: [], parameters: [], responses: [] },
|
|
11
|
+
{ method: "POST", path: "/users", tags: [], parameters: [], responses: [] },
|
|
12
|
+
{ method: "GET", path: "/users/{id}", tags: [], parameters: [], responses: [] },
|
|
13
|
+
]),
|
|
14
|
+
extractSecuritySchemes: mock(() => []),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
mock.module("../../src/core/generator/coverage-scanner.ts", () => ({
|
|
18
|
+
scanCoveredEndpoints: mock(() => Promise.resolve([
|
|
19
|
+
{ method: "GET", path: "/users", file: "tests/users.yaml" },
|
|
20
|
+
])),
|
|
21
|
+
filterUncoveredEndpoints: mock((all: any[], covered: any[]) => {
|
|
22
|
+
// Simulate 2 uncovered
|
|
23
|
+
return all.slice(1);
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
afterAll(() => { mock.restore(); });
|
|
28
|
+
|
|
29
|
+
import { coverageCommand } from "../../src/cli/commands/coverage.ts";
|
|
30
|
+
import { readOpenApiSpec, extractEndpoints } from "../../src/core/generator/openapi-reader.ts";
|
|
31
|
+
import { scanCoveredEndpoints } from "../../src/core/generator/coverage-scanner.ts";
|
|
32
|
+
|
|
33
|
+
describe("coverageCommand", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
(readOpenApiSpec as ReturnType<typeof mock>).mockClear();
|
|
36
|
+
(extractEndpoints as ReturnType<typeof mock>).mockClear();
|
|
37
|
+
(scanCoveredEndpoints as ReturnType<typeof mock>).mockClear();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns 1 when there are uncovered endpoints", async () => {
|
|
41
|
+
const code = await coverageCommand({ spec: "spec.yaml", tests: "./tests" });
|
|
42
|
+
expect(code).toBe(1);
|
|
43
|
+
expect(readOpenApiSpec).toHaveBeenCalledWith("spec.yaml");
|
|
44
|
+
expect(scanCoveredEndpoints).toHaveBeenCalledWith("./tests");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returns 2 on error", async () => {
|
|
48
|
+
(readOpenApiSpec as ReturnType<typeof mock>).mockRejectedValueOnce(new Error("File not found"));
|
|
49
|
+
const code = await coverageCommand({ spec: "bad.yaml", tests: "./tests" });
|
|
50
|
+
expect(code).toBe(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns 1 when no endpoints in spec", async () => {
|
|
54
|
+
(extractEndpoints as ReturnType<typeof mock>).mockReturnValueOnce([]);
|
|
55
|
+
const code = await coverageCommand({ spec: "empty.yaml", tests: "./tests" });
|
|
56
|
+
expect(code).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "bun:test";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { unlinkSync } from "fs";
|
|
5
|
+
import { closeDb } from "../../src/db/schema.ts";
|
|
6
|
+
import { doctorCommand } from "../../src/cli/commands/doctor.ts";
|
|
7
|
+
|
|
8
|
+
function tmpDb(): string {
|
|
9
|
+
return join(tmpdir(), `apitool-doctor-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function tryUnlink(path: string): void {
|
|
13
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
14
|
+
try { unlinkSync(path + suffix); } catch { /* ignore */ }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("doctorCommand", () => {
|
|
19
|
+
let dbPath: string;
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
closeDb();
|
|
23
|
+
if (dbPath) tryUnlink(dbPath);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("runs with valid db path and returns 0 or 1", async () => {
|
|
27
|
+
dbPath = tmpDb();
|
|
28
|
+
const code = await doctorCommand({ dbPath });
|
|
29
|
+
// Should succeed (0) or have non-fatal warnings (1) — never crash
|
|
30
|
+
expect(code === 0 || code === 1).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("handles invalid db path gracefully", async () => {
|
|
34
|
+
dbPath = "/nonexistent/path/to/db.sqlite";
|
|
35
|
+
const code = await doctorCommand({ dbPath });
|
|
36
|
+
// Database check will fail, so code should be 1
|
|
37
|
+
expect(code).toBe(1);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { unlinkSync, writeFileSync } from "fs";
|
|
5
|
+
import { getDb, closeDb } from "../../src/db/schema.ts";
|
|
6
|
+
import { upsertEnvironment, getEnvironment } from "../../src/db/queries.ts";
|
|
7
|
+
import { envsCommand, parseKeyValuePairs, parseYamlEnv, toYaml } from "../../src/cli/commands/envs.ts";
|
|
8
|
+
|
|
9
|
+
function tmpDb(): string {
|
|
10
|
+
return join(tmpdir(), `apitool-envs-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function tryUnlink(path: string): void {
|
|
14
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
15
|
+
try { unlinkSync(path + suffix); } catch { /* ignore */ }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("parseKeyValuePairs", () => {
|
|
20
|
+
test("parses KEY=VALUE strings", () => {
|
|
21
|
+
expect(parseKeyValuePairs(["base_url=https://api.example.com", "token=abc"])).toEqual({
|
|
22
|
+
base_url: "https://api.example.com",
|
|
23
|
+
token: "abc",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("handles = in value", () => {
|
|
28
|
+
expect(parseKeyValuePairs(["key=val=ue"])).toEqual({ key: "val=ue" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("skips invalid pairs", () => {
|
|
32
|
+
expect(parseKeyValuePairs(["noeq", "good=yes"])).toEqual({ good: "yes" });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("envsCommand", () => {
|
|
37
|
+
let dbPath: string;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
dbPath = tmpDb();
|
|
41
|
+
getDb(dbPath);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
closeDb();
|
|
46
|
+
tryUnlink(dbPath);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("list shows empty message when no envs", () => {
|
|
50
|
+
const code = envsCommand({ action: "list", dbPath });
|
|
51
|
+
expect(code).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("list shows environments", () => {
|
|
55
|
+
upsertEnvironment("dev", { base_url: "http://localhost" });
|
|
56
|
+
const code = envsCommand({ action: "list", dbPath });
|
|
57
|
+
expect(code).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("get returns variables", () => {
|
|
61
|
+
upsertEnvironment("staging", { base_url: "https://staging.api.com", token: "abc" });
|
|
62
|
+
const code = envsCommand({ action: "get", name: "staging", dbPath });
|
|
63
|
+
expect(code).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("get returns 1 for missing env", () => {
|
|
67
|
+
const code = envsCommand({ action: "get", name: "nonexistent", dbPath });
|
|
68
|
+
expect(code).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("get returns 2 without name", () => {
|
|
72
|
+
const code = envsCommand({ action: "get", dbPath });
|
|
73
|
+
expect(code).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("set creates environment", () => {
|
|
77
|
+
const code = envsCommand({ action: "set", name: "prod", pairs: ["base_url=https://prod.api.com"], dbPath });
|
|
78
|
+
expect(code).toBe(0);
|
|
79
|
+
// Verify via get
|
|
80
|
+
const code2 = envsCommand({ action: "get", name: "prod", dbPath });
|
|
81
|
+
expect(code2).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("set returns 2 without name", () => {
|
|
85
|
+
const code = envsCommand({ action: "set", pairs: ["k=v"], dbPath });
|
|
86
|
+
expect(code).toBe(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("set returns 2 without pairs", () => {
|
|
90
|
+
const code = envsCommand({ action: "set", name: "prod", dbPath });
|
|
91
|
+
expect(code).toBe(2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("delete removes environment", () => {
|
|
95
|
+
upsertEnvironment("tmp", { key: "val" });
|
|
96
|
+
const code = envsCommand({ action: "delete", name: "tmp", dbPath });
|
|
97
|
+
expect(code).toBe(0);
|
|
98
|
+
// Verify deleted
|
|
99
|
+
const code2 = envsCommand({ action: "get", name: "tmp", dbPath });
|
|
100
|
+
expect(code2).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("delete returns 1 for missing env", () => {
|
|
104
|
+
const code = envsCommand({ action: "delete", name: "ghost", dbPath });
|
|
105
|
+
expect(code).toBe(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("import loads YAML file into DB", () => {
|
|
109
|
+
const yamlFile = join(tmpdir(), `apitool-import-${Date.now()}.yaml`);
|
|
110
|
+
writeFileSync(yamlFile, "base_url: https://api.example.com\ntoken: secret123\n");
|
|
111
|
+
try {
|
|
112
|
+
const code = envsCommand({ action: "import", name: "imported", file: yamlFile, dbPath });
|
|
113
|
+
expect(code).toBe(0);
|
|
114
|
+
const vars = getEnvironment("imported");
|
|
115
|
+
expect(vars).toEqual({ base_url: "https://api.example.com", token: "secret123" });
|
|
116
|
+
} finally {
|
|
117
|
+
try { unlinkSync(yamlFile); } catch {}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("import returns 2 without name", () => {
|
|
122
|
+
const code = envsCommand({ action: "import", file: "some.yaml", dbPath });
|
|
123
|
+
expect(code).toBe(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("import returns 2 without file", () => {
|
|
127
|
+
const code = envsCommand({ action: "import", name: "test", dbPath });
|
|
128
|
+
expect(code).toBe(2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("import returns 1 for nonexistent file", () => {
|
|
132
|
+
const code = envsCommand({ action: "import", name: "test", file: "/nonexistent/file.yaml", dbPath });
|
|
133
|
+
expect(code).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("export outputs YAML", () => {
|
|
137
|
+
upsertEnvironment("exportme", { base: "http://localhost", key: "val" });
|
|
138
|
+
const code = envsCommand({ action: "export", name: "exportme", dbPath });
|
|
139
|
+
expect(code).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("export returns 1 for missing env", () => {
|
|
143
|
+
const code = envsCommand({ action: "export", name: "ghost", dbPath });
|
|
144
|
+
expect(code).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("export returns 2 without name", () => {
|
|
148
|
+
const code = envsCommand({ action: "export", dbPath });
|
|
149
|
+
expect(code).toBe(2);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("parseYamlEnv", () => {
|
|
154
|
+
test("parses simple key: value pairs", () => {
|
|
155
|
+
expect(parseYamlEnv("base: http://localhost\ntoken: abc")).toEqual({
|
|
156
|
+
base: "http://localhost",
|
|
157
|
+
token: "abc",
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("strips quotes", () => {
|
|
162
|
+
expect(parseYamlEnv('key: "hello world"')).toEqual({ key: "hello world" });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("skips comments and blank lines", () => {
|
|
166
|
+
expect(parseYamlEnv("# comment\n\nkey: val")).toEqual({ key: "val" });
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("toYaml", () => {
|
|
171
|
+
test("serializes flat record", () => {
|
|
172
|
+
const yaml = toYaml({ name: "dev", token: "abc" });
|
|
173
|
+
expect(yaml).toContain("name: dev");
|
|
174
|
+
expect(yaml).toContain("token: abc");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("quotes values with special chars", () => {
|
|
178
|
+
const yaml = toYaml({ url: "http://host:3000/path" });
|
|
179
|
+
expect(yaml).toContain('"http://host:3000/path"');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test";
|
|
2
|
+
import { mkdtempSync, existsSync, readFileSync, writeFileSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { initCommand } from "../../src/cli/commands/init.ts";
|
|
6
|
+
|
|
7
|
+
// Suppress stdout/stderr during tests
|
|
8
|
+
function suppressOutput() {
|
|
9
|
+
const origOut = process.stdout.write;
|
|
10
|
+
const origErr = process.stderr.write;
|
|
11
|
+
process.stdout.write = mock(() => true) as typeof process.stdout.write;
|
|
12
|
+
process.stderr.write = mock(() => true) as typeof process.stderr.write;
|
|
13
|
+
return () => {
|
|
14
|
+
process.stdout.write = origOut;
|
|
15
|
+
process.stderr.write = origErr;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("apitool init", () => {
|
|
20
|
+
let tmpDir: string;
|
|
21
|
+
let origCwd: string;
|
|
22
|
+
let restoreOutput: () => void;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
tmpDir = mkdtempSync(join(tmpdir(), "apitool-init-"));
|
|
26
|
+
origCwd = process.cwd();
|
|
27
|
+
process.chdir(tmpDir);
|
|
28
|
+
restoreOutput = suppressOutput();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
process.chdir(origCwd);
|
|
33
|
+
restoreOutput();
|
|
34
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("creates example test and env files", async () => {
|
|
38
|
+
const code = await initCommand({ force: false });
|
|
39
|
+
expect(code).toBe(0);
|
|
40
|
+
|
|
41
|
+
expect(existsSync(join(tmpDir, "tests/example.yaml"))).toBe(true);
|
|
42
|
+
expect(existsSync(join(tmpDir, ".env.dev.yaml"))).toBe(true);
|
|
43
|
+
|
|
44
|
+
const testContent = readFileSync(join(tmpDir, "tests/example.yaml"), "utf-8");
|
|
45
|
+
expect(testContent).toContain("Example Smoke Test");
|
|
46
|
+
expect(testContent).toContain("base_url");
|
|
47
|
+
|
|
48
|
+
const envContent = readFileSync(join(tmpDir, ".env.dev.yaml"), "utf-8");
|
|
49
|
+
expect(envContent).toContain("jsonplaceholder");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("skips existing files without --force", async () => {
|
|
53
|
+
writeFileSync(join(tmpDir, ".env.dev.yaml"), "original: true\n");
|
|
54
|
+
|
|
55
|
+
const code = await initCommand({ force: false });
|
|
56
|
+
expect(code).toBe(0);
|
|
57
|
+
|
|
58
|
+
// tests/example.yaml should be created
|
|
59
|
+
expect(existsSync(join(tmpDir, "tests/example.yaml"))).toBe(true);
|
|
60
|
+
|
|
61
|
+
// .env.dev.yaml should NOT be overwritten
|
|
62
|
+
const envContent = readFileSync(join(tmpDir, ".env.dev.yaml"), "utf-8");
|
|
63
|
+
expect(envContent).toBe("original: true\n");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("--force overwrites existing files", async () => {
|
|
67
|
+
writeFileSync(join(tmpDir, ".env.dev.yaml"), "original: true\n");
|
|
68
|
+
|
|
69
|
+
const code = await initCommand({ force: true });
|
|
70
|
+
expect(code).toBe(0);
|
|
71
|
+
|
|
72
|
+
const envContent = readFileSync(join(tmpDir, ".env.dev.yaml"), "utf-8");
|
|
73
|
+
expect(envContent).toContain("jsonplaceholder");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("returns 0 exit code", async () => {
|
|
77
|
+
const code = await initCommand({ force: false });
|
|
78
|
+
expect(code).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
});
|