@kirrosh/apitool 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/.github/workflows/ci.yml +27 -0
  2. package/.github/workflows/release.yml +97 -0
  3. package/.mcp.json +9 -0
  4. package/APITOOL.md +195 -0
  5. package/BACKLOG.md +62 -0
  6. package/CHANGELOG.md +88 -0
  7. package/LICENSE +21 -0
  8. package/README.md +105 -0
  9. package/bun.lock +291 -0
  10. package/docs/GLOSSARY.md +182 -0
  11. package/docs/INDEX.md +21 -0
  12. package/docs/agent.md +135 -0
  13. package/docs/archive/APITOOL-pre-M22.md +831 -0
  14. package/docs/archive/BACKLOG-AI-NATIVE.md +56 -0
  15. package/docs/archive/M1-M2-parser-runner.md +216 -0
  16. package/docs/archive/M4-M7-reporter-cli.md +179 -0
  17. package/docs/archive/M5-M7-storage-junit.md +300 -0
  18. package/docs/archive/M6-webui.md +339 -0
  19. package/docs/ci.md +274 -0
  20. package/docs/generation-issues.md +67 -0
  21. package/generated/.env.yaml +3 -0
  22. package/install.ps1 +80 -0
  23. package/install.sh +113 -0
  24. package/package.json +46 -0
  25. package/scripts/run-mocked-tests.ts +45 -0
  26. package/seed-demo.ts +53 -0
  27. package/self-tests/auth.yaml +18 -0
  28. package/self-tests/collections-crud.yaml +46 -0
  29. package/self-tests/environments-crud.yaml +48 -0
  30. package/self-tests/export.yaml +32 -0
  31. package/self-tests/runs.yaml +16 -0
  32. package/src/bun-types.d.ts +5 -0
  33. package/src/cli/commands/add-api.ts +51 -0
  34. package/src/cli/commands/ai-generate.ts +106 -0
  35. package/src/cli/commands/chat.ts +43 -0
  36. package/src/cli/commands/ci-init.ts +126 -0
  37. package/src/cli/commands/collections.ts +41 -0
  38. package/src/cli/commands/coverage.ts +65 -0
  39. package/src/cli/commands/doctor.ts +127 -0
  40. package/src/cli/commands/envs.ts +218 -0
  41. package/src/cli/commands/init.ts +84 -0
  42. package/src/cli/commands/mcp.ts +16 -0
  43. package/src/cli/commands/run.ts +137 -0
  44. package/src/cli/commands/runs.ts +108 -0
  45. package/src/cli/commands/serve.ts +22 -0
  46. package/src/cli/commands/update.ts +142 -0
  47. package/src/cli/commands/validate.ts +18 -0
  48. package/src/cli/index.ts +500 -0
  49. package/src/cli/output.ts +24 -0
  50. package/src/cli/runtime.ts +7 -0
  51. package/src/core/agent/agent-loop.ts +116 -0
  52. package/src/core/agent/context-manager.ts +41 -0
  53. package/src/core/agent/system-prompt.ts +33 -0
  54. package/src/core/agent/tools/diagnose-failure.ts +51 -0
  55. package/src/core/agent/tools/explore-api.ts +40 -0
  56. package/src/core/agent/tools/index.ts +48 -0
  57. package/src/core/agent/tools/manage-environment.ts +40 -0
  58. package/src/core/agent/tools/query-results.ts +40 -0
  59. package/src/core/agent/tools/run-tests.ts +38 -0
  60. package/src/core/agent/tools/send-request.ts +44 -0
  61. package/src/core/agent/tools/validate-tests.ts +23 -0
  62. package/src/core/agent/types.ts +22 -0
  63. package/src/core/generator/ai/ai-generator.ts +61 -0
  64. package/src/core/generator/ai/llm-client.ts +159 -0
  65. package/src/core/generator/ai/output-parser.ts +307 -0
  66. package/src/core/generator/ai/prompt-builder.ts +153 -0
  67. package/src/core/generator/ai/types.ts +56 -0
  68. package/src/core/generator/coverage-scanner.ts +87 -0
  69. package/src/core/generator/data-factory.ts +115 -0
  70. package/src/core/generator/index.ts +10 -0
  71. package/src/core/generator/openapi-reader.ts +142 -0
  72. package/src/core/generator/schema-utils.ts +52 -0
  73. package/src/core/generator/serializer.ts +189 -0
  74. package/src/core/generator/types.ts +47 -0
  75. package/src/core/parser/filter.ts +14 -0
  76. package/src/core/parser/index.ts +21 -0
  77. package/src/core/parser/schema.ts +175 -0
  78. package/src/core/parser/types.ts +50 -0
  79. package/src/core/parser/variables.ts +146 -0
  80. package/src/core/parser/yaml-parser.ts +85 -0
  81. package/src/core/reporter/console.ts +175 -0
  82. package/src/core/reporter/index.ts +23 -0
  83. package/src/core/reporter/json.ts +9 -0
  84. package/src/core/reporter/junit.ts +78 -0
  85. package/src/core/reporter/types.ts +12 -0
  86. package/src/core/runner/assertions.ts +172 -0
  87. package/src/core/runner/execute-run.ts +75 -0
  88. package/src/core/runner/executor.ts +150 -0
  89. package/src/core/runner/http-client.ts +69 -0
  90. package/src/core/runner/index.ts +12 -0
  91. package/src/core/runner/types.ts +48 -0
  92. package/src/core/setup-api.ts +97 -0
  93. package/src/core/utils.ts +9 -0
  94. package/src/db/queries.ts +868 -0
  95. package/src/db/schema.ts +215 -0
  96. package/src/mcp/server.ts +47 -0
  97. package/src/mcp/tools/ci-init.ts +57 -0
  98. package/src/mcp/tools/coverage-analysis.ts +58 -0
  99. package/src/mcp/tools/explore-api.ts +84 -0
  100. package/src/mcp/tools/generate-missing-tests.ts +80 -0
  101. package/src/mcp/tools/generate-tests-guide.ts +353 -0
  102. package/src/mcp/tools/manage-environment.ts +123 -0
  103. package/src/mcp/tools/manage-server.ts +87 -0
  104. package/src/mcp/tools/query-db.ts +141 -0
  105. package/src/mcp/tools/run-tests.ts +66 -0
  106. package/src/mcp/tools/save-test-suite.ts +164 -0
  107. package/src/mcp/tools/send-request.ts +53 -0
  108. package/src/mcp/tools/setup-api.ts +49 -0
  109. package/src/mcp/tools/validate-tests.ts +42 -0
  110. package/src/tui/chat-ui.ts +150 -0
  111. package/src/web/routes/api.ts +234 -0
  112. package/src/web/routes/dashboard.ts +348 -0
  113. package/src/web/routes/runs.ts +64 -0
  114. package/src/web/schemas.ts +121 -0
  115. package/src/web/server.ts +134 -0
  116. package/src/web/static/htmx.min.js +1 -0
  117. package/src/web/static/style.css +265 -0
  118. package/src/web/views/layout.ts +46 -0
  119. package/src/web/views/results.ts +209 -0
  120. package/tests/agent/agent-loop.test.ts +61 -0
  121. package/tests/agent/context-manager.test.ts +59 -0
  122. package/tests/agent/system-prompt.test.ts +42 -0
  123. package/tests/agent/tools/diagnose-failure.test.ts +85 -0
  124. package/tests/agent/tools/explore-api.test.ts +59 -0
  125. package/tests/agent/tools/manage-environment.test.ts +78 -0
  126. package/tests/agent/tools/query-results.test.ts +77 -0
  127. package/tests/agent/tools/run-tests.test.ts +89 -0
  128. package/tests/agent/tools/send-request.test.ts +78 -0
  129. package/tests/agent/tools/validate-tests.test.ts +59 -0
  130. package/tests/ai/ai-generator.integration.test.ts +131 -0
  131. package/tests/ai/llm-client.test.ts +145 -0
  132. package/tests/ai/output-parser.test.ts +132 -0
  133. package/tests/ai/prompt-builder.test.ts +67 -0
  134. package/tests/ai/types.test.ts +55 -0
  135. package/tests/cli/args.test.ts +63 -0
  136. package/tests/cli/chat.test.ts +38 -0
  137. package/tests/cli/ci-init.test.ts +112 -0
  138. package/tests/cli/commands.test.ts +316 -0
  139. package/tests/cli/coverage.test.ts +58 -0
  140. package/tests/cli/doctor.test.ts +39 -0
  141. package/tests/cli/envs.test.ts +181 -0
  142. package/tests/cli/init.test.ts +80 -0
  143. package/tests/cli/runs.test.ts +94 -0
  144. package/tests/cli/safe-run.test.ts +103 -0
  145. package/tests/cli/update.test.ts +32 -0
  146. package/tests/core/generator/schema-utils.test.ts +108 -0
  147. package/tests/core/parser/nested-assertions.test.ts +80 -0
  148. package/tests/core/runner/root-body-assertions.test.ts +70 -0
  149. package/tests/db/chat-queries.test.ts +88 -0
  150. package/tests/db/chat-schema.test.ts +37 -0
  151. package/tests/db/environments.test.ts +131 -0
  152. package/tests/db/queries.test.ts +409 -0
  153. package/tests/db/schema.test.ts +141 -0
  154. package/tests/fixtures/.env.yaml +3 -0
  155. package/tests/fixtures/auth-token-test.yaml +8 -0
  156. package/tests/fixtures/bail/suite-a.yaml +6 -0
  157. package/tests/fixtures/bail/suite-b.yaml +6 -0
  158. package/tests/fixtures/crud.yaml +35 -0
  159. package/tests/fixtures/invalid-missing-name.yaml +5 -0
  160. package/tests/fixtures/invalid-no-method.yaml +6 -0
  161. package/tests/fixtures/petstore-auth.json +295 -0
  162. package/tests/fixtures/petstore-simple.json +151 -0
  163. package/tests/fixtures/post-only.yaml +12 -0
  164. package/tests/fixtures/simple.yaml +6 -0
  165. package/tests/fixtures/valid/.env.yaml +1 -0
  166. package/tests/fixtures/valid/a.yaml +5 -0
  167. package/tests/fixtures/valid/b.yml +5 -0
  168. package/tests/generator/coverage-scanner.test.ts +129 -0
  169. package/tests/generator/data-factory.test.ts +133 -0
  170. package/tests/generator/openapi-reader.test.ts +131 -0
  171. package/tests/integration/auth-flow.test.ts +217 -0
  172. package/tests/mcp/coverage-analysis.test.ts +64 -0
  173. package/tests/mcp/explore-api-schemas.test.ts +105 -0
  174. package/tests/mcp/explore-api.test.ts +49 -0
  175. package/tests/mcp/generate-missing-tests.test.ts +69 -0
  176. package/tests/mcp/manage-environment.test.ts +89 -0
  177. package/tests/mcp/save-test-suite.test.ts +116 -0
  178. package/tests/mcp/send-request.test.ts +79 -0
  179. package/tests/mcp/setup-api.test.ts +106 -0
  180. package/tests/mcp/tools.test.ts +248 -0
  181. package/tests/parser/schema.test.ts +134 -0
  182. package/tests/parser/variables.test.ts +227 -0
  183. package/tests/parser/yaml-parser.test.ts +69 -0
  184. package/tests/reporter/console.test.ts +256 -0
  185. package/tests/reporter/json.test.ts +98 -0
  186. package/tests/reporter/junit.test.ts +284 -0
  187. package/tests/runner/assertions.test.ts +262 -0
  188. package/tests/runner/executor.test.ts +310 -0
  189. package/tests/runner/http-client.test.ts +138 -0
  190. package/tests/web/routes.test.ts +160 -0
  191. package/tsconfig.json +31 -0
