@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,131 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+ import { unlinkSync } from "fs";
5
+ import { getDb, closeDb } from "../../src/db/schema.ts";
6
+ import {
7
+ upsertEnvironment,
8
+ getEnvironment,
9
+ listEnvironments,
10
+ listEnvironmentRecords,
11
+ getEnvironmentById,
12
+ deleteEnvironment,
13
+ } from "../../src/db/queries.ts";
14
+
15
+ function tmpDbPath(): string {
16
+ return join(tmpdir(), `apitool-env-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
17
+ }
18
+
19
+ function tryUnlink(path: string): void {
20
+ for (const suffix of ["", "-wal", "-shm"]) {
21
+ try { unlinkSync(path + suffix); } catch { /* ignore */ }
22
+ }
23
+ }
24
+
25
+ let dbPath: string;
26
+
27
+ beforeEach(() => {
28
+ dbPath = tmpDbPath();
29
+ getDb(dbPath);
30
+ });
31
+
32
+ afterEach(() => {
33
+ closeDb();
34
+ tryUnlink(dbPath);
35
+ });
36
+
37
+ describe("upsertEnvironment", () => {
38
+ test("creates a new environment", () => {
39
+ upsertEnvironment("dev", { BASE_URL: "http://localhost:3000" });
40
+ expect(getEnvironment("dev")).toEqual({ BASE_URL: "http://localhost:3000" });
41
+ });
42
+
43
+ test("updates existing environment on conflict", () => {
44
+ upsertEnvironment("dev", { BASE_URL: "http://old" });
45
+ upsertEnvironment("dev", { BASE_URL: "http://new", TOKEN: "abc" });
46
+ expect(getEnvironment("dev")).toEqual({ BASE_URL: "http://new", TOKEN: "abc" });
47
+ });
48
+ });
49
+
50
+ describe("getEnvironment", () => {
51
+ test("returns variables for existing env", () => {
52
+ upsertEnvironment("staging", { API_KEY: "key123" });
53
+ expect(getEnvironment("staging")).toEqual({ API_KEY: "key123" });
54
+ });
55
+
56
+ test("returns null for non-existent env", () => {
57
+ expect(getEnvironment("nonexistent")).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe("getEnvironmentById", () => {
62
+ test("returns full record for existing env", () => {
63
+ upsertEnvironment("prod", { BASE_URL: "https://prod.example.com" });
64
+ const records = listEnvironmentRecords();
65
+ const id = records[0]!.id;
66
+
67
+ const env = getEnvironmentById(id);
68
+ expect(env).not.toBeNull();
69
+ expect(env!.name).toBe("prod");
70
+ expect(env!.variables).toEqual({ BASE_URL: "https://prod.example.com" });
71
+ expect(env!.id).toBe(id);
72
+ });
73
+
74
+ test("returns null for non-existent id", () => {
75
+ expect(getEnvironmentById(9999)).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe("listEnvironments", () => {
80
+ test("returns sorted names", () => {
81
+ upsertEnvironment("staging", {});
82
+ upsertEnvironment("dev", {});
83
+ upsertEnvironment("prod", {});
84
+ expect(listEnvironments()).toEqual(["dev", "prod", "staging"]);
85
+ });
86
+
87
+ test("returns empty array when none exist", () => {
88
+ expect(listEnvironments()).toEqual([]);
89
+ });
90
+ });
91
+
92
+ describe("listEnvironmentRecords", () => {
93
+ test("returns full records with parsed variables", () => {
94
+ upsertEnvironment("dev", { BASE_URL: "http://localhost" });
95
+ upsertEnvironment("prod", { BASE_URL: "https://prod.com", TOKEN: "xyz" });
96
+
97
+ const records = listEnvironmentRecords();
98
+ expect(records).toHaveLength(2);
99
+ expect(records[0]!.name).toBe("dev");
100
+ expect(records[0]!.variables).toEqual({ BASE_URL: "http://localhost" });
101
+ expect(records[1]!.name).toBe("prod");
102
+ expect(records[1]!.variables).toEqual({ BASE_URL: "https://prod.com", TOKEN: "xyz" });
103
+ expect(typeof records[0]!.id).toBe("number");
104
+ });
105
+ });
106
+
107
+ describe("deleteEnvironment", () => {
108
+ test("deletes existing environment and returns true", () => {
109
+ upsertEnvironment("dev", { BASE_URL: "http://localhost" });
110
+ const records = listEnvironmentRecords();
111
+ const id = records[0]!.id;
112
+
113
+ expect(deleteEnvironment(id)).toBe(true);
114
+ expect(getEnvironmentById(id)).toBeNull();
115
+ expect(listEnvironments()).toEqual([]);
116
+ });
117
+
118
+ test("returns false for non-existent id", () => {
119
+ expect(deleteEnvironment(9999)).toBe(false);
120
+ });
121
+
122
+ test("does not affect other environments", () => {
123
+ upsertEnvironment("dev", {});
124
+ upsertEnvironment("prod", {});
125
+ const records = listEnvironmentRecords();
126
+ const devId = records.find(r => r.name === "dev")!.id;
127
+
128
+ deleteEnvironment(devId);
129
+ expect(listEnvironments()).toEqual(["prod"]);
130
+ });
131
+ });
@@ -0,0 +1,409 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { tmpdir } from "os";
3
+ import { join } 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
+ getRunById,
11
+ getResultsByRunId,
12
+ listRuns,
13
+ deleteRun,
14
+ upsertEnvironment,
15
+ getEnvironment,
16
+ listEnvironments,
17
+ getDashboardStats,
18
+ getPassRateTrend,
19
+ getSlowestTests,
20
+ getFlakyTests,
21
+ countRuns,
22
+ } from "../../src/db/queries.ts";
23
+ import type { TestRunResult } from "../../src/core/runner/types.ts";
24
+
25
+ // ──────────────────────────────────────────────
26
+ // Helpers
27
+ // ──────────────────────────────────────────────
28
+
29
+ function tmpDb(): string {
30
+ return join(tmpdir(), `apitool-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
31
+ }
32
+
33
+ function tryUnlink(path: string): void {
34
+ for (const suffix of ["", "-wal", "-shm"]) {
35
+ try { unlinkSync(path + suffix); } catch { /* ignore on Windows */ }
36
+ }
37
+ }
38
+
39
+ function makeSuiteResult(overrides?: Partial<TestRunResult>): TestRunResult {
40
+ return {
41
+ suite_name: "Users API",
42
+ started_at: "2024-01-01T00:00:00.000Z",
43
+ finished_at: "2024-01-01T00:00:01.000Z",
44
+ total: 2,
45
+ passed: 1,
46
+ failed: 1,
47
+ skipped: 0,
48
+ steps: [
49
+ {
50
+ name: "Get user",
51
+ status: "pass",
52
+ duration_ms: 100,
53
+ request: { method: "GET", url: "http://localhost/users/1", headers: {} },
54
+ response: { status: 200, headers: {}, body: '{"id":1}', duration_ms: 100 },
55
+ assertions: [{ field: "status", rule: "equals 200", passed: true, actual: 200, expected: 200 }],
56
+ captures: {},
57
+ },
58
+ {
59
+ name: "Delete user",
60
+ status: "fail",
61
+ duration_ms: 80,
62
+ request: { method: "DELETE", url: "http://localhost/users/1", headers: {} },
63
+ response: { status: 500, headers: {}, body: '{"error":"oops"}', duration_ms: 80 },
64
+ assertions: [{ field: "status", rule: "equals 204", passed: false, actual: 500, expected: 204 }],
65
+ captures: {},
66
+ },
67
+ ],
68
+ ...overrides,
69
+ };
70
+ }
71
+
72
+ let dbPath: string;
73
+
74
+ beforeEach(() => {
75
+ dbPath = tmpDb();
76
+ getDb(dbPath);
77
+ });
78
+
79
+ afterEach(() => {
80
+ closeDb();
81
+ tryUnlink(dbPath);
82
+ });
83
+
84
+ // ──────────────────────────────────────────────
85
+ // createRun
86
+ // ──────────────────────────────────────────────
87
+
88
+ describe("createRun", () => {
89
+ test("returns a positive integer id", () => {
90
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
91
+ expect(typeof id).toBe("number");
92
+ expect(id).toBeGreaterThan(0);
93
+ });
94
+
95
+ test("ids auto-increment", () => {
96
+ const id1 = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
97
+ const id2 = createRun({ started_at: "2024-01-01T00:00:01.000Z" });
98
+ expect(id2).toBe(id1 + 1);
99
+ });
100
+
101
+ test("stores started_at and environment", () => {
102
+ const id = createRun({ started_at: "2024-06-15T10:00:00.000Z", environment: "staging" });
103
+ const row = getRunById(id);
104
+ expect(row?.started_at).toBe("2024-06-15T10:00:00.000Z");
105
+ expect(row?.environment).toBe("staging");
106
+ });
107
+
108
+ test("defaults trigger to 'manual'", () => {
109
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
110
+ const row = getRunById(id);
111
+ expect(row?.trigger).toBe("manual");
112
+ });
113
+ });
114
+
115
+ // ──────────────────────────────────────────────
116
+ // finalizeRun
117
+ // ──────────────────────────────────────────────
118
+
119
+ describe("finalizeRun", () => {
120
+ test("updates total/passed/failed/skipped/finished_at/duration_ms", () => {
121
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
122
+ finalizeRun(id, [makeSuiteResult()]);
123
+
124
+ const row = getRunById(id);
125
+ expect(row?.total).toBe(2);
126
+ expect(row?.passed).toBe(1);
127
+ expect(row?.failed).toBe(1);
128
+ expect(row?.skipped).toBe(0);
129
+ expect(row?.finished_at).toBe("2024-01-01T00:00:01.000Z");
130
+ expect(typeof row?.duration_ms).toBe("number");
131
+ });
132
+
133
+ test("aggregates totals across multiple suites", () => {
134
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
135
+ finalizeRun(id, [
136
+ makeSuiteResult({ total: 3, passed: 2, failed: 1 }),
137
+ makeSuiteResult({ suite_name: "B", total: 2, passed: 2, failed: 0 }),
138
+ ]);
139
+
140
+ const row = getRunById(id);
141
+ expect(row?.total).toBe(5);
142
+ expect(row?.passed).toBe(4);
143
+ expect(row?.failed).toBe(1);
144
+ });
145
+ });
146
+
147
+ // ──────────────────────────────────────────────
148
+ // saveResults / getResultsByRunId
149
+ // ──────────────────────────────────────────────
150
+
151
+ describe("saveResults", () => {
152
+ test("inserts one row per step", () => {
153
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
154
+ saveResults(id, [makeSuiteResult()]);
155
+
156
+ const row = getDb(dbPath).query("SELECT COUNT(*) as n FROM results WHERE run_id = ?").get(id) as { n: number };
157
+ expect(row.n).toBe(2);
158
+ });
159
+
160
+ test("stores suite_name and test_name correctly", () => {
161
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
162
+ saveResults(id, [makeSuiteResult()]);
163
+
164
+ const results = getResultsByRunId(id);
165
+ expect(results[0]?.suite_name).toBe("Users API");
166
+ expect(results[0]?.test_name).toBe("Get user");
167
+ });
168
+
169
+ test("response_body is null for passing steps", () => {
170
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
171
+ saveResults(id, [makeSuiteResult()]);
172
+
173
+ const row = getDb(dbPath)
174
+ .query("SELECT response_body FROM results WHERE test_name = 'Get user'")
175
+ .get() as { response_body: string | null };
176
+ expect(row?.response_body).toBeNull();
177
+ });
178
+
179
+ test("response_body is stored for failing steps", () => {
180
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
181
+ saveResults(id, [makeSuiteResult()]);
182
+
183
+ const row = getDb(dbPath)
184
+ .query("SELECT response_body FROM results WHERE test_name = 'Delete user'")
185
+ .get() as { response_body: string | null };
186
+ expect(row?.response_body).toBe('{"error":"oops"}');
187
+ });
188
+
189
+ test("assertions are deserialized back from JSON", () => {
190
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
191
+ saveResults(id, [makeSuiteResult()]);
192
+
193
+ const results = getResultsByRunId(id);
194
+ const passStep = results.find((r) => r.test_name === "Get user")!;
195
+ expect(Array.isArray(passStep.assertions)).toBe(true);
196
+ expect(passStep.assertions[0]?.rule).toBe("equals 200");
197
+ });
198
+
199
+ test("handles multiple suites in one call", () => {
200
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
201
+ saveResults(id, [makeSuiteResult(), makeSuiteResult({ suite_name: "Posts API" })]);
202
+
203
+ const count = getDb(dbPath)
204
+ .query("SELECT COUNT(*) as n FROM results WHERE run_id = ?")
205
+ .get(id) as { n: number };
206
+ expect(count.n).toBe(4);
207
+ });
208
+
209
+ test("skipped steps have null response_body", () => {
210
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
211
+ const suite = makeSuiteResult({
212
+ total: 1,
213
+ passed: 0,
214
+ failed: 0,
215
+ skipped: 1,
216
+ steps: [{
217
+ name: "Skipped step",
218
+ status: "skip",
219
+ duration_ms: 0,
220
+ request: { method: "GET", url: "http://localhost/skip", headers: {} },
221
+ assertions: [],
222
+ captures: {},
223
+ error: "dependency failed",
224
+ }],
225
+ });
226
+ saveResults(id, [suite]);
227
+
228
+ const row = getDb(dbPath)
229
+ .query("SELECT response_body FROM results WHERE test_name = 'Skipped step'")
230
+ .get() as { response_body: string | null };
231
+ expect(row?.response_body).toBeNull();
232
+ });
233
+ });
234
+
235
+ // ──────────────────────────────────────────────
236
+ // listRuns / deleteRun
237
+ // ──────────────────────────────────────────────
238
+
239
+ describe("listRuns", () => {
240
+ test("returns runs ordered by started_at DESC", () => {
241
+ createRun({ started_at: "2024-01-01T00:00:00.000Z" });
242
+ createRun({ started_at: "2024-01-03T00:00:00.000Z" });
243
+ createRun({ started_at: "2024-01-02T00:00:00.000Z" });
244
+
245
+ const runs = listRuns();
246
+ expect(runs[0]?.started_at).toBe("2024-01-03T00:00:00.000Z");
247
+ expect(runs[2]?.started_at).toBe("2024-01-01T00:00:00.000Z");
248
+ });
249
+
250
+ test("respects limit and offset", () => {
251
+ for (let i = 1; i <= 5; i++) {
252
+ createRun({ started_at: `2024-01-0${i}T00:00:00.000Z` });
253
+ }
254
+ const page = listRuns(2, 1);
255
+ expect(page).toHaveLength(2);
256
+ });
257
+
258
+ test("returns empty array when no runs", () => {
259
+ expect(listRuns()).toEqual([]);
260
+ });
261
+ });
262
+
263
+ describe("deleteRun", () => {
264
+ test("returns true when run exists and deletes it", () => {
265
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
266
+ expect(deleteRun(id)).toBe(true);
267
+ expect(getRunById(id)).toBeNull();
268
+ });
269
+
270
+ test("returns false when run does not exist", () => {
271
+ expect(deleteRun(9999)).toBe(false);
272
+ });
273
+
274
+ test("also deletes associated results", () => {
275
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
276
+ saveResults(id, [makeSuiteResult()]);
277
+ deleteRun(id);
278
+
279
+ const count = getDb(dbPath)
280
+ .query("SELECT COUNT(*) as n FROM results WHERE run_id = ?")
281
+ .get(id) as { n: number };
282
+ expect(count.n).toBe(0);
283
+ });
284
+ });
285
+
286
+ // ──────────────────────────────────────────────
287
+ // Environments
288
+ // ──────────────────────────────────────────────
289
+
290
+ describe("environments", () => {
291
+ test("upsertEnvironment and getEnvironment round-trip", () => {
292
+ upsertEnvironment("staging", { BASE_URL: "https://staging.example.com", TOKEN: "abc" });
293
+ expect(getEnvironment("staging")).toEqual({ BASE_URL: "https://staging.example.com", TOKEN: "abc" });
294
+ });
295
+
296
+ test("upsert overwrites existing environment", () => {
297
+ upsertEnvironment("prod", { BASE_URL: "https://old.example.com" });
298
+ upsertEnvironment("prod", { BASE_URL: "https://new.example.com" });
299
+ expect(getEnvironment("prod")?.BASE_URL).toBe("https://new.example.com");
300
+ });
301
+
302
+ test("getEnvironment returns null for unknown name", () => {
303
+ expect(getEnvironment("nonexistent")).toBeNull();
304
+ });
305
+
306
+ test("listEnvironments returns all environment names sorted", () => {
307
+ upsertEnvironment("staging", {});
308
+ upsertEnvironment("prod", {});
309
+ upsertEnvironment("dev", {});
310
+ expect(listEnvironments()).toEqual(["dev", "prod", "staging"]);
311
+ });
312
+
313
+ test("listEnvironments returns empty array when none exist", () => {
314
+ expect(listEnvironments()).toEqual([]);
315
+ });
316
+ });
317
+
318
+ // ──────────────────────────────────────────────
319
+ // Dashboard metrics
320
+ // ──────────────────────────────────────────────
321
+
322
+ describe("getDashboardStats", () => {
323
+ test("returns zeros when no runs", () => {
324
+ const stats = getDashboardStats();
325
+ expect(stats.totalRuns).toBe(0);
326
+ expect(stats.totalTests).toBe(0);
327
+ expect(stats.overallPassRate).toBe(0);
328
+ expect(stats.avgDuration).toBe(0);
329
+ });
330
+
331
+ test("returns correct aggregates", () => {
332
+ const id1 = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
333
+ finalizeRun(id1, [makeSuiteResult({ total: 4, passed: 3, failed: 1 })]);
334
+ const id2 = createRun({ started_at: "2024-01-02T00:00:00.000Z" });
335
+ finalizeRun(id2, [makeSuiteResult({ total: 6, passed: 6, failed: 0 })]);
336
+
337
+ const stats = getDashboardStats();
338
+ expect(stats.totalRuns).toBe(2);
339
+ expect(stats.totalTests).toBe(10);
340
+ expect(stats.overallPassRate).toBe(90);
341
+ });
342
+ });
343
+
344
+ describe("getPassRateTrend", () => {
345
+ test("returns empty array when no runs", () => {
346
+ expect(getPassRateTrend()).toEqual([]);
347
+ });
348
+
349
+ test("returns pass rate per run", () => {
350
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
351
+ finalizeRun(id, [makeSuiteResult({ total: 10, passed: 8, failed: 2 })]);
352
+
353
+ const trend = getPassRateTrend(10);
354
+ expect(trend).toHaveLength(1);
355
+ expect(trend[0]!.pass_rate).toBe(80);
356
+ });
357
+ });
358
+
359
+ describe("getSlowestTests", () => {
360
+ test("returns empty array when no results", () => {
361
+ expect(getSlowestTests()).toEqual([]);
362
+ });
363
+
364
+ test("returns tests sorted by avg duration desc", () => {
365
+ const id = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
366
+ saveResults(id, [makeSuiteResult()]);
367
+
368
+ const slow = getSlowestTests(5);
369
+ expect(slow.length).toBeGreaterThan(0);
370
+ expect(slow[0]!.suite_name).toBe("Users API");
371
+ });
372
+ });
373
+
374
+ describe("getFlakyTests", () => {
375
+ test("returns empty array when no flaky tests", () => {
376
+ expect(getFlakyTests()).toEqual([]);
377
+ });
378
+
379
+ test("detects flaky tests with multiple statuses", () => {
380
+ // Run 1: step passes
381
+ const id1 = createRun({ started_at: "2024-01-01T00:00:00.000Z" });
382
+ saveResults(id1, [makeSuiteResult({
383
+ steps: [{ name: "Flaky step", status: "pass", duration_ms: 100, request: { method: "GET", url: "http://x", headers: {} }, assertions: [], captures: {} }],
384
+ })]);
385
+
386
+ // Run 2: same step fails
387
+ const id2 = createRun({ started_at: "2024-01-02T00:00:00.000Z" });
388
+ saveResults(id2, [makeSuiteResult({
389
+ steps: [{ name: "Flaky step", status: "fail", duration_ms: 100, request: { method: "GET", url: "http://x", headers: {} }, assertions: [], captures: {} }],
390
+ })]);
391
+
392
+ const flaky = getFlakyTests(10, 5);
393
+ expect(flaky.length).toBe(1);
394
+ expect(flaky[0]!.test_name).toBe("Flaky step");
395
+ expect(flaky[0]!.distinct_statuses).toBe(2);
396
+ });
397
+ });
398
+
399
+ describe("countRuns", () => {
400
+ test("returns 0 when no runs", () => {
401
+ expect(countRuns()).toBe(0);
402
+ });
403
+
404
+ test("returns correct count", () => {
405
+ createRun({ started_at: "2024-01-01T00:00:00.000Z" });
406
+ createRun({ started_at: "2024-01-02T00:00:00.000Z" });
407
+ expect(countRuns()).toBe(2);
408
+ });
409
+ });
@@ -0,0 +1,141 @@
1
+ import { describe, test, expect, afterEach } from "bun:test";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+ import { existsSync, unlinkSync } from "fs";
5
+ import { getDb, closeDb } from "../../src/db/schema.ts";
6
+
7
+ function tmpDb(): string {
8
+ return join(tmpdir(), `apitool-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
9
+ }
10
+
11
+ function tryUnlink(path: string): void {
12
+ // WAL mode creates -wal and -shm sidecar files; cleanup is best-effort on Windows
13
+ for (const suffix of ["", "-wal", "-shm"]) {
14
+ try { unlinkSync(path + suffix); } catch { /* ignore */ }
15
+ }
16
+ }
17
+
18
+ describe("getDb / schema", () => {
19
+ let dbPath: string | undefined;
20
+
21
+ afterEach(() => {
22
+ closeDb();
23
+ if (dbPath) { tryUnlink(dbPath); dbPath = undefined; }
24
+ });
25
+
26
+ test("creates the db file at the given path", () => {
27
+ dbPath = tmpDb();
28
+ getDb(dbPath);
29
+ expect(existsSync(dbPath)).toBe(true);
30
+ });
31
+
32
+ test("returns the same singleton on repeated calls", () => {
33
+ dbPath = tmpDb();
34
+ const a = getDb(dbPath);
35
+ const b = getDb(dbPath);
36
+ expect(a).toBe(b);
37
+ });
38
+
39
+ test("creates runs table", () => {
40
+ dbPath = tmpDb();
41
+ const db = getDb(dbPath);
42
+ const rows = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='runs'").all();
43
+ expect(rows).toHaveLength(1);
44
+ });
45
+
46
+ test("creates results table", () => {
47
+ dbPath = tmpDb();
48
+ const db = getDb(dbPath);
49
+ const rows = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='results'").all();
50
+ expect(rows).toHaveLength(1);
51
+ });
52
+
53
+ test("creates environments table", () => {
54
+ dbPath = tmpDb();
55
+ const db = getDb(dbPath);
56
+ const rows = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='environments'").all();
57
+ expect(rows).toHaveLength(1);
58
+ });
59
+
60
+ test("creates collections table", () => {
61
+ dbPath = tmpDb();
62
+ const db = getDb(dbPath);
63
+ const rows = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='collections'").all();
64
+ expect(rows).toHaveLength(1);
65
+ });
66
+
67
+ test("creates all indexes", () => {
68
+ dbPath = tmpDb();
69
+ const db = getDb(dbPath);
70
+ const indexes = db
71
+ .query("SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'")
72
+ .all() as { name: string }[];
73
+ const names = indexes.map((r) => r.name);
74
+ expect(names).toContain("idx_runs_started");
75
+ expect(names).toContain("idx_results_run");
76
+ expect(names).toContain("idx_results_status");
77
+ expect(names).toContain("idx_results_name");
78
+ expect(names).toContain("idx_runs_collection");
79
+ expect(names).toContain("idx_collections_name");
80
+ });
81
+
82
+ test("enables WAL journal mode", () => {
83
+ dbPath = tmpDb();
84
+ const db = getDb(dbPath);
85
+ const row = db.query("PRAGMA journal_mode").get() as { journal_mode: string };
86
+ expect(row.journal_mode).toBe("wal");
87
+ });
88
+
89
+ test("enables foreign keys", () => {
90
+ dbPath = tmpDb();
91
+ const db = getDb(dbPath);
92
+ const row = db.query("PRAGMA foreign_keys").get() as { foreign_keys: number };
93
+ expect(row.foreign_keys).toBe(1);
94
+ });
95
+
96
+ test("sets user_version to latest after migration", () => {
97
+ dbPath = tmpDb();
98
+ const db = getDb(dbPath);
99
+ const row = db.query("PRAGMA user_version").get() as { user_version: number };
100
+ expect(row.user_version).toBe(5);
101
+ });
102
+
103
+ test("closeDb resets singleton so next call opens fresh db", () => {
104
+ const path1 = tmpDb();
105
+ const path2 = tmpDb();
106
+ try {
107
+ const db1 = getDb(path1);
108
+ closeDb();
109
+ const db2 = getDb(path2);
110
+ expect(db1).not.toBe(db2);
111
+ } finally {
112
+ closeDb();
113
+ tryUnlink(path1);
114
+ tryUnlink(path2);
115
+ dbPath = undefined;
116
+ }
117
+ });
118
+
119
+ test("getDb() without args reuses existing singleton path", () => {
120
+ const path1 = tmpDb();
121
+ try {
122
+ const db1 = getDb(path1);
123
+ const db2 = getDb(); // no args — should reuse path1
124
+ expect(db2).toBe(db1);
125
+ } finally {
126
+ closeDb();
127
+ tryUnlink(path1);
128
+ dbPath = undefined;
129
+ }
130
+ });
131
+
132
+ test("re-opening existing db does not re-run migrations (idempotent)", () => {
133
+ dbPath = tmpDb();
134
+ getDb(dbPath);
135
+ closeDb();
136
+ // Should not throw or duplicate tables
137
+ const db = getDb(dbPath);
138
+ const rows = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='runs'").all();
139
+ expect(rows).toHaveLength(1);
140
+ });
141
+ });
@@ -0,0 +1,3 @@
1
+ base: http://localhost:3000/api
2
+ token: dev-token-123
3
+ count: 42
@@ -0,0 +1,8 @@
1
+ name: Auth Token Test
2
+ headers:
3
+ Authorization: "Bearer {{auth_token}}"
4
+ tests:
5
+ - name: "Authenticated request"
6
+ GET: /protected
7
+ expect:
8
+ status: 200