@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.
Files changed (191) hide show
  1. package/.github/workflows/ci.yml +27 -0
  2. package/.github/workflows/release.yml +97 -0
  3. package/.mcp.json +9 -0
  4. package/APITOOL.md +195 -0
  5. package/BACKLOG.md +62 -0
  6. package/CHANGELOG.md +88 -0
  7. package/LICENSE +21 -0
  8. package/README.md +105 -0
  9. package/bun.lock +291 -0
  10. package/docs/GLOSSARY.md +182 -0
  11. package/docs/INDEX.md +21 -0
  12. package/docs/agent.md +135 -0
  13. package/docs/archive/APITOOL-pre-M22.md +831 -0
  14. package/docs/archive/BACKLOG-AI-NATIVE.md +56 -0
  15. package/docs/archive/M1-M2-parser-runner.md +216 -0
  16. package/docs/archive/M4-M7-reporter-cli.md +179 -0
  17. package/docs/archive/M5-M7-storage-junit.md +300 -0
  18. package/docs/archive/M6-webui.md +339 -0
  19. package/docs/ci.md +274 -0
  20. package/docs/generation-issues.md +67 -0
  21. package/generated/.env.yaml +3 -0
  22. package/install.ps1 +80 -0
  23. package/install.sh +113 -0
  24. package/package.json +46 -0
  25. package/scripts/run-mocked-tests.ts +45 -0
  26. package/seed-demo.ts +53 -0
  27. package/self-tests/auth.yaml +18 -0
  28. package/self-tests/collections-crud.yaml +46 -0
  29. package/self-tests/environments-crud.yaml +48 -0
  30. package/self-tests/export.yaml +32 -0
  31. package/self-tests/runs.yaml +16 -0
  32. package/src/bun-types.d.ts +5 -0
  33. package/src/cli/commands/add-api.ts +51 -0
  34. package/src/cli/commands/ai-generate.ts +106 -0
  35. package/src/cli/commands/chat.ts +43 -0
  36. package/src/cli/commands/ci-init.ts +126 -0
  37. package/src/cli/commands/collections.ts +41 -0
  38. package/src/cli/commands/coverage.ts +65 -0
  39. package/src/cli/commands/doctor.ts +127 -0
  40. package/src/cli/commands/envs.ts +218 -0
  41. package/src/cli/commands/init.ts +84 -0
  42. package/src/cli/commands/mcp.ts +16 -0
  43. package/src/cli/commands/run.ts +137 -0
  44. package/src/cli/commands/runs.ts +108 -0
  45. package/src/cli/commands/serve.ts +22 -0
  46. package/src/cli/commands/update.ts +142 -0
  47. package/src/cli/commands/validate.ts +18 -0
  48. package/src/cli/index.ts +500 -0
  49. package/src/cli/output.ts +24 -0
  50. package/src/cli/runtime.ts +7 -0
  51. package/src/core/agent/agent-loop.ts +116 -0
  52. package/src/core/agent/context-manager.ts +41 -0
  53. package/src/core/agent/system-prompt.ts +33 -0
  54. package/src/core/agent/tools/diagnose-failure.ts +51 -0
  55. package/src/core/agent/tools/explore-api.ts +40 -0
  56. package/src/core/agent/tools/index.ts +48 -0
  57. package/src/core/agent/tools/manage-environment.ts +40 -0
  58. package/src/core/agent/tools/query-results.ts +40 -0
  59. package/src/core/agent/tools/run-tests.ts +38 -0
  60. package/src/core/agent/tools/send-request.ts +44 -0
  61. package/src/core/agent/tools/validate-tests.ts +23 -0
  62. package/src/core/agent/types.ts +22 -0
  63. package/src/core/generator/ai/ai-generator.ts +61 -0
  64. package/src/core/generator/ai/llm-client.ts +159 -0
  65. package/src/core/generator/ai/output-parser.ts +307 -0
  66. package/src/core/generator/ai/prompt-builder.ts +153 -0
  67. package/src/core/generator/ai/types.ts +56 -0
  68. package/src/core/generator/coverage-scanner.ts +87 -0
  69. package/src/core/generator/data-factory.ts +115 -0
  70. package/src/core/generator/index.ts +10 -0
  71. package/src/core/generator/openapi-reader.ts +142 -0
  72. package/src/core/generator/schema-utils.ts +52 -0
  73. package/src/core/generator/serializer.ts +189 -0
  74. package/src/core/generator/types.ts +47 -0
  75. package/src/core/parser/filter.ts +14 -0
  76. package/src/core/parser/index.ts +21 -0
  77. package/src/core/parser/schema.ts +175 -0
  78. package/src/core/parser/types.ts +50 -0
  79. package/src/core/parser/variables.ts +146 -0
  80. package/src/core/parser/yaml-parser.ts +85 -0
  81. package/src/core/reporter/console.ts +175 -0
  82. package/src/core/reporter/index.ts +23 -0
  83. package/src/core/reporter/json.ts +9 -0
  84. package/src/core/reporter/junit.ts +78 -0
  85. package/src/core/reporter/types.ts +12 -0
  86. package/src/core/runner/assertions.ts +172 -0
  87. package/src/core/runner/execute-run.ts +75 -0
  88. package/src/core/runner/executor.ts +150 -0
  89. package/src/core/runner/http-client.ts +69 -0
  90. package/src/core/runner/index.ts +12 -0
  91. package/src/core/runner/types.ts +48 -0
  92. package/src/core/setup-api.ts +97 -0
  93. package/src/core/utils.ts +9 -0
  94. package/src/db/queries.ts +868 -0
  95. package/src/db/schema.ts +215 -0
  96. package/src/mcp/server.ts +47 -0
  97. package/src/mcp/tools/ci-init.ts +57 -0
  98. package/src/mcp/tools/coverage-analysis.ts +58 -0
  99. package/src/mcp/tools/explore-api.ts +84 -0
  100. package/src/mcp/tools/generate-missing-tests.ts +80 -0
  101. package/src/mcp/tools/generate-tests-guide.ts +353 -0
  102. package/src/mcp/tools/manage-environment.ts +123 -0
  103. package/src/mcp/tools/manage-server.ts +87 -0
  104. package/src/mcp/tools/query-db.ts +141 -0
  105. package/src/mcp/tools/run-tests.ts +66 -0
  106. package/src/mcp/tools/save-test-suite.ts +164 -0
  107. package/src/mcp/tools/send-request.ts +53 -0
  108. package/src/mcp/tools/setup-api.ts +49 -0
  109. package/src/mcp/tools/validate-tests.ts +42 -0
  110. package/src/tui/chat-ui.ts +150 -0
  111. package/src/web/routes/api.ts +234 -0
  112. package/src/web/routes/dashboard.ts +348 -0
  113. package/src/web/routes/runs.ts +64 -0
  114. package/src/web/schemas.ts +121 -0
  115. package/src/web/server.ts +134 -0
  116. package/src/web/static/htmx.min.js +1 -0
  117. package/src/web/static/style.css +265 -0
  118. package/src/web/views/layout.ts +46 -0
  119. package/src/web/views/results.ts +209 -0
  120. package/tests/agent/agent-loop.test.ts +61 -0
  121. package/tests/agent/context-manager.test.ts +59 -0
  122. package/tests/agent/system-prompt.test.ts +42 -0
  123. package/tests/agent/tools/diagnose-failure.test.ts +85 -0
  124. package/tests/agent/tools/explore-api.test.ts +59 -0
  125. package/tests/agent/tools/manage-environment.test.ts +78 -0
  126. package/tests/agent/tools/query-results.test.ts +77 -0
  127. package/tests/agent/tools/run-tests.test.ts +89 -0
  128. package/tests/agent/tools/send-request.test.ts +78 -0
  129. package/tests/agent/tools/validate-tests.test.ts +59 -0
  130. package/tests/ai/ai-generator.integration.test.ts +131 -0
  131. package/tests/ai/llm-client.test.ts +145 -0
  132. package/tests/ai/output-parser.test.ts +132 -0
  133. package/tests/ai/prompt-builder.test.ts +67 -0
  134. package/tests/ai/types.test.ts +55 -0
  135. package/tests/cli/args.test.ts +63 -0
  136. package/tests/cli/chat.test.ts +38 -0
  137. package/tests/cli/ci-init.test.ts +112 -0
  138. package/tests/cli/commands.test.ts +316 -0
  139. package/tests/cli/coverage.test.ts +58 -0
  140. package/tests/cli/doctor.test.ts +39 -0
  141. package/tests/cli/envs.test.ts +181 -0
  142. package/tests/cli/init.test.ts +80 -0
  143. package/tests/cli/runs.test.ts +94 -0
  144. package/tests/cli/safe-run.test.ts +103 -0
  145. package/tests/cli/update.test.ts +32 -0
  146. package/tests/core/generator/schema-utils.test.ts +108 -0
  147. package/tests/core/parser/nested-assertions.test.ts +80 -0
  148. package/tests/core/runner/root-body-assertions.test.ts +70 -0
  149. package/tests/db/chat-queries.test.ts +88 -0
  150. package/tests/db/chat-schema.test.ts +37 -0
  151. package/tests/db/environments.test.ts +131 -0
  152. package/tests/db/queries.test.ts +409 -0
  153. package/tests/db/schema.test.ts +141 -0
  154. package/tests/fixtures/.env.yaml +3 -0
  155. package/tests/fixtures/auth-token-test.yaml +8 -0
  156. package/tests/fixtures/bail/suite-a.yaml +6 -0
  157. package/tests/fixtures/bail/suite-b.yaml +6 -0
  158. package/tests/fixtures/crud.yaml +35 -0
  159. package/tests/fixtures/invalid-missing-name.yaml +5 -0
  160. package/tests/fixtures/invalid-no-method.yaml +6 -0
  161. package/tests/fixtures/petstore-auth.json +295 -0
  162. package/tests/fixtures/petstore-simple.json +151 -0
  163. package/tests/fixtures/post-only.yaml +12 -0
  164. package/tests/fixtures/simple.yaml +6 -0
  165. package/tests/fixtures/valid/.env.yaml +1 -0
  166. package/tests/fixtures/valid/a.yaml +5 -0
  167. package/tests/fixtures/valid/b.yml +5 -0
  168. package/tests/generator/coverage-scanner.test.ts +129 -0
  169. package/tests/generator/data-factory.test.ts +133 -0
  170. package/tests/generator/openapi-reader.test.ts +131 -0
  171. package/tests/integration/auth-flow.test.ts +217 -0
  172. package/tests/mcp/coverage-analysis.test.ts +64 -0
  173. package/tests/mcp/explore-api-schemas.test.ts +105 -0
  174. package/tests/mcp/explore-api.test.ts +49 -0
  175. package/tests/mcp/generate-missing-tests.test.ts +69 -0
  176. package/tests/mcp/manage-environment.test.ts +89 -0
  177. package/tests/mcp/save-test-suite.test.ts +116 -0
  178. package/tests/mcp/send-request.test.ts +79 -0
  179. package/tests/mcp/setup-api.test.ts +106 -0
  180. package/tests/mcp/tools.test.ts +248 -0
  181. package/tests/parser/schema.test.ts +134 -0
  182. package/tests/parser/variables.test.ts +227 -0
  183. package/tests/parser/yaml-parser.test.ts +69 -0
  184. package/tests/reporter/console.test.ts +256 -0
  185. package/tests/reporter/json.test.ts +98 -0
  186. package/tests/reporter/junit.test.ts +284 -0
  187. package/tests/runner/assertions.test.ts +262 -0
  188. package/tests/runner/executor.test.ts +310 -0
  189. package/tests/runner/http-client.test.ts +138 -0
  190. package/tests/web/routes.test.ts +160 -0
  191. package/tsconfig.json +31 -0
