@kirrosh/zond 0.7.0

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 (102) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/package.json +53 -0
  5. package/src/bun-types.d.ts +5 -0
  6. package/src/cli/commands/add-api.ts +51 -0
  7. package/src/cli/commands/ai-generate.ts +106 -0
  8. package/src/cli/commands/chat.ts +43 -0
  9. package/src/cli/commands/ci-init.ts +163 -0
  10. package/src/cli/commands/collections.ts +41 -0
  11. package/src/cli/commands/compare.ts +129 -0
  12. package/src/cli/commands/coverage.ts +156 -0
  13. package/src/cli/commands/doctor.ts +127 -0
  14. package/src/cli/commands/init.ts +84 -0
  15. package/src/cli/commands/mcp.ts +16 -0
  16. package/src/cli/commands/run.ts +156 -0
  17. package/src/cli/commands/runs.ts +108 -0
  18. package/src/cli/commands/serve.ts +22 -0
  19. package/src/cli/commands/update.ts +142 -0
  20. package/src/cli/commands/validate.ts +18 -0
  21. package/src/cli/index.ts +529 -0
  22. package/src/cli/output.ts +24 -0
  23. package/src/cli/runtime.ts +7 -0
  24. package/src/core/agent/agent-loop.ts +116 -0
  25. package/src/core/agent/context-manager.ts +41 -0
  26. package/src/core/agent/system-prompt.ts +28 -0
  27. package/src/core/agent/tools/diagnose-failure.ts +51 -0
  28. package/src/core/agent/tools/explore-api.ts +40 -0
  29. package/src/core/agent/tools/index.ts +46 -0
  30. package/src/core/agent/tools/query-results.ts +40 -0
  31. package/src/core/agent/tools/run-tests.ts +38 -0
  32. package/src/core/agent/tools/send-request.ts +44 -0
  33. package/src/core/agent/tools/validate-tests.ts +23 -0
  34. package/src/core/agent/types.ts +22 -0
  35. package/src/core/diagnostics/failure-hints.ts +63 -0
  36. package/src/core/generator/ai/ai-generator.ts +61 -0
  37. package/src/core/generator/ai/llm-client.ts +159 -0
  38. package/src/core/generator/ai/output-parser.ts +307 -0
  39. package/src/core/generator/ai/prompt-builder.ts +153 -0
  40. package/src/core/generator/ai/types.ts +56 -0
  41. package/src/core/generator/chunker.ts +47 -0
  42. package/src/core/generator/coverage-scanner.ts +87 -0
  43. package/src/core/generator/data-factory.ts +115 -0
  44. package/src/core/generator/endpoint-warnings.ts +43 -0
  45. package/src/core/generator/index.ts +12 -0
  46. package/src/core/generator/openapi-reader.ts +143 -0
  47. package/src/core/generator/schema-utils.ts +52 -0
  48. package/src/core/generator/serializer.ts +189 -0
  49. package/src/core/generator/types.ts +48 -0
  50. package/src/core/parser/filter.ts +14 -0
  51. package/src/core/parser/index.ts +21 -0
  52. package/src/core/parser/schema.ts +175 -0
  53. package/src/core/parser/types.ts +52 -0
  54. package/src/core/parser/variables.ts +154 -0
  55. package/src/core/parser/yaml-parser.ts +85 -0
  56. package/src/core/reporter/console.ts +175 -0
  57. package/src/core/reporter/index.ts +23 -0
  58. package/src/core/reporter/json.ts +9 -0
  59. package/src/core/reporter/junit.ts +78 -0
  60. package/src/core/reporter/types.ts +12 -0
  61. package/src/core/runner/assertions.ts +173 -0
  62. package/src/core/runner/execute-run.ts +97 -0
  63. package/src/core/runner/executor.ts +183 -0
  64. package/src/core/runner/http-client.ts +69 -0
  65. package/src/core/runner/index.ts +12 -0
  66. package/src/core/runner/types.ts +48 -0
  67. package/src/core/setup-api.ts +113 -0
  68. package/src/core/utils.ts +9 -0
  69. package/src/db/queries.ts +774 -0
  70. package/src/db/schema.ts +159 -0
  71. package/src/mcp/descriptions.ts +88 -0
  72. package/src/mcp/server.ts +52 -0
  73. package/src/mcp/tools/ci-init.ts +54 -0
  74. package/src/mcp/tools/coverage-analysis.ts +141 -0
  75. package/src/mcp/tools/describe-endpoint.ts +241 -0
  76. package/src/mcp/tools/explore-api.ts +84 -0
  77. package/src/mcp/tools/generate-and-save.ts +129 -0
  78. package/src/mcp/tools/generate-missing-tests.ts +91 -0
  79. package/src/mcp/tools/generate-tests-guide.ts +391 -0
  80. package/src/mcp/tools/manage-server.ts +86 -0
  81. package/src/mcp/tools/query-db.ts +255 -0
  82. package/src/mcp/tools/run-tests.ts +71 -0
  83. package/src/mcp/tools/save-test-suite.ts +218 -0
  84. package/src/mcp/tools/send-request.ts +63 -0
  85. package/src/mcp/tools/set-work-dir.ts +35 -0
  86. package/src/mcp/tools/setup-api.ts +84 -0
  87. package/src/mcp/tools/validate-tests.ts +43 -0
  88. package/src/tui/chat-ui.ts +150 -0
  89. package/src/web/data/collection-state.ts +360 -0
  90. package/src/web/routes/api.ts +234 -0
  91. package/src/web/routes/dashboard.ts +313 -0
  92. package/src/web/routes/runs.ts +64 -0
  93. package/src/web/schemas.ts +121 -0
  94. package/src/web/server.ts +134 -0
  95. package/src/web/static/htmx.min.js +1 -0
  96. package/src/web/static/style.css +827 -0
  97. package/src/web/views/endpoints-tab.ts +170 -0
  98. package/src/web/views/health-strip.ts +92 -0
  99. package/src/web/views/layout.ts +48 -0
  100. package/src/web/views/results.ts +209 -0
  101. package/src/web/views/runs-tab.ts +126 -0
  102. package/src/web/views/suites-tab.ts +153 -0
