@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,868 @@
1
+ import { getDb } from "./schema.ts";
2
+ import { resolve } from "path";
3
+ import type { StepResult, TestRunResult } from "../core/runner/types.ts";
4
+
5
+ // ──────────────────────────────────────────────
6
+ // Path normalization
7
+ // ──────────────────────────────────────────────
8
+
9
+ export function normalizePath(p: string): string {
10
+ return resolve(p).replace(/\\/g, "/");
11
+ }
12
+
13
+ // ──────────────────────────────────────────────
14
+ // Types
15
+ // ──────────────────────────────────────────────
16
+
17
+ export interface CreateRunOpts {
18
+ started_at: string;
19
+ environment?: string;
20
+ trigger?: string;
21
+ commit_sha?: string;
22
+ branch?: string;
23
+ collection_id?: number;
24
+ }
25
+
26
+ export interface RunRecord {
27
+ id: number;
28
+ started_at: string;
29
+ finished_at: string | null;
30
+ total: number;
31
+ passed: number;
32
+ failed: number;
33
+ skipped: number;
34
+ trigger: string;
35
+ commit_sha: string | null;
36
+ branch: string | null;
37
+ environment: string | null;
38
+ duration_ms: number | null;
39
+ collection_id: number | null;
40
+ }
41
+
42
+ export interface RunSummary {
43
+ id: number;
44
+ started_at: string;
45
+ finished_at: string | null;
46
+ total: number;
47
+ passed: number;
48
+ failed: number;
49
+ skipped: number;
50
+ environment: string | null;
51
+ duration_ms: number | null;
52
+ collection_id: number | null;
53
+ }
54
+
55
+ // ──────────────────────────────────────────────
56
+ // Collection types
57
+ // ──────────────────────────────────────────────
58
+
59
+ export interface CollectionRecord {
60
+ id: number;
61
+ name: string;
62
+ base_dir: string | null;
63
+ test_path: string;
64
+ openapi_spec: string | null;
65
+ created_at: string;
66
+ }
67
+
68
+ export interface CollectionSummary {
69
+ id: number;
70
+ name: string;
71
+ base_dir: string | null;
72
+ test_path: string;
73
+ openapi_spec: string | null;
74
+ created_at: string;
75
+ total_runs: number;
76
+ pass_rate: number;
77
+ last_run_at: string | null;
78
+ last_run_passed: number;
79
+ last_run_failed: number;
80
+ last_run_total: number;
81
+ }
82
+
83
+ export interface CreateCollectionOpts {
84
+ name: string;
85
+ base_dir?: string;
86
+ test_path: string;
87
+ openapi_spec?: string;
88
+ }
89
+
90
+ export interface StoredStepResult {
91
+ id: number;
92
+ run_id: number;
93
+ suite_name: string;
94
+ test_name: string;
95
+ status: string;
96
+ duration_ms: number;
97
+ request_method: string | null;
98
+ request_url: string | null;
99
+ request_body: string | null;
100
+ response_status: number | null;
101
+ response_body: string | null;
102
+ response_headers: string | null;
103
+ error_message: string | null;
104
+ assertions: import("../core/runner/types.ts").AssertionResult[];
105
+ captures: Record<string, unknown>;
106
+ }
107
+
108
+ // ──────────────────────────────────────────────
109
+ // Runs
110
+ // ──────────────────────────────────────────────
111
+
112
+ export function createRun(opts: CreateRunOpts): number {
113
+ const db = getDb();
114
+ const stmt = db.prepare(`
115
+ INSERT INTO runs (started_at, environment, trigger, commit_sha, branch, collection_id)
116
+ VALUES ($started_at, $environment, $trigger, $commit_sha, $branch, $collection_id)
117
+ `);
118
+ const result = stmt.run({
119
+ $started_at: opts.started_at,
120
+ $environment: opts.environment ?? null,
121
+ $trigger: opts.trigger ?? "manual",
122
+ $commit_sha: opts.commit_sha ?? null,
123
+ $branch: opts.branch ?? null,
124
+ $collection_id: opts.collection_id ?? null,
125
+ });
126
+ return Number(result.lastInsertRowid);
127
+ }
128
+
129
+ export function finalizeRun(runId: number, results: TestRunResult[]): void {
130
+ const db = getDb();
131
+
132
+ const total = results.reduce((s, r) => s + r.total, 0);
133
+ const passed = results.reduce((s, r) => s + r.passed, 0);
134
+ const failed = results.reduce((s, r) => s + r.failed, 0);
135
+ const skipped = results.reduce((s, r) => s + r.skipped, 0);
136
+
137
+ const started = results[0]?.started_at ?? new Date().toISOString();
138
+ const finished = results[results.length - 1]?.finished_at ?? new Date().toISOString();
139
+ const durationMs = new Date(finished).getTime() - new Date(started).getTime();
140
+
141
+ db.prepare(`
142
+ UPDATE runs
143
+ SET finished_at = $finished_at,
144
+ total = $total,
145
+ passed = $passed,
146
+ failed = $failed,
147
+ skipped = $skipped,
148
+ duration_ms = $duration_ms
149
+ WHERE id = $id
150
+ `).run({
151
+ $finished_at: finished,
152
+ $total: total,
153
+ $passed: passed,
154
+ $failed: failed,
155
+ $skipped: skipped,
156
+ $duration_ms: durationMs,
157
+ $id: runId,
158
+ });
159
+ }
160
+
161
+ export function getRunById(runId: number): RunRecord | null {
162
+ const db = getDb();
163
+ return db.query("SELECT * FROM runs WHERE id = ?").get(runId) as RunRecord | null;
164
+ }
165
+
166
+ export interface RunFilters {
167
+ status?: string;
168
+ environment?: string;
169
+ date_from?: string;
170
+ date_to?: string;
171
+ test_name?: string;
172
+ }
173
+
174
+ function buildRunFilterSQL(filters: RunFilters): { where: string; params: unknown[] } {
175
+ const clauses: string[] = [];
176
+ const params: unknown[] = [];
177
+
178
+ if (filters.status === "has_failures") {
179
+ clauses.push("r.failed > 0");
180
+ } else if (filters.status === "all_passed") {
181
+ clauses.push("r.failed = 0 AND r.total > 0");
182
+ }
183
+
184
+ if (filters.environment) {
185
+ clauses.push("r.environment = ?");
186
+ params.push(filters.environment);
187
+ }
188
+
189
+ if (filters.date_from) {
190
+ clauses.push("r.started_at >= ?");
191
+ params.push(filters.date_from);
192
+ }
193
+
194
+ if (filters.date_to) {
195
+ clauses.push("r.started_at <= ?");
196
+ params.push(filters.date_to + "T23:59:59");
197
+ }
198
+
199
+ if (filters.test_name) {
200
+ clauses.push("r.id IN (SELECT DISTINCT run_id FROM results WHERE test_name LIKE ?)");
201
+ params.push(`%${filters.test_name}%`);
202
+ }
203
+
204
+ const where = clauses.length > 0 ? "WHERE " + clauses.join(" AND ") : "";
205
+ return { where, params };
206
+ }
207
+
208
+ export function listRuns(limit = 20, offset = 0, filters?: RunFilters): RunSummary[] {
209
+ const db = getDb();
210
+ if (filters && Object.values(filters).some(Boolean)) {
211
+ const { where, params } = buildRunFilterSQL(filters);
212
+ return db.query(`
213
+ SELECT r.id, r.started_at, r.finished_at, r.total, r.passed, r.failed, r.skipped, r.environment, r.duration_ms, r.collection_id
214
+ FROM runs r
215
+ ${where}
216
+ ORDER BY r.started_at DESC
217
+ LIMIT ? OFFSET ?
218
+ `).all(...(params as (string | number)[]), limit, offset) as RunSummary[];
219
+ }
220
+ return db.query(`
221
+ SELECT id, started_at, finished_at, total, passed, failed, skipped, environment, duration_ms, collection_id
222
+ FROM runs
223
+ ORDER BY started_at DESC
224
+ LIMIT ? OFFSET ?
225
+ `).all(limit, offset) as RunSummary[];
226
+ }
227
+
228
+ export function deleteRun(runId: number): boolean {
229
+ const db = getDb();
230
+ // results are cascade-deleted via FK; but SQLite FK delete cascade requires explicit config
231
+ db.prepare("DELETE FROM results WHERE run_id = ?").run(runId);
232
+ const result = db.prepare("DELETE FROM runs WHERE id = ?").run(runId);
233
+ return result.changes > 0;
234
+ }
235
+
236
+ // ──────────────────────────────────────────────
237
+ // Results (steps)
238
+ // ──────────────────────────────────────────────
239
+
240
+ export function saveResults(runId: number, suiteResults: TestRunResult[]): void {
241
+ const db = getDb();
242
+
243
+ const stmt = db.prepare(`
244
+ INSERT INTO results
245
+ (run_id, suite_name, test_name, status, duration_ms,
246
+ request_method, request_url, request_body,
247
+ response_status, response_body, response_headers, error_message, assertions, captures)
248
+ VALUES
249
+ ($run_id, $suite_name, $test_name, $status, $duration_ms,
250
+ $request_method, $request_url, $request_body,
251
+ $response_status, $response_body, $response_headers, $error_message, $assertions, $captures)
252
+ `);
253
+
254
+ db.transaction(() => {
255
+ for (const suite of suiteResults) {
256
+ for (const step of suite.steps) {
257
+ const keepBody = step.status === "fail" || step.status === "error";
258
+ stmt.run({
259
+ $run_id: runId,
260
+ $suite_name: suite.suite_name,
261
+ $test_name: step.name,
262
+ $status: step.status,
263
+ $duration_ms: step.duration_ms,
264
+ $request_method: step.request.method,
265
+ $request_url: step.request.url,
266
+ $request_body: step.request.body ?? null,
267
+ $response_status: step.response?.status ?? null,
268
+ $response_body: keepBody ? (step.response?.body ?? null) : null,
269
+ $response_headers: keepBody && step.response?.headers
270
+ ? JSON.stringify(step.response.headers)
271
+ : null,
272
+ $error_message: step.error ?? null,
273
+ $assertions: step.assertions.length > 0 ? JSON.stringify(step.assertions) : null,
274
+ $captures: Object.keys(step.captures).length > 0 ? JSON.stringify(step.captures) : null,
275
+ });
276
+ }
277
+ }
278
+ })();
279
+ }
280
+
281
+ export function getResultsByRunId(runId: number): StoredStepResult[] {
282
+ const db = getDb();
283
+ const rows = db.query("SELECT * FROM results WHERE run_id = ? ORDER BY id").all(runId) as Array<
284
+ Omit<StoredStepResult, "assertions" | "captures"> & { assertions: string | null; captures: string | null }
285
+ >;
286
+ return rows.map((row) => ({
287
+ ...row,
288
+ assertions: row.assertions ? JSON.parse(row.assertions) : [],
289
+ captures: row.captures ? JSON.parse(row.captures) : {},
290
+ }));
291
+ }
292
+
293
+ // ──────────────────────────────────────────────
294
+ // Environments
295
+ // ──────────────────────────────────────────────
296
+
297
+ export function upsertEnvironment(name: string, vars: Record<string, string>, collectionId?: number | null): void {
298
+ const db = getDb();
299
+ const cid = collectionId ?? null;
300
+ // Check if exists first (unique index is composite)
301
+ const existing = cid === null
302
+ ? db.query("SELECT id FROM environments WHERE name = ? AND collection_id IS NULL").get(name) as { id: number } | null
303
+ : db.query("SELECT id FROM environments WHERE name = ? AND collection_id = ?").get(name, cid) as { id: number } | null;
304
+
305
+ if (existing) {
306
+ db.prepare("UPDATE environments SET variables = ? WHERE id = ?").run(JSON.stringify(vars), existing.id);
307
+ } else {
308
+ db.prepare("INSERT INTO environments (name, collection_id, variables) VALUES (?, ?, ?)").run(name, cid, JSON.stringify(vars));
309
+ }
310
+ }
311
+
312
+ export function getEnvironment(name: string, collectionId?: number): Record<string, string> | null {
313
+ const db = getDb();
314
+ if (collectionId !== undefined) {
315
+ // Try scoped first, then global
316
+ const scoped = db.query("SELECT variables FROM environments WHERE name = ? AND collection_id = ?").get(name, collectionId) as { variables: string } | null;
317
+ if (scoped) return JSON.parse(scoped.variables);
318
+ }
319
+ const global = db.query("SELECT variables FROM environments WHERE name = ? AND collection_id IS NULL").get(name) as { variables: string } | null;
320
+ return global ? JSON.parse(global.variables) : null;
321
+ }
322
+
323
+ export function resolveEnvironment(name: string, collectionId?: number): Record<string, string> | null {
324
+ const db = getDb();
325
+ // Start with global as base
326
+ const globalRow = db.query("SELECT variables FROM environments WHERE name = ? AND collection_id IS NULL").get(name) as { variables: string } | null;
327
+ const globalVars = globalRow ? JSON.parse(globalRow.variables) as Record<string, string> : null;
328
+
329
+ if (collectionId === undefined) return globalVars;
330
+
331
+ // Scoped overrides
332
+ const scopedRow = db.query("SELECT variables FROM environments WHERE name = ? AND collection_id = ?").get(name, collectionId) as { variables: string } | null;
333
+ if (!scopedRow) return globalVars;
334
+
335
+ const scopedVars = JSON.parse(scopedRow.variables) as Record<string, string>;
336
+ if (!globalVars) return scopedVars;
337
+
338
+ // Merge: global base + scoped overrides
339
+ return { ...globalVars, ...scopedVars };
340
+ }
341
+
342
+ export interface EnvironmentRecord {
343
+ id: number;
344
+ name: string;
345
+ collection_id: number | null;
346
+ variables: Record<string, string>;
347
+ }
348
+
349
+ export function listEnvironments(collectionId?: number): string[] {
350
+ const db = getDb();
351
+ if (collectionId !== undefined) {
352
+ const rows = db.query("SELECT DISTINCT name FROM environments WHERE collection_id = ? OR collection_id IS NULL ORDER BY name").all(collectionId) as { name: string }[];
353
+ return rows.map((r) => r.name);
354
+ }
355
+ const rows = db.query("SELECT name FROM environments ORDER BY name").all() as { name: string }[];
356
+ return rows.map((r) => r.name);
357
+ }
358
+
359
+ export function listEnvironmentRecords(collectionId?: number | null): EnvironmentRecord[] {
360
+ const db = getDb();
361
+ let rows: { id: number; name: string; collection_id: number | null; variables: string }[];
362
+ if (collectionId !== undefined) {
363
+ if (collectionId === null) {
364
+ rows = db.query("SELECT id, name, collection_id, variables FROM environments WHERE collection_id IS NULL ORDER BY name").all() as typeof rows;
365
+ } else {
366
+ rows = db.query("SELECT id, name, collection_id, variables FROM environments WHERE collection_id = ? OR collection_id IS NULL ORDER BY collection_id IS NULL, name").all(collectionId) as typeof rows;
367
+ }
368
+ } else {
369
+ rows = db.query("SELECT id, name, collection_id, variables FROM environments ORDER BY name").all() as typeof rows;
370
+ }
371
+ return rows.map((r) => ({ id: r.id, name: r.name, collection_id: r.collection_id, variables: JSON.parse(r.variables) }));
372
+ }
373
+
374
+ export function getEnvironmentById(id: number): EnvironmentRecord | null {
375
+ const db = getDb();
376
+ const row = db.query("SELECT id, name, collection_id, variables FROM environments WHERE id = ?").get(id) as { id: number; name: string; collection_id: number | null; variables: string } | null;
377
+ if (!row) return null;
378
+ return { id: row.id, name: row.name, collection_id: row.collection_id, variables: JSON.parse(row.variables) };
379
+ }
380
+
381
+ export function deleteEnvironment(id: number): boolean {
382
+ const db = getDb();
383
+ const result = db.prepare("DELETE FROM environments WHERE id = ?").run(id);
384
+ return result.changes > 0;
385
+ }
386
+
387
+ // ──────────────────────────────────────────────
388
+ // Dashboard metrics
389
+ // ──────────────────────────────────────────────
390
+
391
+ export interface DashboardStats {
392
+ totalRuns: number;
393
+ totalTests: number;
394
+ overallPassRate: number;
395
+ avgDuration: number;
396
+ }
397
+
398
+ export function getDashboardStats(): DashboardStats {
399
+ const db = getDb();
400
+ const row = db.query(`
401
+ SELECT
402
+ COUNT(*) AS totalRuns,
403
+ COALESCE(SUM(total), 0) AS totalTests,
404
+ CASE WHEN SUM(total) > 0
405
+ THEN ROUND(SUM(passed) * 100.0 / SUM(total), 1)
406
+ ELSE 0 END AS overallPassRate,
407
+ COALESCE(ROUND(AVG(duration_ms), 0), 0) AS avgDuration
408
+ FROM runs
409
+ WHERE finished_at IS NOT NULL
410
+ `).get() as { totalRuns: number; totalTests: number; overallPassRate: number; avgDuration: number };
411
+ return row;
412
+ }
413
+
414
+ export interface PassRateTrendPoint {
415
+ run_id: number;
416
+ started_at: string;
417
+ pass_rate: number;
418
+ }
419
+
420
+ export function getPassRateTrend(limit = 30): PassRateTrendPoint[] {
421
+ const db = getDb();
422
+ return db.query(`
423
+ SELECT id AS run_id, started_at,
424
+ CASE WHEN total > 0 THEN ROUND(passed * 100.0 / total, 1) ELSE 0 END AS pass_rate
425
+ FROM runs
426
+ WHERE finished_at IS NOT NULL
427
+ ORDER BY started_at DESC
428
+ LIMIT ?
429
+ `).all(limit) as PassRateTrendPoint[];
430
+ }
431
+
432
+ export interface SlowestTest {
433
+ suite_name: string;
434
+ test_name: string;
435
+ avg_duration: number;
436
+ }
437
+
438
+ export function getSlowestTests(limit = 5): SlowestTest[] {
439
+ const db = getDb();
440
+ return db.query(`
441
+ SELECT suite_name, test_name, ROUND(AVG(duration_ms), 0) AS avg_duration
442
+ FROM results
443
+ GROUP BY suite_name, test_name
444
+ ORDER BY avg_duration DESC
445
+ LIMIT ?
446
+ `).all(limit) as SlowestTest[];
447
+ }
448
+
449
+ export interface FlakyTest {
450
+ suite_name: string;
451
+ test_name: string;
452
+ distinct_statuses: number;
453
+ }
454
+
455
+ export function getFlakyTests(runsBack = 20, limit = 5): FlakyTest[] {
456
+ const db = getDb();
457
+ return db.query(`
458
+ SELECT r.suite_name, r.test_name, COUNT(DISTINCT r.status) AS distinct_statuses
459
+ FROM results r
460
+ INNER JOIN (SELECT id FROM runs ORDER BY started_at DESC LIMIT ?) recent ON r.run_id = recent.id
461
+ GROUP BY r.suite_name, r.test_name
462
+ HAVING COUNT(DISTINCT r.status) > 1
463
+ ORDER BY distinct_statuses DESC
464
+ LIMIT ?
465
+ `).all(runsBack, limit) as FlakyTest[];
466
+ }
467
+
468
+ export function countRuns(filters?: RunFilters): number {
469
+ const db = getDb();
470
+ if (filters && Object.values(filters).some(Boolean)) {
471
+ const { where, params } = buildRunFilterSQL(filters);
472
+ const row = db.query(`SELECT COUNT(*) AS cnt FROM runs r ${where}`).get(...(params as (string | number)[])) as { cnt: number };
473
+ return row.cnt;
474
+ }
475
+ const row = db.query("SELECT COUNT(*) AS cnt FROM runs").get() as { cnt: number };
476
+ return row.cnt;
477
+ }
478
+
479
+ export function getDistinctEnvironments(): string[] {
480
+ const db = getDb();
481
+ const rows = db.query("SELECT DISTINCT environment FROM runs WHERE environment IS NOT NULL ORDER BY environment").all() as { environment: string }[];
482
+ return rows.map((r) => r.environment);
483
+ }
484
+
485
+ // ──────────────────────────────────────────────
486
+ // Collections
487
+ // ──────────────────────────────────────────────
488
+
489
+ export function createCollection(opts: CreateCollectionOpts): number {
490
+ const db = getDb();
491
+ const stmt = db.prepare(`
492
+ INSERT INTO collections (name, base_dir, test_path, openapi_spec)
493
+ VALUES ($name, $base_dir, $test_path, $openapi_spec)
494
+ `);
495
+ const result = stmt.run({
496
+ $name: opts.name,
497
+ $base_dir: opts.base_dir ?? null,
498
+ $test_path: opts.test_path,
499
+ $openapi_spec: opts.openapi_spec ?? null,
500
+ });
501
+ return Number(result.lastInsertRowid);
502
+ }
503
+
504
+ export function getCollectionById(id: number): CollectionRecord | null {
505
+ const db = getDb();
506
+ return db.query("SELECT * FROM collections WHERE id = ?").get(id) as CollectionRecord | null;
507
+ }
508
+
509
+ export function listCollections(): CollectionSummary[] {
510
+ const db = getDb();
511
+ return db.query(`
512
+ SELECT
513
+ c.id, c.name, c.base_dir, c.test_path, c.openapi_spec, c.created_at,
514
+ COUNT(r.id) AS total_runs,
515
+ CASE WHEN SUM(r.total) > 0
516
+ THEN ROUND(SUM(r.passed) * 100.0 / SUM(r.total), 1)
517
+ ELSE 0 END AS pass_rate,
518
+ MAX(r.started_at) AS last_run_at,
519
+ COALESCE((SELECT passed FROM runs WHERE collection_id = c.id ORDER BY started_at DESC LIMIT 1), 0) AS last_run_passed,
520
+ COALESCE((SELECT failed FROM runs WHERE collection_id = c.id ORDER BY started_at DESC LIMIT 1), 0) AS last_run_failed,
521
+ COALESCE((SELECT total FROM runs WHERE collection_id = c.id ORDER BY started_at DESC LIMIT 1), 0) AS last_run_total
522
+ FROM collections c
523
+ LEFT JOIN runs r ON r.collection_id = c.id AND r.finished_at IS NOT NULL
524
+ GROUP BY c.id
525
+ ORDER BY c.name
526
+ `).all() as CollectionSummary[];
527
+ }
528
+
529
+ export function updateCollection(id: number, opts: Partial<CreateCollectionOpts>): boolean {
530
+ const db = getDb();
531
+ const sets: string[] = [];
532
+ const params: Record<string, any> = { $id: id };
533
+
534
+ if (opts.name !== undefined) { sets.push("name = $name"); params.$name = opts.name; }
535
+ if (opts.base_dir !== undefined) { sets.push("base_dir = $base_dir"); params.$base_dir = opts.base_dir; }
536
+ if (opts.test_path !== undefined) { sets.push("test_path = $test_path"); params.$test_path = opts.test_path; }
537
+ if (opts.openapi_spec !== undefined) { sets.push("openapi_spec = $openapi_spec"); params.$openapi_spec = opts.openapi_spec; }
538
+
539
+ if (sets.length === 0) return false;
540
+
541
+ const result = db.prepare(`UPDATE collections SET ${sets.join(", ")} WHERE id = $id`).run(params);
542
+ return result.changes > 0;
543
+ }
544
+
545
+ export function deleteCollection(id: number, deleteRuns = false): boolean {
546
+ const db = getDb();
547
+ if (deleteRuns) {
548
+ const runIds = db.query("SELECT id FROM runs WHERE collection_id = ?").all(id) as { id: number }[];
549
+ for (const row of runIds) {
550
+ db.prepare("DELETE FROM results WHERE run_id = ?").run(row.id);
551
+ }
552
+ db.prepare("DELETE FROM runs WHERE collection_id = ?").run(id);
553
+ } else {
554
+ db.prepare("UPDATE runs SET collection_id = NULL WHERE collection_id = ?").run(id);
555
+ }
556
+ const result = db.prepare("DELETE FROM collections WHERE id = ?").run(id);
557
+ return result.changes > 0;
558
+ }
559
+
560
+ export function findCollectionByTestPath(path: string): CollectionRecord | null {
561
+ const db = getDb();
562
+ const normalized = normalizePath(path);
563
+ return db.query("SELECT * FROM collections WHERE test_path = ?").get(normalized) as CollectionRecord | null;
564
+ }
565
+
566
+ export function findCollectionByNameOrId(nameOrId: string): CollectionRecord | null {
567
+ const db = getDb();
568
+ // Try as numeric ID first
569
+ const id = parseInt(nameOrId, 10);
570
+ if (!isNaN(id)) {
571
+ const byId = db.query("SELECT * FROM collections WHERE id = ?").get(id) as CollectionRecord | null;
572
+ if (byId) return byId;
573
+ }
574
+ // Then by name (case-insensitive)
575
+ return db.query("SELECT * FROM collections WHERE lower(name) = lower(?)").get(nameOrId) as CollectionRecord | null;
576
+ }
577
+
578
+ export function findCollectionBySpec(spec: string): CollectionRecord | null {
579
+ const db = getDb();
580
+ return db.query("SELECT * FROM collections WHERE openapi_spec = ?").get(spec) as CollectionRecord | null;
581
+ }
582
+
583
+ export function listRunsByCollection(collectionId: number, limit = 20, offset = 0): RunSummary[] {
584
+ const db = getDb();
585
+ return db.query(`
586
+ SELECT id, started_at, finished_at, total, passed, failed, skipped, environment, duration_ms, collection_id
587
+ FROM runs
588
+ WHERE collection_id = ?
589
+ ORDER BY started_at DESC
590
+ LIMIT ? OFFSET ?
591
+ `).all(collectionId, limit, offset) as RunSummary[];
592
+ }
593
+
594
+ export function getCollectionPassRateTrend(collectionId: number, limit = 30): PassRateTrendPoint[] {
595
+ const db = getDb();
596
+ return db.query(`
597
+ SELECT id AS run_id, started_at,
598
+ CASE WHEN total > 0 THEN ROUND(passed * 100.0 / total, 1) ELSE 0 END AS pass_rate
599
+ FROM runs
600
+ WHERE collection_id = ? AND finished_at IS NOT NULL
601
+ ORDER BY started_at DESC
602
+ LIMIT ?
603
+ `).all(collectionId, limit) as PassRateTrendPoint[];
604
+ }
605
+
606
+ export function countRunsByCollection(collectionId: number): number {
607
+ const db = getDb();
608
+ const row = db.query("SELECT COUNT(*) AS cnt FROM runs WHERE collection_id = ?").get(collectionId) as { cnt: number };
609
+ return row.cnt;
610
+ }
611
+
612
+ export function getCollectionStats(collectionId: number): DashboardStats {
613
+ const db = getDb();
614
+ const row = db.query(`
615
+ SELECT
616
+ COUNT(*) AS totalRuns,
617
+ COALESCE(SUM(total), 0) AS totalTests,
618
+ CASE WHEN SUM(total) > 0
619
+ THEN ROUND(SUM(passed) * 100.0 / SUM(total), 1)
620
+ ELSE 0 END AS overallPassRate,
621
+ COALESCE(ROUND(AVG(duration_ms), 0), 0) AS avgDuration
622
+ FROM runs
623
+ WHERE collection_id = ? AND finished_at IS NOT NULL
624
+ `).get(collectionId) as { totalRuns: number; totalTests: number; overallPassRate: number; avgDuration: number };
625
+ return row;
626
+ }
627
+
628
+ export function linkRunToCollection(runId: number, collectionId: number): void {
629
+ const db = getDb();
630
+ db.prepare("UPDATE runs SET collection_id = ? WHERE id = ?").run(collectionId, runId);
631
+ }
632
+
633
+ // ──────────────────────────────────────────────
634
+ // AI Generations
635
+ // ──────────────────────────────────────────────
636
+
637
+ export interface AIGenerationRecord {
638
+ id: number;
639
+ collection_id: number | null;
640
+ prompt: string;
641
+ model: string;
642
+ provider: string;
643
+ generated_yaml: string | null;
644
+ output_path: string | null;
645
+ status: string;
646
+ error_message: string | null;
647
+ prompt_tokens: number | null;
648
+ completion_tokens: number | null;
649
+ duration_ms: number | null;
650
+ created_at: string;
651
+ }
652
+
653
+ export interface SaveAIGenerationOpts {
654
+ collection_id?: number;
655
+ prompt: string;
656
+ model: string;
657
+ provider: string;
658
+ generated_yaml?: string;
659
+ output_path?: string;
660
+ status: string;
661
+ error_message?: string;
662
+ prompt_tokens?: number;
663
+ completion_tokens?: number;
664
+ duration_ms?: number;
665
+ }
666
+
667
+ export function saveAIGeneration(opts: SaveAIGenerationOpts): number {
668
+ const db = getDb();
669
+ const result = db.prepare(`
670
+ INSERT INTO ai_generations
671
+ (collection_id, prompt, model, provider, generated_yaml, output_path,
672
+ status, error_message, prompt_tokens, completion_tokens, duration_ms)
673
+ VALUES ($collection_id, $prompt, $model, $provider, $generated_yaml, $output_path,
674
+ $status, $error_message, $prompt_tokens, $completion_tokens, $duration_ms)
675
+ `).run({
676
+ $collection_id: opts.collection_id ?? null,
677
+ $prompt: opts.prompt,
678
+ $model: opts.model,
679
+ $provider: opts.provider,
680
+ $generated_yaml: opts.generated_yaml ?? null,
681
+ $output_path: opts.output_path ?? null,
682
+ $status: opts.status,
683
+ $error_message: opts.error_message ?? null,
684
+ $prompt_tokens: opts.prompt_tokens ?? null,
685
+ $completion_tokens: opts.completion_tokens ?? null,
686
+ $duration_ms: opts.duration_ms ?? null,
687
+ });
688
+ return Number(result.lastInsertRowid);
689
+ }
690
+
691
+ export function listAIGenerations(collectionId: number, limit = 10): AIGenerationRecord[] {
692
+ const db = getDb();
693
+ return db.query(`
694
+ SELECT * FROM ai_generations
695
+ WHERE collection_id = ?
696
+ ORDER BY created_at DESC
697
+ LIMIT ?
698
+ `).all(collectionId, limit) as AIGenerationRecord[];
699
+ }
700
+
701
+ export function getAIGeneration(id: number): AIGenerationRecord | null {
702
+ const db = getDb();
703
+ return db.query("SELECT * FROM ai_generations WHERE id = ?").get(id) as AIGenerationRecord | null;
704
+ }
705
+
706
+ export function updateAIGenerationOutputPath(id: number, outputPath: string): boolean {
707
+ const db = getDb();
708
+ const result = db.prepare("UPDATE ai_generations SET output_path = ? WHERE id = ?").run(outputPath, id);
709
+ return result.changes > 0;
710
+ }
711
+
712
+ export function listSavedAIGenerations(collectionId: number): AIGenerationRecord[] {
713
+ const db = getDb();
714
+ return db.query(`
715
+ SELECT * FROM ai_generations
716
+ WHERE collection_id = ? AND output_path IS NOT NULL AND output_path != ''
717
+ ORDER BY created_at DESC
718
+ `).all(collectionId) as AIGenerationRecord[];
719
+ }
720
+
721
+ export function findAIGenerationByYaml(collectionId: number, yaml: string): AIGenerationRecord | null {
722
+ const db = getDb();
723
+ return db.query(
724
+ "SELECT * FROM ai_generations WHERE collection_id = ? AND generated_yaml = ? ORDER BY created_at DESC LIMIT 1"
725
+ ).get(collectionId, yaml) as AIGenerationRecord | null;
726
+ }
727
+
728
+ // ──────────────────────────────────────────────
729
+ // Settings
730
+ // ──────────────────────────────────────────────
731
+
732
+ export function getSetting(key: string): string | null {
733
+ const db = getDb();
734
+ const row = db.query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null;
735
+ return row?.value ?? null;
736
+ }
737
+
738
+ export function setSetting(key: string, value: string): void {
739
+ const db = getDb();
740
+ db.prepare(`
741
+ INSERT INTO settings (key, value) VALUES ($key, $value)
742
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
743
+ `).run({ $key: key, $value: value });
744
+ }
745
+
746
+ export interface AISettingsConfig {
747
+ provider: string;
748
+ model: string;
749
+ base_url: string;
750
+ api_key: string;
751
+ }
752
+
753
+ export function getAISettings(): AISettingsConfig {
754
+ return {
755
+ provider: getSetting("ai_provider") ?? "ollama",
756
+ model: getSetting("ai_model") ?? "",
757
+ base_url: getSetting("ai_base_url") ?? "",
758
+ api_key: getSetting("ai_api_key") ?? "",
759
+ };
760
+ }
761
+
762
+ export function setAISettings(config: Partial<AISettingsConfig>): void {
763
+ if (config.provider !== undefined) setSetting("ai_provider", config.provider);
764
+ if (config.model !== undefined) setSetting("ai_model", config.model);
765
+ if (config.base_url !== undefined) setSetting("ai_base_url", config.base_url);
766
+ if (config.api_key !== undefined) setSetting("ai_api_key", config.api_key);
767
+ }
768
+
769
+ // ──────────────────────────────────────────────
770
+ // Chat Sessions & Messages
771
+ // ──────────────────────────────────────────────
772
+
773
+ export interface ChatSessionRecord {
774
+ id: number;
775
+ title: string | null;
776
+ provider: string;
777
+ model: string;
778
+ created_at: string;
779
+ last_active: string;
780
+ }
781
+
782
+ export interface ChatMessageRecord {
783
+ id: number;
784
+ session_id: number;
785
+ role: string;
786
+ content: string;
787
+ tool_name: string | null;
788
+ tool_args: string | null;
789
+ tool_result: string | null;
790
+ input_tokens: number | null;
791
+ output_tokens: number | null;
792
+ created_at: string;
793
+ }
794
+
795
+ export interface SaveChatMessageOpts {
796
+ session_id: number;
797
+ role: string;
798
+ content: string;
799
+ tool_name?: string;
800
+ tool_args?: string;
801
+ tool_result?: string;
802
+ input_tokens?: number;
803
+ output_tokens?: number;
804
+ }
805
+
806
+ export function createChatSession(provider: string, model: string, title?: string): number {
807
+ const db = getDb();
808
+ const result = db.prepare(`
809
+ INSERT INTO chat_sessions (title, provider, model)
810
+ VALUES ($title, $provider, $model)
811
+ `).run({
812
+ $title: title ?? null,
813
+ $provider: provider,
814
+ $model: model,
815
+ });
816
+ return Number(result.lastInsertRowid);
817
+ }
818
+
819
+ export function saveChatMessage(opts: SaveChatMessageOpts): number {
820
+ const db = getDb();
821
+
822
+ // Update session last_active
823
+ db.prepare("UPDATE chat_sessions SET last_active = datetime('now') WHERE id = ?").run(opts.session_id);
824
+
825
+ const result = db.prepare(`
826
+ INSERT INTO chat_messages (session_id, role, content, tool_name, tool_args, tool_result, input_tokens, output_tokens)
827
+ VALUES ($session_id, $role, $content, $tool_name, $tool_args, $tool_result, $input_tokens, $output_tokens)
828
+ `).run({
829
+ $session_id: opts.session_id,
830
+ $role: opts.role,
831
+ $content: opts.content,
832
+ $tool_name: opts.tool_name ?? null,
833
+ $tool_args: opts.tool_args ?? null,
834
+ $tool_result: opts.tool_result ?? null,
835
+ $input_tokens: opts.input_tokens ?? null,
836
+ $output_tokens: opts.output_tokens ?? null,
837
+ });
838
+ return Number(result.lastInsertRowid);
839
+ }
840
+
841
+ export function getChatMessages(sessionId: number): ChatMessageRecord[] {
842
+ const db = getDb();
843
+ return db.query(
844
+ "SELECT * FROM chat_messages WHERE session_id = ? ORDER BY created_at ASC"
845
+ ).all(sessionId) as ChatMessageRecord[];
846
+ }
847
+
848
+ export function listChatSessions(limit = 20): ChatSessionRecord[] {
849
+ const db = getDb();
850
+ return db.query(
851
+ "SELECT * FROM chat_sessions ORDER BY last_active DESC LIMIT ?"
852
+ ).all(limit) as ChatSessionRecord[];
853
+ }
854
+
855
+ export interface CoreMessageFormat {
856
+ role: "user" | "assistant";
857
+ content: string;
858
+ }
859
+
860
+ export function loadSessionHistory(sessionId: number): CoreMessageFormat[] {
861
+ const messages = getChatMessages(sessionId);
862
+ return messages
863
+ .filter((m) => m.role === "user" || m.role === "assistant")
864
+ .map((m) => ({
865
+ role: m.role as "user" | "assistant",
866
+ content: m.content,
867
+ }));
868
+ }