@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.
- package/CHANGELOG.md +130 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/package.json +53 -0
- package/src/bun-types.d.ts +5 -0
- package/src/cli/commands/add-api.ts +51 -0
- package/src/cli/commands/ai-generate.ts +106 -0
- package/src/cli/commands/chat.ts +43 -0
- package/src/cli/commands/ci-init.ts +163 -0
- package/src/cli/commands/collections.ts +41 -0
- package/src/cli/commands/compare.ts +129 -0
- package/src/cli/commands/coverage.ts +156 -0
- package/src/cli/commands/doctor.ts +127 -0
- package/src/cli/commands/init.ts +84 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/run.ts +156 -0
- package/src/cli/commands/runs.ts +108 -0
- package/src/cli/commands/serve.ts +22 -0
- package/src/cli/commands/update.ts +142 -0
- package/src/cli/commands/validate.ts +18 -0
- package/src/cli/index.ts +529 -0
- package/src/cli/output.ts +24 -0
- package/src/cli/runtime.ts +7 -0
- package/src/core/agent/agent-loop.ts +116 -0
- package/src/core/agent/context-manager.ts +41 -0
- package/src/core/agent/system-prompt.ts +28 -0
- package/src/core/agent/tools/diagnose-failure.ts +51 -0
- package/src/core/agent/tools/explore-api.ts +40 -0
- package/src/core/agent/tools/index.ts +46 -0
- package/src/core/agent/tools/query-results.ts +40 -0
- package/src/core/agent/tools/run-tests.ts +38 -0
- package/src/core/agent/tools/send-request.ts +44 -0
- package/src/core/agent/tools/validate-tests.ts +23 -0
- package/src/core/agent/types.ts +22 -0
- package/src/core/diagnostics/failure-hints.ts +63 -0
- package/src/core/generator/ai/ai-generator.ts +61 -0
- package/src/core/generator/ai/llm-client.ts +159 -0
- package/src/core/generator/ai/output-parser.ts +307 -0
- package/src/core/generator/ai/prompt-builder.ts +153 -0
- package/src/core/generator/ai/types.ts +56 -0
- package/src/core/generator/chunker.ts +47 -0
- package/src/core/generator/coverage-scanner.ts +87 -0
- package/src/core/generator/data-factory.ts +115 -0
- package/src/core/generator/endpoint-warnings.ts +43 -0
- package/src/core/generator/index.ts +12 -0
- package/src/core/generator/openapi-reader.ts +143 -0
- package/src/core/generator/schema-utils.ts +52 -0
- package/src/core/generator/serializer.ts +189 -0
- package/src/core/generator/types.ts +48 -0
- package/src/core/parser/filter.ts +14 -0
- package/src/core/parser/index.ts +21 -0
- package/src/core/parser/schema.ts +175 -0
- package/src/core/parser/types.ts +52 -0
- package/src/core/parser/variables.ts +154 -0
- package/src/core/parser/yaml-parser.ts +85 -0
- package/src/core/reporter/console.ts +175 -0
- package/src/core/reporter/index.ts +23 -0
- package/src/core/reporter/json.ts +9 -0
- package/src/core/reporter/junit.ts +78 -0
- package/src/core/reporter/types.ts +12 -0
- package/src/core/runner/assertions.ts +173 -0
- package/src/core/runner/execute-run.ts +97 -0
- package/src/core/runner/executor.ts +183 -0
- package/src/core/runner/http-client.ts +69 -0
- package/src/core/runner/index.ts +12 -0
- package/src/core/runner/types.ts +48 -0
- package/src/core/setup-api.ts +113 -0
- package/src/core/utils.ts +9 -0
- package/src/db/queries.ts +774 -0
- package/src/db/schema.ts +159 -0
- package/src/mcp/descriptions.ts +88 -0
- package/src/mcp/server.ts +52 -0
- package/src/mcp/tools/ci-init.ts +54 -0
- package/src/mcp/tools/coverage-analysis.ts +141 -0
- package/src/mcp/tools/describe-endpoint.ts +241 -0
- package/src/mcp/tools/explore-api.ts +84 -0
- package/src/mcp/tools/generate-and-save.ts +129 -0
- package/src/mcp/tools/generate-missing-tests.ts +91 -0
- package/src/mcp/tools/generate-tests-guide.ts +391 -0
- package/src/mcp/tools/manage-server.ts +86 -0
- package/src/mcp/tools/query-db.ts +255 -0
- package/src/mcp/tools/run-tests.ts +71 -0
- package/src/mcp/tools/save-test-suite.ts +218 -0
- package/src/mcp/tools/send-request.ts +63 -0
- package/src/mcp/tools/set-work-dir.ts +35 -0
- package/src/mcp/tools/setup-api.ts +84 -0
- package/src/mcp/tools/validate-tests.ts +43 -0
- package/src/tui/chat-ui.ts +150 -0
- package/src/web/data/collection-state.ts +360 -0
- package/src/web/routes/api.ts +234 -0
- package/src/web/routes/dashboard.ts +313 -0
- package/src/web/routes/runs.ts +64 -0
- package/src/web/schemas.ts +121 -0
- package/src/web/server.ts +134 -0
- package/src/web/static/htmx.min.js +1 -0
- package/src/web/static/style.css +827 -0
- package/src/web/views/endpoints-tab.ts +170 -0
- package/src/web/views/health-strip.ts +92 -0
- package/src/web/views/layout.ts +48 -0
- package/src/web/views/results.ts +209 -0
- package/src/web/views/runs-tab.ts +126 -0
- 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
|
+
}
|