@@ -0,0 +1,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}&#10003; ${failed}&#10007; ${skipped}&#9675;</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,348 @@
1
+ import { Hono } from "hono";
2
+ import { layout, escapeHtml } from "../views/layout.ts";
3
+ import { statusBadge, renderSuiteResults, failedFilterToggle, autoExpandFailedScript, methodBadge } from "../views/results.ts";
4
+ import { formatDuration } from "../../core/reporter/console.ts";
5
+ import {
6
+ listCollections,
7
+ listEnvironmentRecords,
8
+ listRunsByCollection,
9
+ countRunsByCollection,
10
+ getResultsByRunId,
11
+ getRunById,
12
+ getCollectionById,
13
+ } from "../../db/queries.ts";
14
+ import type { CollectionSummary } from "../../db/queries.ts";
15
+
16
+ const dashboard = new Hono();
17
+
18
+ const HISTORY_PAGE_SIZE = 10;
19
+
20
+ // ──────────────────────────────────────────────
21
+ // GET / — Single-page dashboard
22
+ // ──────────────────────────────────────────────
23
+
24
+ dashboard.get("/", (c) => {
25
+ const collections = listCollections();
26
+
27
+ // Auto-select the only collection, or use query param
28
+ let selectedId: number | null = null;
29
+ const qId = c.req.query("collection");
30
+ if (qId) {
31
+ selectedId = parseInt(qId, 10) || null;
32
+ } else if (collections.length === 1) {
33
+ selectedId = collections[0]!.id;
34
+ }
35
+
36
+ const content = renderPage(collections, selectedId);
37
+ const isHtmx = c.req.header("HX-Request") === "true";
38
+ if (isHtmx) return c.html(content);
39
+ return c.html(layout("apitool", content));
40
+ });
41
+
42
+ // ──────────────────────────────────────────────
43
+ // HTMX panel endpoints
44
+ // ──────────────────────────────────────────────
45
+
46
+ dashboard.get("/panels/content", (c) => {
47
+ const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
48
+ if (isNaN(collectionId)) return c.html("");
49
+
50
+ const collection = getCollectionById(collectionId);
51
+ if (!collection) return c.html("<p>Collection not found</p>");
52
+
53
+ const envRecords = listEnvironmentRecords(collectionId);
54
+ return c.html(renderCollectionContent(collection, envRecords));
55
+ });
56
+
57
+ dashboard.get("/panels/results", async (c) => {
58
+ const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
59
+ const runId = parseInt(c.req.query("run_id") ?? "", 10);
60
+
61
+ if (!isNaN(runId)) {
62
+ return c.html(await renderRunResults(runId));
63
+ }
64
+
65
+ if (!isNaN(collectionId)) {
66
+ // Get latest run for this collection
67
+ const runs = listRunsByCollection(collectionId, 1, 0);
68
+ if (runs.length === 0) return c.html(`<p style="color:var(--text-dim);">No runs yet. Click <strong>Run Tests</strong> to get started.</p>`);
69
+ return c.html(await renderRunResults(runs[0]!.id));
70
+ }
71
+
72
+ return c.html("");
73
+ });
74
+
75
+ dashboard.get("/panels/coverage", async (c) => {
76
+ const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
77
+ if (isNaN(collectionId)) return c.html("");
78
+
79
+ const collection = getCollectionById(collectionId);
80
+ if (!collection?.openapi_spec) return c.html("");
81
+
82
+ return c.html(await renderCoveragePanel(collection));
83
+ });
84
+
85
+ dashboard.get("/panels/history", (c) => {
86
+ const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
87
+ const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10));
88
+ if (isNaN(collectionId)) return c.html("");
89
+
90
+ return c.html(renderHistoryPanel(collectionId, page));
91
+ });
92
+
93
+ // ──────────────────────────────────────────────
94
+ // Rendering functions
95
+ // ──────────────────────────────────────────────
96
+
97
+ function renderPage(collections: CollectionSummary[], selectedId: number | null): string {
98
+ if (collections.length === 0) {
99
+ return `
100
+ <div style="text-align:center;padding:3rem 1rem;">
101
+ <h1>apitool</h1>
102
+ <p style="color:var(--text-dim);margin:1rem 0;">No API collections registered yet.</p>
103
+ <p style="color:var(--text-dim);">Use <code>setup_api</code> via CLI or MCP to register your first API.</p>
104
+ </div>`;
105
+ }
106
+
107
+ const selected = selectedId ? collections.find(col => col.id === selectedId) ?? null : null;
108
+ const envRecords = selected ? listEnvironmentRecords(selected.id) : [];
109
+
110
+ // API selector
111
+ const collectionOptions = collections.map(col =>
112
+ `<option value="${col.id}"${col.id === selectedId ? " selected" : ""}>${escapeHtml(col.name)}${col.last_run_total > 0 ? ` (${col.pass_rate}%)` : ""}</option>`,
113
+ ).join("");
114
+
115
+ const selectorHtml = collections.length === 1
116
+ ? `<span style="font-weight:600;font-size:1.1rem;">${escapeHtml(collections[0]!.name)}</span>
117
+ <input type="hidden" id="collection-select" value="${collections[0]!.id}">`
118
+ : `<select id="collection-select"
119
+ hx-get="/panels/content"
120
+ hx-target="#collection-content"
121
+ hx-swap="innerHTML"
122
+ name="collection_id"
123
+ style="padding:0.4rem 0.6rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:1rem;font-weight:600;">
124
+ <option value="">Select an API...</option>
125
+ ${collectionOptions}
126
+ </select>`;
127
+
128
+ return `
129
+ <div style="display:flex;align-items:center;gap:1rem;margin:1.5rem 0 1rem;">
130
+ ${selectorHtml}
131
+ </div>
132
+ <div id="collection-content">
133
+ ${selected ? renderCollectionContent(selected, envRecords) : ""}
134
+ </div>`;
135
+ }
136
+
137
+ function renderCollectionContent(collection: CollectionSummary, envRecords: { id: number; name: string; collection_id: number | null }[]): string {
138
+ // Auto-select: prefer first scoped env, then first env if only one
139
+ const defaultEnv = envRecords.find(e => e.collection_id !== null)?.name
140
+ ?? (envRecords.length === 1 ? envRecords[0]!.name : null);
141
+
142
+ const envOptions = envRecords.map(e =>
143
+ `<option value="${escapeHtml(e.name)}"${e.name === defaultEnv ? " selected" : ""}>${escapeHtml(e.name)}${e.collection_id ? "" : " (global)"}</option>`,
144
+ ).join("");
145
+
146
+ const envSelect = envRecords.length > 0
147
+ ? `<select name="env" form="run-form" style="padding:0.35rem 0.5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:0.85rem;">
148
+ <option value="">No environment</option>
149
+ ${envOptions}
150
+ </select>`
151
+ : "";
152
+
153
+ const actionBar = `
154
+ <div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
155
+ ${envSelect}
156
+ <form id="run-form"
157
+ hx-post="/run"
158
+ hx-target="#results-panel"
159
+ hx-swap="innerHTML"
160
+ hx-indicator="#run-spinner"
161
+ style="display:inline;">
162
+ <input type="hidden" name="path" value="${escapeHtml(collection.test_path)}">
163
+ <button type="submit" class="btn btn-run" hx-disabled-elt="this">Run Tests</button>
164
+ <span id="run-spinner" class="htmx-indicator" style="color:var(--text-dim);font-size:0.85rem;margin-left:0.25rem;">Running...</span>
165
+ </form>
166
+ </div>`;
167
+
168
+ return `
169
+ ${actionBar}
170
+ <div id="coverage-panel"
171
+ hx-get="/panels/coverage?collection_id=${collection.id}"
172
+ hx-trigger="load"
173
+ hx-swap="innerHTML">
174
+ </div>
175
+ <div id="results-panel"
176
+ hx-get="/panels/results?collection_id=${collection.id}"
177
+ hx-trigger="load"
178
+ hx-swap="innerHTML">
179
+ <span class="htmx-indicator" style="color:var(--text-dim);">Loading results...</span>
180
+ </div>
181
+ <div id="history-panel"
182
+ hx-get="/panels/history?collection_id=${collection.id}"
183
+ hx-trigger="load, every 5s"
184
+ hx-swap="innerHTML">
185
+ </div>`;
186
+ }
187
+
188
+ async function loadSuiteMetadata(testPath: string): Promise<Map<string, { description?: string; tags?: string[] }>> {
189
+ const { parseDirectory } = await import("../../core/parser/yaml-parser.ts");
190
+ const suites = await parseDirectory(testPath);
191
+ const map = new Map<string, { description?: string; tags?: string[] }>();
192
+ for (const s of suites) {
193
+ map.set(s.name, { description: s.description, tags: s.tags });
194
+ }
195
+ return map;
196
+ }
197
+
198
+ async function renderRunResults(runId: number): Promise<string> {
199
+ const run = getRunById(runId);
200
+ if (!run) return `<p>Run not found</p>`;
201
+
202
+ const results = getResultsByRunId(runId);
203
+ if (results.length === 0) return `<p style="color:var(--text-dim);">No results for run #${runId}.</p>`;
204
+
205
+ const passed = run.passed;
206
+ const failed = run.failed;
207
+ const skipped = run.skipped;
208
+ const total = run.total;
209
+
210
+ const timeAgo = formatTimeAgo(run.started_at);
211
+ const duration = run.duration_ms != null ? formatDuration(run.duration_ms) : "-";
212
+
213
+ // Load suite metadata from YAML files if we can find the collection
214
+ let suiteMetadata: Map<string, { description?: string; tags?: string[] }> | undefined;
215
+ try {
216
+ const collection = run.collection_id != null ? getCollectionById(run.collection_id) : null;
217
+ if (collection?.test_path) {
218
+ suiteMetadata = await loadSuiteMetadata(collection.test_path);
219
+ }
220
+ } catch { /* skip metadata if unavailable */ }
221
+
222
+ const header = `
223
+ <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);">
224
+ <strong>Run #${run.id}</strong>
225
+ <span style="color:var(--text-dim);font-size:0.85rem;">${escapeHtml(timeAgo)}</span>
226
+ <span style="font-size:0.9rem;">${passed}&#10003; ${failed}&#10007; ${skipped}&#9675;</span>
227
+ <span style="color:var(--text-dim);font-size:0.85rem;">${duration}</span>
228
+ ${statusBadge(total, passed, failed)}
229
+ <span style="flex:1;"></span>
230
+ <a href="/api/export/${run.id}/junit" download class="btn btn-sm btn-outline">Export JUnit</a>
231
+ <a href="/api/export/${run.id}/json" download class="btn btn-sm btn-outline">Export JSON</a>
232
+ ${failedFilterToggle()}
233
+ </div>`;
234
+
235
+ const suitesHtml = renderSuiteResults(results, runId, { suiteMetadata });
236
+
237
+ return header + suitesHtml + autoExpandFailedScript();
238
+ }
239
+
240
+ async function renderCoveragePanel(collection: CollectionSummary & { openapi_spec: string }): Promise<string> {
241
+ try {
242
+ const { readOpenApiSpec, extractEndpoints } = await import("../../core/generator/openapi-reader.ts");
243
+ const { scanCoveredEndpoints, filterUncoveredEndpoints } = await import("../../core/generator/coverage-scanner.ts");
244
+
245
+ const doc = await readOpenApiSpec(collection.openapi_spec);
246
+ const allEndpoints = extractEndpoints(doc);
247
+ const covered = await scanCoveredEndpoints(collection.test_path);
248
+ const uncovered = filterUncoveredEndpoints(allEndpoints, covered);
249
+
250
+ const totalEndpoints = allEndpoints.length;
251
+ const coveredCount = totalEndpoints - uncovered.length;
252
+ const pct = totalEndpoints > 0 ? Math.round((coveredCount / totalEndpoints) * 100) : 0;
253
+
254
+ const badgeClass = pct >= 80 ? "badge-pass" : pct >= 50 ? "badge-skip" : "badge-fail";
255
+
256
+ // Build set of uncovered keys for lookup
257
+ const uncoveredSet = new Set(uncovered.map(ep => `${ep.method} ${ep.path}`));
258
+
259
+ // Show all endpoints: covered with checkmark, uncovered with X
260
+ const allItems = allEndpoints.map(ep => {
261
+ const isCovered = !uncoveredSet.has(`${ep.method} ${ep.path}`);
262
+ const icon = isCovered
263
+ ? `<span style="color:var(--pass);font-weight:700;">&#10003;</span>`
264
+ : `<span style="color:var(--fail);font-weight:700;">&#10007;</span>`;
265
+ return `<div style="padding:0.2rem 0;font-size:0.85rem;font-family:monospace;display:flex;align-items:center;gap:0.5rem;">
266
+ ${icon} ${methodBadge(ep.method)} ${escapeHtml(ep.path)}
267
+ </div>`;
268
+ }).join("");
269
+
270
+ const endpointsHtml = totalEndpoints > 0
271
+ ? `<details style="margin-top:0.5rem;">
272
+ <summary style="cursor:pointer;font-size:0.85rem;color:var(--text-dim);">Show all ${totalEndpoints} endpoints</summary>
273
+ <div style="margin-top:0.25rem;">${allItems}</div>
274
+ </details>`
275
+ : "";
276
+
277
+ return `
278
+ <div style="margin-bottom:1rem;">
279
+ <span style="font-size:0.9rem;font-weight:600;">Coverage:</span>
280
+ <span class="badge ${badgeClass}" style="margin-left:0.25rem;">${pct}% (${coveredCount}/${totalEndpoints})</span>
281
+ ${endpointsHtml}
282
+ </div>`;
283
+ } catch {
284
+ return "";
285
+ }
286
+ }
287
+
288
+ function renderHistoryPanel(collectionId: number, page: number): string {
289
+ const offset = (page - 1) * HISTORY_PAGE_SIZE;
290
+ const runs = listRunsByCollection(collectionId, HISTORY_PAGE_SIZE, offset);
291
+ const total = countRunsByCollection(collectionId);
292
+ const hasMore = offset + runs.length < total;
293
+
294
+ if (runs.length === 0 && page === 1) return "";
295
+
296
+ const rows = runs.map(r => {
297
+ const timeAgo = formatTimeAgo(r.started_at);
298
+ return `
299
+ <div class="history-row"
300
+ style="display:flex;align-items:center;gap:0.75rem;padding:0.4rem 0.5rem;border-bottom:1px solid var(--border);cursor:pointer;font-size:0.85rem;"
301
+ hx-get="/panels/results?run_id=${r.id}"
302
+ hx-target="#results-panel"
303
+ hx-swap="innerHTML">
304
+ <span style="font-weight:600;">#${r.id}</span>
305
+ <span style="color:var(--text-dim);min-width:5rem;">${escapeHtml(timeAgo)}</span>
306
+ <span>${r.passed}/${r.total} pass</span>
307
+ ${statusBadge(r.total, r.passed, r.failed)}
308
+ ${r.duration_ms != null ? `<span style="color:var(--text-dim);">${formatDuration(r.duration_ms)}</span>` : ""}
309
+ </div>`;
310
+ }).join("");
311
+
312
+ const loadMore = hasMore
313
+ ? `<div style="text-align:center;padding:0.5rem;">
314
+ <button class="btn btn-sm btn-outline"
315
+ hx-get="/panels/history?collection_id=${collectionId}&page=${page + 1}"
316
+ hx-target="#history-panel"
317
+ hx-swap="innerHTML">Load more...</button>
318
+ </div>`
319
+ : "";
320
+
321
+ return `
322
+ <div style="margin-top:1.5rem;">
323
+ <div style="font-weight:600;font-size:0.95rem;margin-bottom:0.5rem;padding-bottom:0.25rem;border-bottom:1px solid var(--border);">Run History</div>
324
+ ${rows}
325
+ ${loadMore}
326
+ </div>`;
327
+ }
328
+
329
+ function formatTimeAgo(isoDate: string): string {
330
+ try {
331
+ const date = new Date(isoDate);
332
+ const now = new Date();
333
+ const diffMs = now.getTime() - date.getTime();
334
+ const diffSec = Math.floor(diffMs / 1000);
335
+ if (diffSec < 60) return "just now";
336
+ const diffMin = Math.floor(diffSec / 60);
337
+ if (diffMin < 60) return `${diffMin}m ago`;
338
+ const diffHr = Math.floor(diffMin / 60);
339
+ if (diffHr < 24) return `${diffHr}h ago`;
340
+ const diffDay = Math.floor(diffHr / 24);
341
+ if (diffDay < 7) return `${diffDay}d ago`;
342
+ return date.toLocaleDateString();
343
+ } catch {
344
+ return isoDate;
345
+ }
346
+ }
347
+
348
+ 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} &#10003; ${run.failed} &#10007; ${run.skipped} &#9675;</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">&larr; 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;