@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,234 @@
|
|
|
1
|
+
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
|
2
|
+
import { getRunById, getResultsByRunId } from "../../db/queries.ts";
|
|
3
|
+
import { generateJunitXml } from "../../core/reporter/junit.ts";
|
|
4
|
+
import { executeRun } from "../../core/runner/execute-run.ts";
|
|
5
|
+
import { statusBadge, renderSuiteResults, failedFilterToggle, autoExpandFailedScript } from "../views/results.ts";
|
|
6
|
+
import { formatDuration } from "../../core/reporter/console.ts";
|
|
7
|
+
import type { TestRunResult, StepResult } from "../../core/runner/types.ts";
|
|
8
|
+
import {
|
|
9
|
+
ErrorSchema,
|
|
10
|
+
RunRequestSchema,
|
|
11
|
+
RunResponseSchema,
|
|
12
|
+
RunDetailSchema,
|
|
13
|
+
RunIdParam,
|
|
14
|
+
} from "../schemas.ts";
|
|
15
|
+
|
|
16
|
+
const api = new OpenAPIHono();
|
|
17
|
+
|
|
18
|
+
// ──────────────────────────────────────────────
|
|
19
|
+
// POST /run — form-data handler for HTMX
|
|
20
|
+
// ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
api.post("/run", async (c) => {
|
|
23
|
+
try {
|
|
24
|
+
const form = await c.req.parseBody();
|
|
25
|
+
const testPath = form["path"] as string;
|
|
26
|
+
const envName = (form["env"] as string) || undefined;
|
|
27
|
+
|
|
28
|
+
if (!testPath) {
|
|
29
|
+
return c.json({ error: "Missing 'path' field" }, 400);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { runId } = await executeRun({ testPath, envName, trigger: "webui" });
|
|
33
|
+
|
|
34
|
+
// If targeted at the results panel (dashboard), return inline HTML
|
|
35
|
+
const hxTarget = c.req.header("HX-Target");
|
|
36
|
+
if (hxTarget === "results-panel") {
|
|
37
|
+
const run = getRunById(runId);
|
|
38
|
+
if (!run) {
|
|
39
|
+
c.header("HX-Redirect", `/runs/${runId}`);
|
|
40
|
+
return c.json({ runId });
|
|
41
|
+
}
|
|
42
|
+
const results = getResultsByRunId(runId);
|
|
43
|
+
const passed = run.passed;
|
|
44
|
+
const failed = run.failed;
|
|
45
|
+
const skipped = run.skipped;
|
|
46
|
+
const total = run.total;
|
|
47
|
+
const duration = run.duration_ms != null ? formatDuration(run.duration_ms) : "-";
|
|
48
|
+
|
|
49
|
+
const header = `
|
|
50
|
+
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.5rem;padding-bottom:0.5rem;border-bottom:1px solid var(--border);">
|
|
51
|
+
<strong>Run #${run.id}</strong>
|
|
52
|
+
<span style="color:var(--text-dim);font-size:0.85rem;">just now</span>
|
|
53
|
+
<span style="font-size:0.9rem;">${passed}✓ ${failed}✗ ${skipped}○</span>
|
|
54
|
+
<span style="color:var(--text-dim);font-size:0.85rem;">${duration}</span>
|
|
55
|
+
${statusBadge(total, passed, failed)}
|
|
56
|
+
<span style="flex:1;"></span>
|
|
57
|
+
<a href="/api/export/${run.id}/junit" download class="btn btn-sm btn-outline">Export JUnit</a>
|
|
58
|
+
<a href="/api/export/${run.id}/json" download class="btn btn-sm btn-outline">Export JSON</a>
|
|
59
|
+
${failedFilterToggle()}
|
|
60
|
+
</div>`;
|
|
61
|
+
|
|
62
|
+
const suitesHtml = renderSuiteResults(results, runId);
|
|
63
|
+
return c.html(header + suitesHtml + autoExpandFailedScript());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Default: redirect to run detail page
|
|
67
|
+
c.header("HX-Redirect", `/runs/${runId}`);
|
|
68
|
+
return c.json({ runId });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
const hxTarget = c.req.header("HX-Target");
|
|
71
|
+
if (hxTarget === "results-panel") {
|
|
72
|
+
return c.html(`<div style="color:var(--fail);padding:1rem;border:1px solid var(--fail);border-radius:6px;">Error: ${(err as Error).message}</div>`, 500);
|
|
73
|
+
}
|
|
74
|
+
return c.json({ error: (err as Error).message }, 500);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const runRoute = createRoute({
|
|
79
|
+
method: "post",
|
|
80
|
+
path: "/api/run",
|
|
81
|
+
tags: ["Runs"],
|
|
82
|
+
summary: "Run tests",
|
|
83
|
+
request: {
|
|
84
|
+
body: {
|
|
85
|
+
content: { "application/json": { schema: RunRequestSchema } },
|
|
86
|
+
required: true,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
responses: {
|
|
90
|
+
200: {
|
|
91
|
+
content: { "application/json": { schema: RunResponseSchema } },
|
|
92
|
+
description: "Run created",
|
|
93
|
+
},
|
|
94
|
+
400: {
|
|
95
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
96
|
+
description: "Validation error",
|
|
97
|
+
},
|
|
98
|
+
500: {
|
|
99
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
100
|
+
description: "Server error",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
api.openapi(runRoute, async (c) => {
|
|
106
|
+
try {
|
|
107
|
+
const { path: testPath, env: envName } = c.req.valid("json");
|
|
108
|
+
const { runId } = await executeRun({ testPath, envName, trigger: "webui" });
|
|
109
|
+
|
|
110
|
+
c.header("HX-Redirect", `/runs/${runId}`);
|
|
111
|
+
return c.json({ runId }, 200);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return c.json({ error: (err as Error).message }, 500);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ──────────────────────────────────────────────
|
|
118
|
+
// Export helpers
|
|
119
|
+
// ──────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function reconstructResults(runId: number): TestRunResult[] | null {
|
|
122
|
+
const run = getRunById(runId);
|
|
123
|
+
if (!run) return null;
|
|
124
|
+
|
|
125
|
+
const rows = getResultsByRunId(runId);
|
|
126
|
+
const suiteMap = new Map<string, StepResult[]>();
|
|
127
|
+
|
|
128
|
+
for (const row of rows) {
|
|
129
|
+
const steps = suiteMap.get(row.suite_name) ?? [];
|
|
130
|
+
steps.push({
|
|
131
|
+
name: row.test_name,
|
|
132
|
+
status: row.status as StepResult["status"],
|
|
133
|
+
duration_ms: row.duration_ms,
|
|
134
|
+
request: {
|
|
135
|
+
method: row.request_method ?? "GET",
|
|
136
|
+
url: row.request_url ?? "",
|
|
137
|
+
headers: {},
|
|
138
|
+
},
|
|
139
|
+
response: row.response_status != null
|
|
140
|
+
? { status: row.response_status, headers: {}, body: "", duration_ms: row.duration_ms }
|
|
141
|
+
: undefined,
|
|
142
|
+
assertions: row.assertions,
|
|
143
|
+
captures: row.captures as Record<string, unknown>,
|
|
144
|
+
error: row.error_message ?? undefined,
|
|
145
|
+
});
|
|
146
|
+
suiteMap.set(row.suite_name, steps);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const results: TestRunResult[] = [];
|
|
150
|
+
for (const [suiteName, steps] of suiteMap) {
|
|
151
|
+
const total = steps.length;
|
|
152
|
+
const passed = steps.filter((s) => s.status === "pass").length;
|
|
153
|
+
const failed = steps.filter((s) => s.status === "fail").length;
|
|
154
|
+
const skipped = steps.filter((s) => s.status === "skip").length;
|
|
155
|
+
results.push({
|
|
156
|
+
suite_name: suiteName,
|
|
157
|
+
started_at: run.started_at,
|
|
158
|
+
finished_at: run.finished_at ?? run.started_at,
|
|
159
|
+
total,
|
|
160
|
+
passed,
|
|
161
|
+
failed,
|
|
162
|
+
skipped,
|
|
163
|
+
steps,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ──────────────────────────────────────────────
|
|
170
|
+
// Export routes (OpenAPI-documented)
|
|
171
|
+
// ──────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
const exportJsonRoute = createRoute({
|
|
174
|
+
method: "get",
|
|
175
|
+
path: "/api/export/{runId}/json",
|
|
176
|
+
tags: ["Export"],
|
|
177
|
+
summary: "Export run results as JSON",
|
|
178
|
+
request: { params: RunIdParam },
|
|
179
|
+
responses: {
|
|
180
|
+
200: {
|
|
181
|
+
content: { "application/json": { schema: RunDetailSchema } },
|
|
182
|
+
description: "Run results",
|
|
183
|
+
},
|
|
184
|
+
400: {
|
|
185
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
186
|
+
description: "Invalid run ID",
|
|
187
|
+
},
|
|
188
|
+
404: {
|
|
189
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
190
|
+
description: "Run not found",
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
api.openapi(exportJsonRoute, (c) => {
|
|
196
|
+
const { runId } = c.req.valid("param");
|
|
197
|
+
const results = reconstructResults(runId);
|
|
198
|
+
if (!results) return c.json({ error: "Run not found" }, 404);
|
|
199
|
+
|
|
200
|
+
c.header("Content-Disposition", `attachment; filename="run-${runId}-results.json"`);
|
|
201
|
+
return c.json(results as any, 200);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const exportJunitRoute = createRoute({
|
|
205
|
+
method: "get",
|
|
206
|
+
path: "/api/export/{runId}/junit",
|
|
207
|
+
tags: ["Export"],
|
|
208
|
+
summary: "Export run results as JUnit XML",
|
|
209
|
+
request: { params: RunIdParam },
|
|
210
|
+
responses: {
|
|
211
|
+
200: { description: "JUnit XML file" },
|
|
212
|
+
400: {
|
|
213
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
214
|
+
description: "Invalid run ID",
|
|
215
|
+
},
|
|
216
|
+
404: {
|
|
217
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
218
|
+
description: "Run not found",
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
api.openapi(exportJunitRoute, (c) => {
|
|
224
|
+
const { runId } = c.req.valid("param");
|
|
225
|
+
const results = reconstructResults(runId);
|
|
226
|
+
if (!results) return c.json({ error: "Run not found" }, 404);
|
|
227
|
+
|
|
228
|
+
const xml = generateJunitXml(results);
|
|
229
|
+
c.header("Content-Disposition", `attachment; filename="run-${runId}-junit.xml"`);
|
|
230
|
+
c.header("Content-Type", "application/xml");
|
|
231
|
+
return c.body(xml);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
export default api;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { layout, escapeHtml } from "../views/layout.ts";
|
|
3
|
+
import { methodBadge } from "../views/results.ts";
|
|
4
|
+
import { renderHealthStrip } from "../views/health-strip.ts";
|
|
5
|
+
import { renderEndpointsTab } from "../views/endpoints-tab.ts";
|
|
6
|
+
import { renderSuitesTab } from "../views/suites-tab.ts";
|
|
7
|
+
import { renderRunsTab, renderRunDetail } from "../views/runs-tab.ts";
|
|
8
|
+
import { buildCollectionState, invalidateCollectionCache } from "../data/collection-state.ts";
|
|
9
|
+
import {
|
|
10
|
+
listCollections,
|
|
11
|
+
getCollectionById,
|
|
12
|
+
countRunsByCollection,
|
|
13
|
+
} from "../../db/queries.ts";
|
|
14
|
+
import type { CollectionRecord, CollectionSummary } from "../../db/queries.ts";
|
|
15
|
+
import { listEnvFiles } from "../../core/parser/variables.ts";
|
|
16
|
+
|
|
17
|
+
const dashboard = new Hono();
|
|
18
|
+
|
|
19
|
+
// ──────────────────────────────────────────────
|
|
20
|
+
// GET / — Main dashboard
|
|
21
|
+
// ──────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
dashboard.get("/", async (c) => {
|
|
24
|
+
const collections = listCollections();
|
|
25
|
+
|
|
26
|
+
let selectedId: number | null = null;
|
|
27
|
+
const qId = c.req.query("collection");
|
|
28
|
+
if (qId) {
|
|
29
|
+
selectedId = parseInt(qId, 10) || null;
|
|
30
|
+
} else if (collections.length === 1) {
|
|
31
|
+
selectedId = collections[0]!.id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const selected = selectedId ? collections.find(col => col.id === selectedId) ?? null : null;
|
|
35
|
+
const selectedRecord = selected ? getCollectionById(selected.id) : null;
|
|
36
|
+
|
|
37
|
+
const { content, navExtra } = await renderPage(collections, selectedId, selectedRecord);
|
|
38
|
+
const isHtmx = c.req.header("HX-Request") === "true";
|
|
39
|
+
if (isHtmx) return c.html(content);
|
|
40
|
+
return c.html(layout("zond", content, navExtra));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ──────────────────────────────────────────────
|
|
44
|
+
// HTMX panel endpoints
|
|
45
|
+
// ──────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
dashboard.get("/panels/content", async (c) => {
|
|
48
|
+
const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
|
|
49
|
+
if (isNaN(collectionId)) return c.html("");
|
|
50
|
+
|
|
51
|
+
const collection = getCollectionById(collectionId);
|
|
52
|
+
if (!collection) return c.html("<p>Collection not found</p>");
|
|
53
|
+
|
|
54
|
+
return c.html(await renderCollectionContent(collection));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
dashboard.get("/panels/health-strip", async (c) => {
|
|
58
|
+
const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
|
|
59
|
+
if (isNaN(collectionId)) return c.html("");
|
|
60
|
+
|
|
61
|
+
const collection = getCollectionById(collectionId);
|
|
62
|
+
if (!collection) return c.html("");
|
|
63
|
+
|
|
64
|
+
invalidateCollectionCache(collectionId);
|
|
65
|
+
const state = await buildCollectionState(collection);
|
|
66
|
+
return c.html(renderHealthStrip(state));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
dashboard.get("/panels/endpoints", async (c) => {
|
|
70
|
+
const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
|
|
71
|
+
if (isNaN(collectionId)) return c.html("");
|
|
72
|
+
|
|
73
|
+
const collection = getCollectionById(collectionId);
|
|
74
|
+
if (!collection) return c.html("");
|
|
75
|
+
|
|
76
|
+
const state = await buildCollectionState(collection);
|
|
77
|
+
const filters = {
|
|
78
|
+
status: c.req.query("status") || undefined,
|
|
79
|
+
method: c.req.query("method") || undefined,
|
|
80
|
+
};
|
|
81
|
+
return c.html(renderEndpointsTab(state, filters));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
dashboard.get("/panels/suites", async (c) => {
|
|
85
|
+
const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
|
|
86
|
+
if (isNaN(collectionId)) return c.html("");
|
|
87
|
+
|
|
88
|
+
const collection = getCollectionById(collectionId);
|
|
89
|
+
if (!collection) return c.html("");
|
|
90
|
+
|
|
91
|
+
const state = await buildCollectionState(collection);
|
|
92
|
+
return c.html(renderSuitesTab(state));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
dashboard.get("/panels/runs-tab", (c) => {
|
|
96
|
+
const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
|
|
97
|
+
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10));
|
|
98
|
+
if (isNaN(collectionId)) return c.html("");
|
|
99
|
+
|
|
100
|
+
return c.html(renderRunsTab(collectionId, page));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
dashboard.get("/panels/run-detail", (c) => {
|
|
104
|
+
const runId = parseInt(c.req.query("run_id") ?? "", 10);
|
|
105
|
+
const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
|
|
106
|
+
if (isNaN(runId)) return c.html("");
|
|
107
|
+
|
|
108
|
+
return c.html(renderRunDetail(runId, collectionId));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Legacy endpoints for backwards compat (runs.ts detail page uses /panels/results)
|
|
112
|
+
dashboard.get("/panels/results", async (c) => {
|
|
113
|
+
const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
|
|
114
|
+
const runId = parseInt(c.req.query("run_id") ?? "", 10);
|
|
115
|
+
|
|
116
|
+
if (!isNaN(runId)) {
|
|
117
|
+
return c.html(renderRunDetail(runId, collectionId || 0));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!isNaN(collectionId)) {
|
|
121
|
+
const { listRunsByCollection } = await import("../../db/queries.ts");
|
|
122
|
+
const runs = listRunsByCollection(collectionId, 1, 0);
|
|
123
|
+
if (runs.length === 0) return c.html(`<p style="color:var(--text-dim);">No runs yet.</p>`);
|
|
124
|
+
return c.html(renderRunDetail(runs[0]!.id, collectionId));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return c.html("");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Legacy coverage panel (kept for /runs/:id page)
|
|
131
|
+
dashboard.get("/panels/coverage", async (c) => {
|
|
132
|
+
const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
|
|
133
|
+
if (isNaN(collectionId)) return c.html("");
|
|
134
|
+
|
|
135
|
+
const collection = getCollectionById(collectionId);
|
|
136
|
+
if (!collection?.openapi_spec) return c.html("");
|
|
137
|
+
|
|
138
|
+
return c.html(await renderCoveragePanel(collection as CollectionRecord & { openapi_spec: string }));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Legacy history panel (kept for /runs/:id page)
|
|
142
|
+
dashboard.get("/panels/history", (c) => {
|
|
143
|
+
const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
|
|
144
|
+
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10));
|
|
145
|
+
if (isNaN(collectionId)) return c.html("");
|
|
146
|
+
|
|
147
|
+
return c.html(renderRunsTab(collectionId, page));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ──────────────────────────────────────────────
|
|
151
|
+
// Rendering functions
|
|
152
|
+
// ──────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
async function renderPage(
|
|
155
|
+
collections: CollectionSummary[],
|
|
156
|
+
selectedId: number | null,
|
|
157
|
+
selectedRecord: CollectionRecord | null,
|
|
158
|
+
): Promise<{ content: string; navExtra: string }> {
|
|
159
|
+
if (collections.length === 0) {
|
|
160
|
+
return {
|
|
161
|
+
navExtra: "",
|
|
162
|
+
content: `
|
|
163
|
+
<div style="text-align:center;padding:3rem 1rem;">
|
|
164
|
+
<h1>zond</h1>
|
|
165
|
+
<p style="color:var(--text-dim);margin:1rem 0;">No API collections registered yet.</p>
|
|
166
|
+
<p style="color:var(--text-dim);">Use <code>setup_api</code> via CLI or MCP to register your first API.</p>
|
|
167
|
+
</div>`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Navbar: separator + collection selector + action bar
|
|
172
|
+
const collectionOptions = collections.map(col =>
|
|
173
|
+
`<option value="${col.id}"${col.id === selectedId ? " selected" : ""}>${escapeHtml(col.name)}${col.last_run_total > 0 ? ` (${col.pass_rate}%)` : ""}</option>`,
|
|
174
|
+
).join("");
|
|
175
|
+
|
|
176
|
+
const selectorHtml = collections.length === 1
|
|
177
|
+
? `<span class="nav-separator"></span>
|
|
178
|
+
<span class="collection-selector" style="border:none;background:none;">${escapeHtml(collections[0]!.name)}</span>
|
|
179
|
+
<input type="hidden" id="collection-select" value="${collections[0]!.id}">`
|
|
180
|
+
: `<span class="nav-separator"></span>
|
|
181
|
+
<select id="collection-select" class="collection-selector"
|
|
182
|
+
hx-get="/panels/content"
|
|
183
|
+
hx-target="#main-content"
|
|
184
|
+
hx-swap="innerHTML"
|
|
185
|
+
name="collection_id">
|
|
186
|
+
<option value="">Select an API...</option>
|
|
187
|
+
${collectionOptions}
|
|
188
|
+
</select>`;
|
|
189
|
+
|
|
190
|
+
// Action bar in navbar
|
|
191
|
+
let actionHtml = "";
|
|
192
|
+
if (selectedRecord) {
|
|
193
|
+
const baseDir = selectedRecord.base_dir ?? selectedRecord.test_path;
|
|
194
|
+
const envNames = await listEnvFiles(baseDir);
|
|
195
|
+
const envSelect = envNames.length > 0
|
|
196
|
+
? `<select name="env" form="run-form" class="collection-selector" style="font-size:0.75rem;padding:0.25rem 0.5rem;">
|
|
197
|
+
${envNames.map(n => `<option value="${escapeHtml(n)}">${escapeHtml(n || "default")}</option>`).join("")}
|
|
198
|
+
</select>`
|
|
199
|
+
: "";
|
|
200
|
+
|
|
201
|
+
actionHtml = `<div class="nav-actions">
|
|
202
|
+
${envSelect}
|
|
203
|
+
<form id="run-form" style="display:contents;"
|
|
204
|
+
hx-post="/run"
|
|
205
|
+
hx-indicator="#run-spinner"
|
|
206
|
+
hx-swap="none">
|
|
207
|
+
<input type="hidden" name="path" value="${escapeHtml(selectedRecord.test_path)}">
|
|
208
|
+
<button type="submit" class="btn btn-run" hx-disabled-elt="this">▶ Run Tests</button>
|
|
209
|
+
<span id="run-spinner" class="htmx-indicator" style="color:var(--text-dim);font-size:0.85rem;">Running...</span>
|
|
210
|
+
</form>
|
|
211
|
+
</div>`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const navExtra = `${selectorHtml}${actionHtml}`;
|
|
215
|
+
|
|
216
|
+
const bodyContent = selectedRecord ? await renderCollectionContent(selectedRecord) : "";
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
navExtra,
|
|
220
|
+
content: `<div id="main-content">${bodyContent}</div>`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function renderCollectionContent(collection: CollectionRecord): Promise<string> {
|
|
225
|
+
const state = await buildCollectionState(collection);
|
|
226
|
+
|
|
227
|
+
// Health strip
|
|
228
|
+
const healthStrip = renderHealthStrip(state);
|
|
229
|
+
|
|
230
|
+
// Tab bar with counts
|
|
231
|
+
const runCount = countRunsByCollection(collection.id);
|
|
232
|
+
const tabBar = `
|
|
233
|
+
<div class="tab-bar" id="tab-bar">
|
|
234
|
+
<button class="tab-btn tab-active" data-tab="endpoints"
|
|
235
|
+
hx-get="/panels/endpoints?collection_id=${collection.id}"
|
|
236
|
+
hx-target="#tab-content" hx-swap="innerHTML"
|
|
237
|
+
onclick="activateTab(this)">Endpoints <span class="tab-count">${state.totalEndpoints}</span></button>
|
|
238
|
+
<button class="tab-btn" data-tab="suites"
|
|
239
|
+
hx-get="/panels/suites?collection_id=${collection.id}"
|
|
240
|
+
hx-target="#tab-content" hx-swap="innerHTML"
|
|
241
|
+
onclick="activateTab(this)">Suites <span class="tab-count">${state.suites.length}</span></button>
|
|
242
|
+
<button class="tab-btn" data-tab="runs"
|
|
243
|
+
hx-get="/panels/runs-tab?collection_id=${collection.id}"
|
|
244
|
+
hx-target="#tab-content" hx-swap="innerHTML"
|
|
245
|
+
onclick="activateTab(this)">Runs <span class="tab-count">${runCount}</span></button>
|
|
246
|
+
</div>`;
|
|
247
|
+
|
|
248
|
+
// Default tab content (endpoints)
|
|
249
|
+
const defaultContent = renderEndpointsTab(state);
|
|
250
|
+
|
|
251
|
+
const tabScript = `<script>
|
|
252
|
+
function activateTab(el) {
|
|
253
|
+
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('tab-active'));
|
|
254
|
+
el.classList.add('tab-active');
|
|
255
|
+
}
|
|
256
|
+
</script>`;
|
|
257
|
+
|
|
258
|
+
return `
|
|
259
|
+
<div id="health-strip-panel">${healthStrip}</div>
|
|
260
|
+
${tabBar}
|
|
261
|
+
<div id="tab-content">${defaultContent}</div>
|
|
262
|
+
${tabScript}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Legacy helpers (kept for /runs/:id page) ──
|
|
266
|
+
|
|
267
|
+
async function renderCoveragePanel(collection: CollectionRecord & { openapi_spec: string }): Promise<string> {
|
|
268
|
+
try {
|
|
269
|
+
const { readOpenApiSpec, extractEndpoints } = await import("../../core/generator/openapi-reader.ts");
|
|
270
|
+
const { scanCoveredEndpoints, filterUncoveredEndpoints } = await import("../../core/generator/coverage-scanner.ts");
|
|
271
|
+
|
|
272
|
+
const doc = await readOpenApiSpec(collection.openapi_spec);
|
|
273
|
+
const allEndpoints = extractEndpoints(doc);
|
|
274
|
+
const covered = await scanCoveredEndpoints(collection.test_path);
|
|
275
|
+
const uncovered = filterUncoveredEndpoints(allEndpoints, covered);
|
|
276
|
+
|
|
277
|
+
const totalEndpoints = allEndpoints.length;
|
|
278
|
+
const coveredCount = totalEndpoints - uncovered.length;
|
|
279
|
+
const pct = totalEndpoints > 0 ? Math.round((coveredCount / totalEndpoints) * 100) : 0;
|
|
280
|
+
|
|
281
|
+
const badgeClass = pct >= 80 ? "badge-pass" : pct >= 50 ? "badge-skip" : "badge-fail";
|
|
282
|
+
|
|
283
|
+
const uncoveredSet = new Set(uncovered.map(ep => `${ep.method} ${ep.path}`));
|
|
284
|
+
|
|
285
|
+
const allItems = allEndpoints.map(ep => {
|
|
286
|
+
const isCovered = !uncoveredSet.has(`${ep.method} ${ep.path}`);
|
|
287
|
+
const icon = isCovered
|
|
288
|
+
? `<span style="color:var(--pass);font-weight:700;">✓</span>`
|
|
289
|
+
: `<span style="color:var(--fail);font-weight:700;">✗</span>`;
|
|
290
|
+
return `<div style="padding:0.2rem 0;font-size:0.85rem;font-family:monospace;display:flex;align-items:center;gap:0.5rem;">
|
|
291
|
+
${icon} ${methodBadge(ep.method)} ${escapeHtml(ep.path)}
|
|
292
|
+
</div>`;
|
|
293
|
+
}).join("");
|
|
294
|
+
|
|
295
|
+
const endpointsHtml = totalEndpoints > 0
|
|
296
|
+
? `<details style="margin-top:0.5rem;">
|
|
297
|
+
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-dim);">Show all ${totalEndpoints} endpoints</summary>
|
|
298
|
+
<div style="margin-top:0.25rem;">${allItems}</div>
|
|
299
|
+
</details>`
|
|
300
|
+
: "";
|
|
301
|
+
|
|
302
|
+
return `
|
|
303
|
+
<div style="margin-bottom:1rem;">
|
|
304
|
+
<span style="font-size:0.9rem;font-weight:600;">Coverage:</span>
|
|
305
|
+
<span class="badge ${badgeClass}" style="margin-left:0.25rem;">${pct}% (${coveredCount}/${totalEndpoints})</span>
|
|
306
|
+
${endpointsHtml}
|
|
307
|
+
</div>`;
|
|
308
|
+
} catch {
|
|
309
|
+
return "";
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export default dashboard;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { layout, escapeHtml } from "../views/layout.ts";
|
|
3
|
+
import { statusBadge, renderSuiteResults, failedFilterToggle, autoExpandFailedScript } from "../views/results.ts";
|
|
4
|
+
import { getRunById, getResultsByRunId, getCollectionById } from "../../db/queries.ts";
|
|
5
|
+
import { formatDuration } from "../../core/reporter/console.ts";
|
|
6
|
+
|
|
7
|
+
const runs = new Hono();
|
|
8
|
+
|
|
9
|
+
runs.get("/runs/:id", (c) => {
|
|
10
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
11
|
+
if (isNaN(id)) return c.html(layout("Not Found", "<h1>Invalid run ID</h1>"), 400);
|
|
12
|
+
|
|
13
|
+
const run = getRunById(id);
|
|
14
|
+
if (!run) return c.html(layout("Not Found", "<h1>Run not found</h1>"), 404);
|
|
15
|
+
|
|
16
|
+
const results = getResultsByRunId(id);
|
|
17
|
+
|
|
18
|
+
// Resolve test_path for re-run button
|
|
19
|
+
const collection = run.collection_id ? getCollectionById(run.collection_id) : null;
|
|
20
|
+
const rerunBtnHtml = collection
|
|
21
|
+
? `<button class="btn btn-sm btn-run"
|
|
22
|
+
hx-post="/run"
|
|
23
|
+
hx-vals='${escapeHtml(JSON.stringify({ path: collection.test_path, ...(run.environment ? { env: run.environment } : {}) }))}'
|
|
24
|
+
hx-disabled-elt="this"
|
|
25
|
+
style="margin-left:0.5rem;">Re-run</button>`
|
|
26
|
+
: "";
|
|
27
|
+
|
|
28
|
+
const headerHtml = `
|
|
29
|
+
<h1>Run #${run.id}</h1>
|
|
30
|
+
<div class="cards">
|
|
31
|
+
<div class="card">
|
|
32
|
+
<div class="card-label">Date</div>
|
|
33
|
+
<div class="card-value" style="font-size:1rem">${escapeHtml(run.started_at)}</div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="card">
|
|
36
|
+
<div class="card-label">Environment</div>
|
|
37
|
+
<div class="card-value" style="font-size:1rem">${run.environment ? escapeHtml(run.environment) : "-"}</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="card">
|
|
40
|
+
<div class="card-label">Duration</div>
|
|
41
|
+
<div class="card-value">${run.duration_ms != null ? formatDuration(run.duration_ms) : "-"}</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="card">
|
|
44
|
+
<div class="card-label">Results</div>
|
|
45
|
+
<div class="card-value" style="font-size:1rem">${run.passed} ✓ ${run.failed} ✗ ${run.skipped} ○</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div style="margin:0.5rem 0 1rem;">
|
|
49
|
+
<a href="/api/export/${run.id}/junit" download class="btn btn-sm btn-outline">Export JUnit XML</a>
|
|
50
|
+
<a href="/api/export/${run.id}/json" download class="btn btn-sm btn-outline" style="margin-left:0.5rem;">Export JSON</a>
|
|
51
|
+
${rerunBtnHtml}
|
|
52
|
+
</div>`;
|
|
53
|
+
|
|
54
|
+
const suitesHtml = renderSuiteResults(results, id);
|
|
55
|
+
|
|
56
|
+
const content = headerHtml + failedFilterToggle() + suitesHtml + autoExpandFailedScript()
|
|
57
|
+
+ `<div style="margin-top:1rem"><a href="/" class="btn btn-outline btn-sm">← Back to Dashboard</a></div>`;
|
|
58
|
+
|
|
59
|
+
const isHtmx = c.req.header("HX-Request") === "true";
|
|
60
|
+
if (isHtmx) return c.html(content);
|
|
61
|
+
return c.html(layout(`Run #${id}`, content));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export default runs;
|