@@ -0,0 +1,774 @@
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
+ // Dashboard metrics
295
+ // ──────────────────────────────────────────────
296
+
297
+ export interface DashboardStats {
298
+ totalRuns: number;
299
+ totalTests: number;
300
+ overallPassRate: number;
301
+ avgDuration: number;
302
+ }
303
+
304
+ export function getDashboardStats(): DashboardStats {
305
+ const db = getDb();
306
+ const row = db.query(`
307
+ SELECT
308
+ COUNT(*) AS totalRuns,
309
+ COALESCE(SUM(total), 0) AS totalTests,
310
+ CASE WHEN SUM(total) > 0
311
+ THEN ROUND(SUM(passed) * 100.0 / SUM(total), 1)
312
+ ELSE 0 END AS overallPassRate,
313
+ COALESCE(ROUND(AVG(duration_ms), 0), 0) AS avgDuration
314
+ FROM runs
315
+ WHERE finished_at IS NOT NULL
316
+ `).get() as { totalRuns: number; totalTests: number; overallPassRate: number; avgDuration: number };
317
+ return row;
318
+ }
319
+
320
+ export interface PassRateTrendPoint {
321
+ run_id: number;
322
+ started_at: string;
323
+ pass_rate: number;
324
+ }
325
+
326
+ export function getPassRateTrend(limit = 30): PassRateTrendPoint[] {
327
+ const db = getDb();
328
+ return db.query(`
329
+ SELECT id AS run_id, started_at,
330
+ CASE WHEN total > 0 THEN ROUND(passed * 100.0 / total, 1) ELSE 0 END AS pass_rate
331
+ FROM runs
332
+ WHERE finished_at IS NOT NULL
333
+ ORDER BY started_at DESC
334
+ LIMIT ?
335
+ `).all(limit) as PassRateTrendPoint[];
336
+ }
337
+
338
+ export interface SlowestTest {
339
+ suite_name: string;
340
+ test_name: string;
341
+ avg_duration: number;
342
+ }
343
+
344
+ export function getSlowestTests(limit = 5): SlowestTest[] {
345
+ const db = getDb();
346
+ return db.query(`
347
+ SELECT suite_name, test_name, ROUND(AVG(duration_ms), 0) AS avg_duration
348
+ FROM results
349
+ GROUP BY suite_name, test_name
350
+ ORDER BY avg_duration DESC
351
+ LIMIT ?
352
+ `).all(limit) as SlowestTest[];
353
+ }
354
+
355
+ export interface FlakyTest {
356
+ suite_name: string;
357
+ test_name: string;
358
+ distinct_statuses: number;
359
+ }
360
+
361
+ export function getFlakyTests(runsBack = 20, limit = 5): FlakyTest[] {
362
+ const db = getDb();
363
+ return db.query(`
364
+ SELECT r.suite_name, r.test_name, COUNT(DISTINCT r.status) AS distinct_statuses
365
+ FROM results r
366
+ INNER JOIN (SELECT id FROM runs ORDER BY started_at DESC LIMIT ?) recent ON r.run_id = recent.id
367
+ GROUP BY r.suite_name, r.test_name
368
+ HAVING COUNT(DISTINCT r.status) > 1
369
+ ORDER BY distinct_statuses DESC
370
+ LIMIT ?
371
+ `).all(runsBack, limit) as FlakyTest[];
372
+ }
373
+
374
+ export function countRuns(filters?: RunFilters): number {
375
+ const db = getDb();
376
+ if (filters && Object.values(filters).some(Boolean)) {
377
+ const { where, params } = buildRunFilterSQL(filters);
378
+ const row = db.query(`SELECT COUNT(*) AS cnt FROM runs r ${where}`).get(...(params as (string | number)[])) as { cnt: number };
379
+ return row.cnt;
380
+ }
381
+ const row = db.query("SELECT COUNT(*) AS cnt FROM runs").get() as { cnt: number };
382
+ return row.cnt;
383
+ }
384
+
385
+ export function getDistinctEnvironments(): string[] {
386
+ const db = getDb();
387
+ const rows = db.query("SELECT DISTINCT environment FROM runs WHERE environment IS NOT NULL ORDER BY environment").all() as { environment: string }[];
388
+ return rows.map((r) => r.environment);
389
+ }
390
+
391
+ // ──────────────────────────────────────────────
392
+ // Collections
393
+ // ──────────────────────────────────────────────
394
+
395
+ export function createCollection(opts: CreateCollectionOpts): number {
396
+ const db = getDb();
397
+ const stmt = db.prepare(`
398
+ INSERT INTO collections (name, base_dir, test_path, openapi_spec)
399
+ VALUES ($name, $base_dir, $test_path, $openapi_spec)
400
+ `);
401
+ const result = stmt.run({
402
+ $name: opts.name,
403
+ $base_dir: opts.base_dir ?? null,
404
+ $test_path: opts.test_path,
405
+ $openapi_spec: opts.openapi_spec ?? null,
406
+ });
407
+ return Number(result.lastInsertRowid);
408
+ }
409
+
410
+ export function getCollectionById(id: number): CollectionRecord | null {
411
+ const db = getDb();
412
+ return db.query("SELECT * FROM collections WHERE id = ?").get(id) as CollectionRecord | null;
413
+ }
414
+
415
+ export function listCollections(): CollectionSummary[] {
416
+ const db = getDb();
417
+ return db.query(`
418
+ SELECT
419
+ c.id, c.name, c.base_dir, c.test_path, c.openapi_spec, c.created_at,
420
+ COUNT(r.id) AS total_runs,
421
+ CASE WHEN SUM(r.total) > 0
422
+ THEN ROUND(SUM(r.passed) * 100.0 / SUM(r.total), 1)
423
+ ELSE 0 END AS pass_rate,
424
+ MAX(r.started_at) AS last_run_at,
425
+ COALESCE((SELECT passed FROM runs WHERE collection_id = c.id ORDER BY started_at DESC LIMIT 1), 0) AS last_run_passed,
426
+ COALESCE((SELECT failed FROM runs WHERE collection_id = c.id ORDER BY started_at DESC LIMIT 1), 0) AS last_run_failed,
427
+ COALESCE((SELECT total FROM runs WHERE collection_id = c.id ORDER BY started_at DESC LIMIT 1), 0) AS last_run_total
428
+ FROM collections c
429
+ LEFT JOIN runs r ON r.collection_id = c.id AND r.finished_at IS NOT NULL
430
+ GROUP BY c.id
431
+ ORDER BY c.name
432
+ `).all() as CollectionSummary[];
433
+ }
434
+
435
+ export function updateCollection(id: number, opts: Partial<CreateCollectionOpts>): boolean {
436
+ const db = getDb();
437
+ const sets: string[] = [];
438
+ const params: Record<string, any> = { $id: id };
439
+
440
+ if (opts.name !== undefined) { sets.push("name = $name"); params.$name = opts.name; }
441
+ if (opts.base_dir !== undefined) { sets.push("base_dir = $base_dir"); params.$base_dir = opts.base_dir; }
442
+ if (opts.test_path !== undefined) { sets.push("test_path = $test_path"); params.$test_path = opts.test_path; }
443
+ if (opts.openapi_spec !== undefined) { sets.push("openapi_spec = $openapi_spec"); params.$openapi_spec = opts.openapi_spec; }
444
+
445
+ if (sets.length === 0) return false;
446
+
447
+ const result = db.prepare(`UPDATE collections SET ${sets.join(", ")} WHERE id = $id`).run(params);
448
+ return result.changes > 0;
449
+ }
450
+
451
+ export function deleteCollection(id: number, deleteRuns = false): boolean {
452
+ const db = getDb();
453
+ if (deleteRuns) {
454
+ const runIds = db.query("SELECT id FROM runs WHERE collection_id = ?").all(id) as { id: number }[];
455
+ for (const row of runIds) {
456
+ db.prepare("DELETE FROM results WHERE run_id = ?").run(row.id);
457
+ }
458
+ db.prepare("DELETE FROM runs WHERE collection_id = ?").run(id);
459
+ } else {
460
+ db.prepare("UPDATE runs SET collection_id = NULL WHERE collection_id = ?").run(id);
461
+ }
462
+ const result = db.prepare("DELETE FROM collections WHERE id = ?").run(id);
463
+ return result.changes > 0;
464
+ }
465
+
466
+ export function findCollectionByTestPath(path: string): CollectionRecord | null {
467
+ const db = getDb();
468
+ const normalized = normalizePath(path);
469
+ return db.query("SELECT * FROM collections WHERE test_path = ?").get(normalized) as CollectionRecord | null;
470
+ }
471
+
472
+ export function findCollectionByNameOrId(nameOrId: string): CollectionRecord | null {
473
+ const db = getDb();
474
+ // Try as numeric ID first
475
+ const id = parseInt(nameOrId, 10);
476
+ if (!isNaN(id)) {
477
+ const byId = db.query("SELECT * FROM collections WHERE id = ?").get(id) as CollectionRecord | null;
478
+ if (byId) return byId;
479
+ }
480
+ // Then by name (case-insensitive)
481
+ return db.query("SELECT * FROM collections WHERE lower(name) = lower(?)").get(nameOrId) as CollectionRecord | null;
482
+ }
483
+
484
+ export function findCollectionBySpec(spec: string): CollectionRecord | null {
485
+ const db = getDb();
486
+ return db.query("SELECT * FROM collections WHERE openapi_spec = ?").get(spec) as CollectionRecord | null;
487
+ }
488
+
489
+ export function listRunsByCollection(collectionId: number, limit = 20, offset = 0): RunSummary[] {
490
+ const db = getDb();
491
+ return db.query(`
492
+ SELECT id, started_at, finished_at, total, passed, failed, skipped, environment, duration_ms, collection_id
493
+ FROM runs
494
+ WHERE collection_id = ?
495
+ ORDER BY started_at DESC
496
+ LIMIT ? OFFSET ?
497
+ `).all(collectionId, limit, offset) as RunSummary[];
498
+ }
499
+
500
+ export function getCollectionPassRateTrend(collectionId: number, limit = 30): PassRateTrendPoint[] {
501
+ const db = getDb();
502
+ return db.query(`
503
+ SELECT id AS run_id, started_at,
504
+ CASE WHEN total > 0 THEN ROUND(passed * 100.0 / total, 1) ELSE 0 END AS pass_rate
505
+ FROM runs
506
+ WHERE collection_id = ? AND finished_at IS NOT NULL
507
+ ORDER BY started_at DESC
508
+ LIMIT ?
509
+ `).all(collectionId, limit) as PassRateTrendPoint[];
510
+ }
511
+
512
+ export function countRunsByCollection(collectionId: number): number {
513
+ const db = getDb();
514
+ const row = db.query("SELECT COUNT(*) AS cnt FROM runs WHERE collection_id = ?").get(collectionId) as { cnt: number };
515
+ return row.cnt;
516
+ }
517
+
518
+ export function getCollectionStats(collectionId: number): DashboardStats {
519
+ const db = getDb();
520
+ const row = db.query(`
521
+ SELECT
522
+ COUNT(*) AS totalRuns,
523
+ COALESCE(SUM(total), 0) AS totalTests,
524
+ CASE WHEN SUM(total) > 0
525
+ THEN ROUND(SUM(passed) * 100.0 / SUM(total), 1)
526
+ ELSE 0 END AS overallPassRate,
527
+ COALESCE(ROUND(AVG(duration_ms), 0), 0) AS avgDuration
528
+ FROM runs
529
+ WHERE collection_id = ? AND finished_at IS NOT NULL
530
+ `).get(collectionId) as { totalRuns: number; totalTests: number; overallPassRate: number; avgDuration: number };
531
+ return row;
532
+ }
533
+
534
+ export function linkRunToCollection(runId: number, collectionId: number): void {
535
+ const db = getDb();
536
+ db.prepare("UPDATE runs SET collection_id = ? WHERE id = ?").run(collectionId, runId);
537
+ }
538
+
539
+ // ──────────────────────────────────────────────
540
+ // AI Generations
541
+ // ──────────────────────────────────────────────
542
+
543
+ export interface AIGenerationRecord {
544
+ id: number;
545
+ collection_id: number | null;
546
+ prompt: string;
547
+ model: string;
548
+ provider: string;
549
+ generated_yaml: string | null;
550
+ output_path: string | null;
551
+ status: string;
552
+ error_message: string | null;
553
+ prompt_tokens: number | null;
554
+ completion_tokens: number | null;
555
+ duration_ms: number | null;
556
+ created_at: string;
557
+ }
558
+
559
+ export interface SaveAIGenerationOpts {
560
+ collection_id?: number;
561
+ prompt: string;
562
+ model: string;
563
+ provider: string;
564
+ generated_yaml?: string;
565
+ output_path?: string;
566
+ status: string;
567
+ error_message?: string;
568
+ prompt_tokens?: number;
569
+ completion_tokens?: number;
570
+ duration_ms?: number;
571
+ }
572
+
573
+ export function saveAIGeneration(opts: SaveAIGenerationOpts): number {
574
+ const db = getDb();
575
+ const result = db.prepare(`
576
+ INSERT INTO ai_generations
577
+ (collection_id, prompt, model, provider, generated_yaml, output_path,
578
+ status, error_message, prompt_tokens, completion_tokens, duration_ms)
579
+ VALUES ($collection_id, $prompt, $model, $provider, $generated_yaml, $output_path,
580
+ $status, $error_message, $prompt_tokens, $completion_tokens, $duration_ms)
581
+ `).run({
582
+ $collection_id: opts.collection_id ?? null,
583
+ $prompt: opts.prompt,
584
+ $model: opts.model,
585
+ $provider: opts.provider,
586
+ $generated_yaml: opts.generated_yaml ?? null,
587
+ $output_path: opts.output_path ?? null,
588
+ $status: opts.status,
589
+ $error_message: opts.error_message ?? null,
590
+ $prompt_tokens: opts.prompt_tokens ?? null,
591
+ $completion_tokens: opts.completion_tokens ?? null,
592
+ $duration_ms: opts.duration_ms ?? null,
593
+ });
594
+ return Number(result.lastInsertRowid);
595
+ }
596
+
597
+ export function listAIGenerations(collectionId: number, limit = 10): AIGenerationRecord[] {
598
+ const db = getDb();
599
+ return db.query(`
600
+ SELECT * FROM ai_generations
601
+ WHERE collection_id = ?
602
+ ORDER BY created_at DESC
603
+ LIMIT ?
604
+ `).all(collectionId, limit) as AIGenerationRecord[];
605
+ }
606
+
607
+ export function getAIGeneration(id: number): AIGenerationRecord | null {
608
+ const db = getDb();
609
+ return db.query("SELECT * FROM ai_generations WHERE id = ?").get(id) as AIGenerationRecord | null;
610
+ }
611
+
612
+ export function updateAIGenerationOutputPath(id: number, outputPath: string): boolean {
613
+ const db = getDb();
614
+ const result = db.prepare("UPDATE ai_generations SET output_path = ? WHERE id = ?").run(outputPath, id);
615
+ return result.changes > 0;
616
+ }
617
+
618
+ export function listSavedAIGenerations(collectionId: number): AIGenerationRecord[] {
619
+ const db = getDb();
620
+ return db.query(`
621
+ SELECT * FROM ai_generations
622
+ WHERE collection_id = ? AND output_path IS NOT NULL AND output_path != ''
623
+ ORDER BY created_at DESC
624
+ `).all(collectionId) as AIGenerationRecord[];
625
+ }
626
+
627
+ export function findAIGenerationByYaml(collectionId: number, yaml: string): AIGenerationRecord | null {
628
+ const db = getDb();
629
+ return db.query(
630
+ "SELECT * FROM ai_generations WHERE collection_id = ? AND generated_yaml = ? ORDER BY created_at DESC LIMIT 1"
631
+ ).get(collectionId, yaml) as AIGenerationRecord | null;
632
+ }
633
+
634
+ // ──────────────────────────────────────────────
635
+ // Settings
636
+ // ──────────────────────────────────────────────
637
+
638
+ export function getSetting(key: string): string | null {
639
+ const db = getDb();
640
+ const row = db.query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null;
641
+ return row?.value ?? null;
642
+ }
643
+
644
+ export function setSetting(key: string, value: string): void {
645
+ const db = getDb();
646
+ db.prepare(`
647
+ INSERT INTO settings (key, value) VALUES ($key, $value)
648
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
649
+ `).run({ $key: key, $value: value });
650
+ }
651
+
652
+ export interface AISettingsConfig {
653
+ provider: string;
654
+ model: string;
655
+ base_url: string;
656
+ api_key: string;
657
+ }
658
+
659
+ export function getAISettings(): AISettingsConfig {
660
+ return {
661
+ provider: getSetting("ai_provider") ?? "ollama",
662
+ model: getSetting("ai_model") ?? "",
663
+ base_url: getSetting("ai_base_url") ?? "",
664
+ api_key: getSetting("ai_api_key") ?? "",
665
+ };
666
+ }
667
+
668
+ export function setAISettings(config: Partial<AISettingsConfig>): void {
669
+ if (config.provider !== undefined) setSetting("ai_provider", config.provider);
670
+ if (config.model !== undefined) setSetting("ai_model", config.model);
671
+ if (config.base_url !== undefined) setSetting("ai_base_url", config.base_url);
672
+ if (config.api_key !== undefined) setSetting("ai_api_key", config.api_key);
673
+ }
674
+
675
+ // ──────────────────────────────────────────────
676
+ // Chat Sessions & Messages
677
+ // ──────────────────────────────────────────────
678
+
679
+ export interface ChatSessionRecord {
680
+ id: number;
681
+ title: string | null;
682
+ provider: string;
683
+ model: string;
684
+ created_at: string;
685
+ last_active: string;
686
+ }
687
+
688
+ export interface ChatMessageRecord {
689
+ id: number;
690
+ session_id: number;
691
+ role: string;
692
+ content: string;
693
+ tool_name: string | null;
694
+ tool_args: string | null;
695
+ tool_result: string | null;
696
+ input_tokens: number | null;
697
+ output_tokens: number | null;
698
+ created_at: string;
699
+ }
700
+
701
+ export interface SaveChatMessageOpts {
702
+ session_id: number;
703
+ role: string;
704
+ content: string;
705
+ tool_name?: string;
706
+ tool_args?: string;
707
+ tool_result?: string;
708
+ input_tokens?: number;
709
+ output_tokens?: number;
710
+ }
711
+
712
+ export function createChatSession(provider: string, model: string, title?: string): number {
713
+ const db = getDb();
714
+ const result = db.prepare(`
715
+ INSERT INTO chat_sessions (title, provider, model)
716
+ VALUES ($title, $provider, $model)
717
+ `).run({
718
+ $title: title ?? null,
719
+ $provider: provider,
720
+ $model: model,
721
+ });
722
+ return Number(result.lastInsertRowid);
723
+ }
724
+
725
+ export function saveChatMessage(opts: SaveChatMessageOpts): number {
726
+ const db = getDb();
727
+
728
+ // Update session last_active
729
+ db.prepare("UPDATE chat_sessions SET last_active = datetime('now') WHERE id = ?").run(opts.session_id);
730
+
731
+ const result = db.prepare(`
732
+ INSERT INTO chat_messages (session_id, role, content, tool_name, tool_args, tool_result, input_tokens, output_tokens)
733
+ VALUES ($session_id, $role, $content, $tool_name, $tool_args, $tool_result, $input_tokens, $output_tokens)
734
+ `).run({
735
+ $session_id: opts.session_id,
736
+ $role: opts.role,
737
+ $content: opts.content,
738
+ $tool_name: opts.tool_name ?? null,
739
+ $tool_args: opts.tool_args ?? null,
740
+ $tool_result: opts.tool_result ?? null,
741
+ $input_tokens: opts.input_tokens ?? null,
742
+ $output_tokens: opts.output_tokens ?? null,
743
+ });
744
+ return Number(result.lastInsertRowid);
745
+ }
746
+
747
+ export function getChatMessages(sessionId: number): ChatMessageRecord[] {
748
+ const db = getDb();
749
+ return db.query(
750
+ "SELECT * FROM chat_messages WHERE session_id = ? ORDER BY created_at ASC"
751
+ ).all(sessionId) as ChatMessageRecord[];
752
+ }
753
+
754
+ export function listChatSessions(limit = 20): ChatSessionRecord[] {
755
+ const db = getDb();
756
+ return db.query(
757
+ "SELECT * FROM chat_sessions ORDER BY last_active DESC LIMIT ?"
758
+ ).all(limit) as ChatSessionRecord[];
759
+ }
760
+
761
+ export interface CoreMessageFormat {
762
+ role: "user" | "assistant";
763
+ content: string;
764
+ }
765
+
766
+ export function loadSessionHistory(sessionId: number): CoreMessageFormat[] {
767
+ const messages = getChatMessages(sessionId);
768
+ return messages
769
+ .filter((m) => m.role === "user" || m.role === "assistant")
770
+ .map((m) => ({
771
+ role: m.role as "user" | "assistant",
772
+ content: m.content,
773
+ }));
774
+ }