@@ -0,0 +1,248 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { tmpdir } from "os";
3
+ import { join, resolve } from "path";
4
+ import { unlinkSync } from "fs";
5
+ import { getDb, closeDb } from "../../src/db/schema.ts";
6
+ import {
7
+ createRun,
8
+ finalizeRun,
9
+ saveResults,
10
+ } from "../../src/db/queries.ts";
11
+ import type { TestRunResult } from "../../src/core/runner/types.ts";
12
+
13
+ // We test the tool handler logic directly by importing the registration functions
14
+ // and calling the MCP server's tool handlers through a minimal test harness
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+
17
+ function tmpDb(): string {
18
+ return join(tmpdir(), `apitool-mcp-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
19
+ }
20
+
21
+ function tryUnlink(path: string): void {
22
+ for (const suffix of ["", "-wal", "-shm"]) {
23
+ try { unlinkSync(path + suffix); } catch { /* ignore */ }
24
+ }
25
+ }
26
+
27
+ function makeSuiteResult(overrides?: Partial<TestRunResult>): TestRunResult {
28
+ return {
29
+ suite_name: "Users API",
30
+ started_at: "2024-01-01T00:00:00.000Z",
31
+ finished_at: "2024-01-01T00:00:01.000Z",
32
+ total: 2,
33
+ passed: 1,
34
+ failed: 1,
35
+ skipped: 0,
36
+ steps: [
37
+ {
38
+ name: "Get user",
39
+ status: "pass",
40
+ duration_ms: 100,
41
+ request: { method: "GET", url: "http://localhost/users/1", headers: {} },
42
+ response: { status: 200, headers: {}, body: '{"id":1}', duration_ms: 100 },
43
+ assertions: [{ field: "status", rule: "equals", passed: true, actual: 200, expected: 200 }],
44
+ captures: {},
45
+ },
46
+ {
47
+ name: "Create user",
48
+ status: "fail",
49
+ duration_ms: 200,
50
+ request: { method: "POST", url: "http://localhost/users", headers: {} },
51
+ response: { status: 500, headers: {}, body: "error", duration_ms: 200 },
52
+ assertions: [{ field: "status", rule: "equals", passed: false, actual: 500, expected: 201 }],
53
+ captures: {},
54
+ error: "Expected 201 but got 500",
55
+ },
56
+ ],
57
+ ...overrides,
58
+ };
59
+ }
60
+
61
+ // ──────────────────────────────────────────────
62
+ // validate_tests
63
+ // ──────────────────────────────────────────────
64
+
65
+ describe("validate_tests", () => {
66
+ test("validates a valid test file", async () => {
67
+ const { registerValidateTestsTool } = await import("../../src/mcp/tools/validate-tests.ts");
68
+
69
+ const server = new McpServer({ name: "test", version: "0.0.1" });
70
+ registerValidateTestsTool(server);
71
+
72
+ // Access the registered tool handler via the internal registry
73
+ const tool = (server as any)._registeredTools["validate_tests"];
74
+ expect(tool).toBeDefined();
75
+
76
+ const fixturePath = resolve("tests/fixtures/valid/a.yaml");
77
+ const result = await tool.handler({ testPath: fixturePath });
78
+
79
+ expect(result.content).toHaveLength(1);
80
+ expect(result.content[0].type).toBe("text");
81
+
82
+ const parsed = JSON.parse(result.content[0].text);
83
+ expect(parsed.valid).toBe(true);
84
+ expect(parsed.suites).toBe(1);
85
+ expect(parsed.tests).toBe(1);
86
+ });
87
+
88
+ test("returns error for invalid path", async () => {
89
+ const { registerValidateTestsTool } = await import("../../src/mcp/tools/validate-tests.ts");
90
+
91
+ const server = new McpServer({ name: "test", version: "0.0.1" });
92
+ registerValidateTestsTool(server);
93
+
94
+ const tool = (server as any)._registeredTools["validate_tests"];
95
+ const result = await tool.handler({ testPath: "/nonexistent/path.yaml" });
96
+
97
+ expect(result.isError).toBe(true);
98
+ const parsed = JSON.parse(result.content[0].text);
99
+ expect(parsed.valid).toBe(false);
100
+ expect(parsed.error).toBeDefined();
101
+ });
102
+ });
103
+
104
+ // ──────────────────────────────────────────────
105
+ // query_db
106
+ // ──────────────────────────────────────────────
107
+
108
+ describe("query_db", () => {
109
+ let dbFile: string;
110
+
111
+ beforeEach(() => {
112
+ dbFile = tmpDb();
113
+ getDb(dbFile);
114
+ });
115
+
116
+ afterEach(() => {
117
+ closeDb();
118
+ tryUnlink(dbFile);
119
+ });
120
+
121
+ test("list_collections returns empty array on fresh DB", async () => {
122
+ const { registerQueryDbTool } = await import("../../src/mcp/tools/query-db.ts");
123
+
124
+ const server = new McpServer({ name: "test", version: "0.0.1" });
125
+ registerQueryDbTool(server, dbFile);
126
+
127
+ const tool = (server as any)._registeredTools["query_db"];
128
+ const result = await tool.handler({ action: "list_collections" });
129
+
130
+ const parsed = JSON.parse(result.content[0].text);
131
+ expect(parsed).toEqual([]);
132
+ });
133
+
134
+ test("list_runs returns empty array on fresh DB", async () => {
135
+ const { registerQueryDbTool } = await import("../../src/mcp/tools/query-db.ts");
136
+
137
+ const server = new McpServer({ name: "test", version: "0.0.1" });
138
+ registerQueryDbTool(server, dbFile);
139
+
140
+ const tool = (server as any)._registeredTools["query_db"];
141
+ const result = await tool.handler({ action: "list_runs" });
142
+
143
+ const parsed = JSON.parse(result.content[0].text);
144
+ expect(parsed).toEqual([]);
145
+ });
146
+
147
+ test("list_runs returns runs after inserting data", async () => {
148
+ const suiteResult = makeSuiteResult();
149
+ const runId = createRun({ started_at: suiteResult.started_at, trigger: "mcp" });
150
+ finalizeRun(runId, [suiteResult]);
151
+ saveResults(runId, [suiteResult]);
152
+
153
+ const { registerQueryDbTool } = await import("../../src/mcp/tools/query-db.ts");
154
+
155
+ const server = new McpServer({ name: "test", version: "0.0.1" });
156
+ registerQueryDbTool(server, dbFile);
157
+
158
+ const tool = (server as any)._registeredTools["query_db"];
159
+ const result = await tool.handler({ action: "list_runs", limit: 10 });
160
+
161
+ const parsed = JSON.parse(result.content[0].text);
162
+ expect(parsed).toHaveLength(1);
163
+ expect(parsed[0].total).toBe(2);
164
+ expect(parsed[0].passed).toBe(1);
165
+ expect(parsed[0].failed).toBe(1);
166
+ });
167
+
168
+ test("get_run_results returns error for non-existent run", async () => {
169
+ const { registerQueryDbTool } = await import("../../src/mcp/tools/query-db.ts");
170
+
171
+ const server = new McpServer({ name: "test", version: "0.0.1" });
172
+ registerQueryDbTool(server, dbFile);
173
+
174
+ const tool = (server as any)._registeredTools["query_db"];
175
+ const result = await tool.handler({ action: "get_run_results", runId: 999 });
176
+
177
+ expect(result.isError).toBe(true);
178
+ const parsed = JSON.parse(result.content[0].text);
179
+ expect(parsed.error).toContain("999");
180
+ });
181
+
182
+ test("get_run_results returns detailed results for existing run", async () => {
183
+ const suiteResult = makeSuiteResult();
184
+ const runId = createRun({ started_at: suiteResult.started_at, trigger: "mcp" });
185
+ finalizeRun(runId, [suiteResult]);
186
+ saveResults(runId, [suiteResult]);
187
+
188
+ const { registerQueryDbTool } = await import("../../src/mcp/tools/query-db.ts");
189
+
190
+ const server = new McpServer({ name: "test", version: "0.0.1" });
191
+ registerQueryDbTool(server, dbFile);
192
+
193
+ const tool = (server as any)._registeredTools["query_db"];
194
+ const result = await tool.handler({ action: "get_run_results", runId });
195
+
196
+ const parsed = JSON.parse(result.content[0].text);
197
+ expect(parsed.run.id).toBe(runId);
198
+ expect(parsed.run.total).toBe(2);
199
+ expect(parsed.results).toHaveLength(2);
200
+ expect(parsed.results[0].test_name).toBe("Get user");
201
+ expect(parsed.results[1].test_name).toBe("Create user");
202
+ });
203
+
204
+ test("get_run_results requires runId", async () => {
205
+ const { registerQueryDbTool } = await import("../../src/mcp/tools/query-db.ts");
206
+
207
+ const server = new McpServer({ name: "test", version: "0.0.1" });
208
+ registerQueryDbTool(server, dbFile);
209
+
210
+ const tool = (server as any)._registeredTools["query_db"];
211
+ const result = await tool.handler({ action: "get_run_results" });
212
+
213
+ expect(result.isError).toBe(true);
214
+ });
215
+
216
+ test("diagnose_failure returns only failures", async () => {
217
+ const suiteResult = makeSuiteResult();
218
+ const runId = createRun({ started_at: suiteResult.started_at, trigger: "mcp" });
219
+ finalizeRun(runId, [suiteResult]);
220
+ saveResults(runId, [suiteResult]);
221
+
222
+ const { registerQueryDbTool } = await import("../../src/mcp/tools/query-db.ts");
223
+
224
+ const server = new McpServer({ name: "test", version: "0.0.1" });
225
+ registerQueryDbTool(server, dbFile);
226
+
227
+ const tool = (server as any)._registeredTools["query_db"];
228
+ const result = await tool.handler({ action: "diagnose_failure", runId });
229
+
230
+ const parsed = JSON.parse(result.content[0].text);
231
+ expect(parsed.run.id).toBe(runId);
232
+ expect(parsed.summary.failed).toBe(1);
233
+ expect(parsed.failures.length).toBeGreaterThan(0);
234
+ expect(parsed.failures.every((f: any) => f.status === "fail" || f.status === "error")).toBe(true);
235
+ });
236
+
237
+ test("diagnose_failure returns error for missing run", async () => {
238
+ const { registerQueryDbTool } = await import("../../src/mcp/tools/query-db.ts");
239
+
240
+ const server = new McpServer({ name: "test", version: "0.0.1" });
241
+ registerQueryDbTool(server, dbFile);
242
+
243
+ const tool = (server as any)._registeredTools["query_db"];
244
+ const result = await tool.handler({ action: "diagnose_failure", runId: 9999 });
245
+
246
+ expect(result.isError).toBe(true);
247
+ });
248
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { validateSuite, DEFAULT_CONFIG } from "../../src/core/parser/schema.ts";
3
+
4
+ describe("validateSuite", () => {
5
+ test("parses minimal valid suite", () => {
6
+ const suite = validateSuite({
7
+ name: "Test",
8
+ tests: [{ GET: "/health", name: "Health", expect: { status: 200 } }],
9
+ });
10
+ expect(suite.name).toBe("Test");
11
+ expect(suite.tests).toHaveLength(1);
12
+ expect(suite.tests[0]!.method).toBe("GET");
13
+ expect(suite.tests[0]!.path).toBe("/health");
14
+ });
15
+
16
+ test("extracts method-as-key for all HTTP methods", () => {
17
+ const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
18
+ for (const method of methods) {
19
+ const suite = validateSuite({
20
+ name: "Test",
21
+ tests: [{ [method]: "/path", name: `${method} test`, expect: {} }],
22
+ });
23
+ expect(suite.tests[0]!.method).toBe(method);
24
+ expect(suite.tests[0]!.path).toBe("/path");
25
+ }
26
+ });
27
+
28
+ test("applies default config when config is missing", () => {
29
+ const suite = validateSuite({
30
+ name: "Test",
31
+ tests: [{ GET: "/health", name: "Health", expect: {} }],
32
+ });
33
+ expect(suite.config).toEqual(DEFAULT_CONFIG);
34
+ });
35
+
36
+ test("merges partial config with defaults", () => {
37
+ const suite = validateSuite({
38
+ name: "Test",
39
+ config: { timeout: 5000 },
40
+ tests: [{ GET: "/health", name: "Health", expect: {} }],
41
+ });
42
+ expect(suite.config.timeout).toBe(5000);
43
+ expect(suite.config.retries).toBe(0);
44
+ expect(suite.config.retry_delay).toBe(1000);
45
+ expect(suite.config.follow_redirects).toBe(true);
46
+ });
47
+
48
+ test("parses all assertion rule fields", () => {
49
+ const suite = validateSuite({
50
+ name: "Test",
51
+ tests: [{
52
+ GET: "/users",
53
+ name: "Test",
54
+ expect: {
55
+ status: 200,
56
+ duration: 1000,
57
+ headers: { "content-type": "application/json" },
58
+ body: {
59
+ id: { capture: "user_id", type: "integer", equals: 1 },
60
+ name: { contains: "John", matches: "^J.*" },
61
+ age: { gt: 18, lt: 100 },
62
+ active: { exists: true },
63
+ },
64
+ },
65
+ }],
66
+ });
67
+ const body = suite.tests[0]!.expect.body!;
68
+ expect(body["id"]!.capture).toBe("user_id");
69
+ expect(body["id"]!.type).toBe("integer");
70
+ expect(body["name"]!.contains).toBe("John");
71
+ expect(body["age"]!.gt).toBe(18);
72
+ expect(body["active"]!.exists).toBe(true);
73
+ });
74
+
75
+ test("parses json, form, query, headers on step", () => {
76
+ const suite = validateSuite({
77
+ name: "Test",
78
+ tests: [{
79
+ POST: "/users",
80
+ name: "Create",
81
+ headers: { "X-Custom": "value" },
82
+ json: { name: "John" },
83
+ query: { page: "1" },
84
+ expect: { status: 201 },
85
+ }],
86
+ });
87
+ const step = suite.tests[0]!;
88
+ expect(step.headers).toEqual({ "X-Custom": "value" });
89
+ expect(step.json).toEqual({ name: "John" });
90
+ expect(step.query).toEqual({ page: "1" });
91
+ });
92
+
93
+ test("throws on missing name", () => {
94
+ expect(() => validateSuite({
95
+ tests: [{ GET: "/health", name: "Health", expect: {} }],
96
+ })).toThrow();
97
+ });
98
+
99
+ test("throws on missing method key in step", () => {
100
+ expect(() => validateSuite({
101
+ name: "Test",
102
+ tests: [{ name: "Bad", path: "/health", expect: {} }],
103
+ })).toThrow();
104
+ });
105
+
106
+ test("throws on ambiguous method keys", () => {
107
+ expect(() => validateSuite({
108
+ name: "Test",
109
+ tests: [{ GET: "/a", POST: "/b", name: "Ambiguous", expect: {} }],
110
+ })).toThrow(/Ambiguous/);
111
+ });
112
+
113
+ test("throws on empty tests array", () => {
114
+ expect(() => validateSuite({ name: "Test", tests: [] })).toThrow();
115
+ });
116
+
117
+ test("throws on non-string method path", () => {
118
+ expect(() => validateSuite({
119
+ name: "Test",
120
+ tests: [{ GET: 123, name: "Bad", expect: {} }],
121
+ })).toThrow();
122
+ });
123
+
124
+ test("parses base_url and suite headers", () => {
125
+ const suite = validateSuite({
126
+ name: "Test",
127
+ base_url: "http://localhost:3000",
128
+ headers: { Authorization: "Bearer token" },
129
+ tests: [{ GET: "/health", name: "Health", expect: {} }],
130
+ });
131
+ expect(suite.base_url).toBe("http://localhost:3000");
132
+ expect(suite.headers).toEqual({ Authorization: "Bearer token" });
133
+ });
134
+ });
@@ -0,0 +1,227 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ substituteString,
4
+ substituteDeep,
5
+ substituteStep,
6
+ extractVariableReferences,
7
+ loadEnvironment,
8
+ GENERATORS,
9
+ } from "../../src/core/parser/variables.ts";
10
+ import type { TestStep } from "../../src/core/parser/types.ts";
11
+
12
+ describe("substituteString", () => {
13
+ test("replaces simple variable", () => {
14
+ expect(substituteString("Hello {{name}}", { name: "World" })).toBe("Hello World");
15
+ });
16
+
17
+ test("replaces multiple variables", () => {
18
+ expect(substituteString("{{a}} and {{b}}", { a: "X", b: "Y" })).toBe("X and Y");
19
+ });
20
+
21
+ test("returns raw value for whole-string variable (number)", () => {
22
+ expect(substituteString("{{count}}", { count: 42 })).toBe(42);
23
+ });
24
+
25
+ test("returns raw value for whole-string variable (boolean)", () => {
26
+ expect(substituteString("{{flag}}", { flag: true })).toBe(true);
27
+ });
28
+
29
+ test("returns string when variable is part of larger string", () => {
30
+ expect(substituteString("id-{{count}}", { count: 42 })).toBe("id-42");
31
+ });
32
+
33
+ test("leaves unresolved variables as-is", () => {
34
+ expect(substituteString("{{unknown}}", {})).toBe("{{unknown}}");
35
+ });
36
+
37
+ test("resolves $uuid generator", () => {
38
+ const result = substituteString("{{$uuid}}", {});
39
+ expect(typeof result).toBe("string");
40
+ expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
41
+ });
42
+
43
+ test("resolves $timestamp generator as number", () => {
44
+ const result = substituteString("{{$timestamp}}", {});
45
+ expect(typeof result).toBe("number");
46
+ expect(result as number).toBeGreaterThan(1000000000);
47
+ });
48
+
49
+ test("resolves $randomEmail generator", () => {
50
+ const result = substituteString("{{$randomEmail}}", {}) as string;
51
+ expect(result).toMatch(/.+@test\.com$/);
52
+ });
53
+
54
+ test("resolves $randomInt generator as number", () => {
55
+ const result = substituteString("{{$randomInt}}", {});
56
+ expect(typeof result).toBe("number");
57
+ });
58
+
59
+ test("resolves $randomName generator", () => {
60
+ const result = substituteString("{{$randomName}}", {}) as string;
61
+ expect(typeof result).toBe("string");
62
+ expect(result.length).toBeGreaterThan(0);
63
+ });
64
+
65
+ test("resolves $randomString generator", () => {
66
+ const result = substituteString("{{$randomString}}", {}) as string;
67
+ expect(typeof result).toBe("string");
68
+ expect(result).toHaveLength(8);
69
+ });
70
+
71
+ test("user variable takes precedence over generator", () => {
72
+ expect(substituteString("{{$uuid}}", { "$uuid": "custom" })).toBe("custom");
73
+ });
74
+ });
75
+
76
+ describe("substituteDeep", () => {
77
+ test("substitutes in nested objects", () => {
78
+ const result = substituteDeep(
79
+ { a: { b: "{{x}}", c: [1, "{{y}}"] } },
80
+ { x: "X", y: "Y" },
81
+ );
82
+ expect(result).toEqual({ a: { b: "X", c: [1, "Y"] } });
83
+ });
84
+
85
+ test("leaves non-string values as-is", () => {
86
+ const result = substituteDeep({ num: 42, bool: true, nil: null }, {});
87
+ expect(result).toEqual({ num: 42, bool: true, nil: null });
88
+ });
89
+
90
+ test("handles arrays", () => {
91
+ const result = substituteDeep(["{{a}}", "{{b}}"], { a: "1", b: "2" });
92
+ expect(result).toEqual(["1", "2"]);
93
+ });
94
+ });
95
+
96
+ describe("substituteStep", () => {
97
+ const baseStep: TestStep = {
98
+ name: "Test",
99
+ method: "GET",
100
+ path: "/users/{{id}}",
101
+ headers: { Authorization: "Bearer {{token}}" },
102
+ query: { page: "{{page}}" },
103
+ expect: {
104
+ status: 200,
105
+ body: { name: { equals: "{{name}}" } },
106
+ },
107
+ };
108
+
109
+ test("substitutes in path, headers, query, expect.body", () => {
110
+ const result = substituteStep(baseStep, { id: "123", token: "abc", page: "1", name: "John" });
111
+ expect(result.path).toBe("/users/123");
112
+ expect(result.headers!["Authorization"]).toBe("Bearer abc");
113
+ expect(result.query!["page"]).toBe("1");
114
+ expect(result.expect.body!["name"]!.equals).toBe("John");
115
+ });
116
+
117
+ test("substitutes in json body", () => {
118
+ const step: TestStep = {
119
+ name: "Create",
120
+ method: "POST",
121
+ path: "/users",
122
+ json: { name: "{{name}}", age: "{{age}}" },
123
+ expect: {},
124
+ };
125
+ const result = substituteStep(step, { name: "John", age: 30 });
126
+ expect((result.json as Record<string, unknown>)["name"]).toBe("John");
127
+ expect((result.json as Record<string, unknown>)["age"]).toBe(30);
128
+ });
129
+
130
+ test("substitutes in form body", () => {
131
+ const step: TestStep = {
132
+ name: "Login",
133
+ method: "POST",
134
+ path: "/login",
135
+ form: { username: "{{user}}" },
136
+ expect: {},
137
+ };
138
+ const result = substituteStep(step, { user: "admin" });
139
+ expect(result.form!["username"]).toBe("admin");
140
+ });
141
+ });
142
+
143
+ describe("extractVariableReferences", () => {
144
+ test("finds variable references in step", () => {
145
+ const step: TestStep = {
146
+ name: "Test",
147
+ method: "GET",
148
+ path: "/users/{{user_id}}",
149
+ headers: { Authorization: "Bearer {{token}}" },
150
+ expect: {},
151
+ };
152
+ const refs = extractVariableReferences(step);
153
+ expect(refs).toContain("user_id");
154
+ expect(refs).toContain("token");
155
+ });
156
+
157
+ test("excludes generator references ($ prefix)", () => {
158
+ const step: TestStep = {
159
+ name: "Test",
160
+ method: "POST",
161
+ path: "/users",
162
+ json: { name: "{{$randomName}}" },
163
+ expect: {},
164
+ };
165
+ const refs = extractVariableReferences(step);
166
+ expect(refs).not.toContain("$randomName");
167
+ });
168
+
169
+ test("returns empty array for step without variables", () => {
170
+ const step: TestStep = {
171
+ name: "Test",
172
+ method: "GET",
173
+ path: "/health",
174
+ expect: {},
175
+ };
176
+ expect(extractVariableReferences(step)).toEqual([]);
177
+ });
178
+ });
179
+
180
+ describe("loadEnvironment", () => {
181
+ const fixturesDir = `${import.meta.dir}/../fixtures`;
182
+
183
+ test("loads default env.yaml", () => {
184
+ // We have tests/fixtures/env.yaml
185
+ return loadEnvironment(undefined, fixturesDir).then((env) => {
186
+ expect(env["base"]).toBe("http://localhost:3000/api");
187
+ expect(env["token"]).toBe("dev-token-123");
188
+ expect(env["count"]).toBe("42");
189
+ });
190
+ });
191
+
192
+ test("returns empty object for non-existent env file", () => {
193
+ return loadEnvironment("nonexistent", fixturesDir).then((env) => {
194
+ expect(env).toEqual({});
195
+ });
196
+ });
197
+
198
+ test("falls back to DB when YAML not found and envName specified", async () => {
199
+ const { tmpdir } = await import("os");
200
+ const { join } = await import("path");
201
+ const { unlinkSync } = await import("fs");
202
+ const { getDb, closeDb } = await import("../../src/db/schema.ts");
203
+ const { upsertEnvironment } = await import("../../src/db/queries.ts");
204
+
205
+ const dbPath = join(tmpdir(), `apitool-vars-test-${Date.now()}.db`);
206
+ try {
207
+ getDb(dbPath);
208
+ upsertEnvironment("myenv", { base_url: "http://localhost:9000", api_key: "secret123" });
209
+
210
+ // loadEnvironment should find "myenv" in DB since no YAML file exists
211
+ const env = await loadEnvironment("myenv", "/nonexistent/path");
212
+ expect(env.base_url).toBe("http://localhost:9000");
213
+ expect(env.api_key).toBe("secret123");
214
+ } finally {
215
+ closeDb();
216
+ for (const suffix of ["", "-wal", "-shm"]) {
217
+ try { unlinkSync(dbPath + suffix); } catch { /* ignore */ }
218
+ }
219
+ }
220
+ });
221
+
222
+ test("returns empty when YAML not found and DB has no env", async () => {
223
+ // No YAML, no DB env — should return empty
224
+ const env = await loadEnvironment("nonexistent-env-xyz", "/nonexistent/path");
225
+ expect(env).toEqual({});
226
+ });
227
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { parseFile, parseDirectory, parse } from "../../src/core/parser/yaml-parser.ts";
3
+
4
+ const fixturesDir = `${import.meta.dir}/../fixtures`;
5
+
6
+ describe("parseFile", () => {
7
+ test("parses simple.yaml correctly", async () => {
8
+ const suite = await parseFile(`${fixturesDir}/simple.yaml`);
9
+ expect(suite.name).toBe("Health Check");
10
+ expect(suite.tests).toHaveLength(1);
11
+ expect(suite.tests[0]!.method).toBe("GET");
12
+ expect(suite.tests[0]!.path).toBe("/health");
13
+ expect(suite.tests[0]!.expect.status).toBe(200);
14
+ });
15
+
16
+ test("parses crud.yaml with all fields", async () => {
17
+ const suite = await parseFile(`${fixturesDir}/crud.yaml`);
18
+ expect(suite.name).toBe("Users CRUD");
19
+ expect(suite.base_url).toBe("{{base}}");
20
+ expect(suite.headers!["Authorization"]).toBe("Bearer {{token}}");
21
+ expect(suite.config.timeout).toBe(10000);
22
+ expect(suite.config.retries).toBe(1);
23
+ expect(suite.tests).toHaveLength(3);
24
+
25
+ // Check first step
26
+ const createStep = suite.tests[0]!;
27
+ expect(createStep.method).toBe("POST");
28
+ expect(createStep.path).toBe("/users");
29
+ expect(createStep.expect.body!["id"]!.capture).toBe("user_id");
30
+ expect(createStep.expect.body!["id"]!.type).toBe("integer");
31
+ });
32
+
33
+ test("throws on invalid YAML (missing name)", async () => {
34
+ await expect(parseFile(`${fixturesDir}/invalid-missing-name.yaml`)).rejects.toThrow(/Validation error/);
35
+ });
36
+
37
+ test("throws on invalid YAML (no method)", async () => {
38
+ await expect(parseFile(`${fixturesDir}/invalid-no-method.yaml`)).rejects.toThrow(/Validation error/);
39
+ });
40
+
41
+ test("throws on non-existent file", async () => {
42
+ await expect(parseFile(`${fixturesDir}/nonexistent.yaml`)).rejects.toThrow(/Failed to read/);
43
+ });
44
+ });
45
+
46
+ describe("parseDirectory", () => {
47
+ test("parses valid yaml files in a clean directory", async () => {
48
+ const tmpDir = `${fixturesDir}/valid`;
49
+ const { mkdirSync, existsSync } = await import("node:fs");
50
+ if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
51
+ await Bun.write(`${tmpDir}/a.yaml`, "name: A\ntests:\n - name: A\n GET: /a\n expect: {}\n");
52
+ await Bun.write(`${tmpDir}/b.yml`, "name: B\ntests:\n - name: B\n POST: /b\n expect: {}\n");
53
+ // .env.yaml should be excluded (dotfile, not scanned by default)
54
+ await Bun.write(`${tmpDir}/.env.yaml`, "base: http://localhost\n");
55
+
56
+ const suites = await parseDirectory(tmpDir);
57
+ expect(suites).toHaveLength(2);
58
+ const names = suites.map((s) => s.name).sort();
59
+ expect(names).toEqual(["A", "B"]);
60
+ });
61
+ });
62
+
63
+ describe("parse", () => {
64
+ test("parse single file returns array of one suite", async () => {
65
+ const suites = await parse(`${fixturesDir}/simple.yaml`);
66
+ expect(suites).toHaveLength(1);
67
+ expect(suites[0]!.name).toBe("Health Check");
68
+ });
69
+ });