@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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoints tab: all spec endpoints with coverage status, warnings, filters.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CollectionState, EndpointViewState, CoveringStep } from "../data/collection-state.ts";
|
|
6
|
+
import { escapeHtml } from "./layout.ts";
|
|
7
|
+
import { methodBadge } from "./results.ts";
|
|
8
|
+
import { basename } from "node:path";
|
|
9
|
+
|
|
10
|
+
export function renderEndpointsTab(state: CollectionState, filters?: { status?: string; method?: string }): string {
|
|
11
|
+
if (state.totalEndpoints === 0) {
|
|
12
|
+
return `<div class="tab-empty">No OpenAPI spec configured. Register a spec with <code>setup_api</code> to see endpoints.</div>`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let filtered = state.endpoints;
|
|
16
|
+
if (filters?.status) {
|
|
17
|
+
filtered = filtered.filter(ep => {
|
|
18
|
+
if (filters.status === "passing") return ep.runStatus === "passing";
|
|
19
|
+
if (filters.status === "failing") return ep.runStatus === "api_error" || ep.runStatus === "test_failed";
|
|
20
|
+
if (filters.status === "no_tests") return ep.runStatus === "no_tests";
|
|
21
|
+
if (filters.status === "not_run") return ep.runStatus === "not_run";
|
|
22
|
+
return true;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (filters?.method) {
|
|
26
|
+
filtered = filtered.filter(ep => ep.method === filters.method);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const collectionId = state.collection.id;
|
|
30
|
+
|
|
31
|
+
// Filter bar
|
|
32
|
+
const counts = {
|
|
33
|
+
all: state.endpoints.length,
|
|
34
|
+
passing: state.endpoints.filter(e => e.runStatus === "passing").length,
|
|
35
|
+
failing: state.endpoints.filter(e => e.runStatus === "api_error" || e.runStatus === "test_failed").length,
|
|
36
|
+
no_tests: state.endpoints.filter(e => e.runStatus === "no_tests").length,
|
|
37
|
+
not_run: state.endpoints.filter(e => e.runStatus === "not_run").length,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const filterBar = `
|
|
41
|
+
<div class="filter-bar">
|
|
42
|
+
<button class="filter-chip ${!filters?.status ? 'filter-active' : ''}"
|
|
43
|
+
hx-get="/panels/endpoints?collection_id=${collectionId}"
|
|
44
|
+
hx-target="#tab-content" hx-swap="innerHTML">All (${counts.all})</button>
|
|
45
|
+
<button class="filter-chip filter-chip-pass ${filters?.status === 'passing' ? 'filter-active' : ''}"
|
|
46
|
+
hx-get="/panels/endpoints?collection_id=${collectionId}&status=passing"
|
|
47
|
+
hx-target="#tab-content" hx-swap="innerHTML">Passing (${counts.passing})</button>
|
|
48
|
+
<button class="filter-chip filter-chip-fail ${filters?.status === 'failing' ? 'filter-active' : ''}"
|
|
49
|
+
hx-get="/panels/endpoints?collection_id=${collectionId}&status=failing"
|
|
50
|
+
hx-target="#tab-content" hx-swap="innerHTML">Failing (${counts.failing})</button>
|
|
51
|
+
<button class="filter-chip filter-chip-notrun ${filters?.status === 'not_run' ? 'filter-active' : ''}"
|
|
52
|
+
hx-get="/panels/endpoints?collection_id=${collectionId}&status=not_run"
|
|
53
|
+
hx-target="#tab-content" hx-swap="innerHTML">Not Run (${counts.not_run})</button>
|
|
54
|
+
<button class="filter-chip filter-chip-notest ${filters?.status === 'no_tests' ? 'filter-active' : ''}"
|
|
55
|
+
hx-get="/panels/endpoints?collection_id=${collectionId}&status=no_tests"
|
|
56
|
+
hx-target="#tab-content" hx-swap="innerHTML">No Tests (${counts.no_tests})</button>
|
|
57
|
+
</div>`;
|
|
58
|
+
|
|
59
|
+
const rows = filtered.map((ep, i) => renderEndpointRow(ep, collectionId, i)).join("");
|
|
60
|
+
|
|
61
|
+
return `${filterBar}<div class="endpoint-list">${rows}</div>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderEndpointRow(ep: EndpointViewState, collectionId: number, index: number): string {
|
|
65
|
+
const statusDot = getStatusDot(ep.runStatus);
|
|
66
|
+
const warningBadges = renderWarningBadges(ep.warnings);
|
|
67
|
+
const detailId = `ep-detail-${index}`;
|
|
68
|
+
|
|
69
|
+
return `
|
|
70
|
+
<div class="endpoint-row" onclick="var d=document.getElementById('${detailId}');d.style.display=d.style.display==='none'?'grid':'none'">
|
|
71
|
+
<span class="endpoint-status">${statusDot}</span>
|
|
72
|
+
<span class="endpoint-method">${methodBadge(ep.method)}</span>
|
|
73
|
+
<span class="endpoint-path">${escapeHtml(ep.path)}</span>
|
|
74
|
+
<span class="endpoint-badges">${warningBadges}${ep.summary ? `<span class="endpoint-summary">${escapeHtml(ep.summary)}</span>` : ""}</span>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="endpoint-detail" id="${detailId}" style="display:none">
|
|
77
|
+
${renderEndpointDetail(ep)}
|
|
78
|
+
</div>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getStatusDot(status: EndpointViewState["runStatus"]): string {
|
|
82
|
+
switch (status) {
|
|
83
|
+
case "passing": return '<span class="status-dot status-pass" title="Tests passing"></span>';
|
|
84
|
+
case "api_error": return '<span class="status-dot status-fail" title="API error (5xx)"></span>';
|
|
85
|
+
case "test_failed": return '<span class="status-dot status-fail" title="Assertion failed"></span>';
|
|
86
|
+
case "not_run": return '<span class="status-dot status-notrun" title="Tests exist, not run"></span>';
|
|
87
|
+
case "no_tests": return '<span class="status-dot status-notest" title="No tests"></span>';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderWarningBadges(warnings: string[]): string {
|
|
92
|
+
return warnings.map(w => {
|
|
93
|
+
if (w === "deprecated") return '<span class="warning-badge warning-deprecated">DEPRECATED</span>';
|
|
94
|
+
if (w === "no_response_schema") return '<span class="warning-badge warning-schema">NO SCHEMA</span>';
|
|
95
|
+
if (w === "no_responses_defined") return '<span class="warning-badge warning-schema">NO RESPONSES</span>';
|
|
96
|
+
if (w.startsWith("required_params_no_examples")) return '<span class="warning-badge warning-params">MISSING EXAMPLES</span>';
|
|
97
|
+
return `<span class="warning-badge">${escapeHtml(w)}</span>`;
|
|
98
|
+
}).join(" ");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderEndpointDetail(ep: EndpointViewState): string {
|
|
102
|
+
if (!ep.hasCoverage) {
|
|
103
|
+
return `<div class="ep-detail-section"><em style="color:var(--text-dim);">No test files cover this endpoint</em></div>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// If we have run results, show covering steps
|
|
107
|
+
if (ep.coveringSteps.length > 0) {
|
|
108
|
+
const steps = ep.coveringSteps.map(step => {
|
|
109
|
+
const icon = step.status === "pass"
|
|
110
|
+
? '<span class="step-icon pass">✓</span>'
|
|
111
|
+
: step.status === "fail" || step.status === "error"
|
|
112
|
+
? '<span class="step-icon fail">✗</span>'
|
|
113
|
+
: step.status === "skip"
|
|
114
|
+
? '<span class="step-icon skip">▬</span>'
|
|
115
|
+
: '<span class="step-icon" style="color:var(--text-dim);">○</span>';
|
|
116
|
+
|
|
117
|
+
const statusBadge = step.responseStatus && step.responseStatus >= 400 && (step.status === "fail" || step.status === "error")
|
|
118
|
+
? ` <span class="warning-badge server-error" style="font-size:0.6rem;">${step.responseStatus} ${httpStatusText(step.responseStatus)}</span>`
|
|
119
|
+
: "";
|
|
120
|
+
|
|
121
|
+
const duration = step.durationMs != null ? `<span class="step-duration">${step.durationMs}ms</span>` : "";
|
|
122
|
+
|
|
123
|
+
let assertionsHtml = "";
|
|
124
|
+
if (step.assertions && step.assertions.length > 0) {
|
|
125
|
+
assertionsHtml = `<div style="padding-left:1.5rem;margin-top:0.25rem;">` +
|
|
126
|
+
step.assertions.map(a => {
|
|
127
|
+
const aIcon = a.passed
|
|
128
|
+
? '<span class="assertion-icon pass">✓</span>'
|
|
129
|
+
: '<span class="assertion-icon fail">✗</span>';
|
|
130
|
+
const actual = !a.passed && a.actual !== undefined
|
|
131
|
+
? ` <span class="assertion-actual">(got ${JSON.stringify(a.actual)})</span>` : "";
|
|
132
|
+
return `<div class="assertion-row">${aIcon} <span class="assertion-field">${escapeHtml(a.field)}:</span> <span class="assertion-rule">${escapeHtml(a.rule)}</span>${actual}</div>`;
|
|
133
|
+
}).join("") + `</div>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let hintHtml = "";
|
|
137
|
+
if (step.hint) {
|
|
138
|
+
hintHtml = `<div class="failure-hint"><span>⚠</span> ${escapeHtml(step.hint)}</div>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return `<div class="covering-suite">
|
|
142
|
+
${icon}
|
|
143
|
+
<span class="suite-ref">${escapeHtml(step.file)}</span>
|
|
144
|
+
<span class="dim" style="font-size:0.75rem;">→ "${escapeHtml(step.stepName)}"</span>
|
|
145
|
+
<span style="margin-left:auto;display:flex;align-items:center;gap:0.5rem;">${statusBadge}${duration}</span>
|
|
146
|
+
</div>${assertionsHtml}${hintHtml}`;
|
|
147
|
+
}).join("");
|
|
148
|
+
return steps;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Fallback: just file names
|
|
152
|
+
const files = ep.coveringFiles.map(f =>
|
|
153
|
+
`<div class="covering-suite">
|
|
154
|
+
<span class="step-icon" style="color:var(--text-dim);">○</span>
|
|
155
|
+
<span class="suite-ref">${escapeHtml(basename(f))}</span>
|
|
156
|
+
<span class="dim" style="font-size:0.75rem;">not run</span>
|
|
157
|
+
</div>`
|
|
158
|
+
).join("");
|
|
159
|
+
return files;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function httpStatusText(code: number): string {
|
|
163
|
+
const map: Record<number, string> = {
|
|
164
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
|
|
165
|
+
405: "Method Not Allowed", 409: "Conflict", 422: "Unprocessable Entity",
|
|
166
|
+
429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway",
|
|
167
|
+
503: "Service Unavailable", 504: "Gateway Timeout",
|
|
168
|
+
};
|
|
169
|
+
return map[code] ?? "";
|
|
170
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health strip: coverage donut, run stats, env alert banner.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CollectionState } from "../data/collection-state.ts";
|
|
6
|
+
import { formatDuration } from "../../core/reporter/console.ts";
|
|
7
|
+
|
|
8
|
+
export function renderHealthStrip(state: CollectionState): string {
|
|
9
|
+
const { coveragePct, coveredCount, totalEndpoints, runPassed, runFailed, runSkipped, runTotal, runDurationMs, envAlert, latestRun } = state;
|
|
10
|
+
|
|
11
|
+
const donut = renderCoverageDonut(coveragePct, coveredCount, totalEndpoints);
|
|
12
|
+
|
|
13
|
+
const hasRun = latestRun !== null;
|
|
14
|
+
const duration = runDurationMs != null ? formatDuration(runDurationMs) : "-";
|
|
15
|
+
|
|
16
|
+
const statsHtml = hasRun
|
|
17
|
+
? `<div class="health-stats">
|
|
18
|
+
<div class="stat-block stat-pass"><span class="stat-value">${runPassed}</span><span class="stat-label">passed</span></div>
|
|
19
|
+
<div class="stat-block stat-fail"><span class="stat-value">${runFailed}</span><span class="stat-label">failed</span></div>
|
|
20
|
+
<div class="stat-block stat-skip"><span class="stat-value">${runSkipped}</span><span class="stat-label">skipped</span></div>
|
|
21
|
+
<div class="stat-block"><span class="stat-value">${duration}</span><span class="stat-label">duration</span></div>
|
|
22
|
+
</div>`
|
|
23
|
+
: `<div class="health-stats">
|
|
24
|
+
<div class="stat-block"><span class="stat-value">-</span><span class="stat-label">No runs yet</span></div>
|
|
25
|
+
</div>`;
|
|
26
|
+
|
|
27
|
+
// Mini progress bar
|
|
28
|
+
const progressHtml = hasRun && runTotal > 0
|
|
29
|
+
? `<div class="health-progress">
|
|
30
|
+
<div class="progress-bar" style="height:6px;">
|
|
31
|
+
<div class="progress-pass" style="width:${(runPassed / runTotal * 100).toFixed(1)}%"></div>
|
|
32
|
+
<div class="progress-fail" style="width:${(runFailed / runTotal * 100).toFixed(1)}%"></div>
|
|
33
|
+
<div class="progress-skip" style="width:${(runSkipped / runTotal * 100).toFixed(1)}%"></div>
|
|
34
|
+
</div>
|
|
35
|
+
<span class="health-progress-label">${runPassed}/${runTotal} steps passed</span>
|
|
36
|
+
</div>`
|
|
37
|
+
: "";
|
|
38
|
+
|
|
39
|
+
const envAlertHtml = envAlert ? renderEnvAlert(envAlert) : "";
|
|
40
|
+
|
|
41
|
+
return `
|
|
42
|
+
<div class="health-strip">
|
|
43
|
+
<div class="health-donut-zone">
|
|
44
|
+
${donut}
|
|
45
|
+
<div class="coverage-label">
|
|
46
|
+
<span class="label-title">Coverage</span>
|
|
47
|
+
<span class="label-value">${coveredCount} / ${totalEndpoints} endpoints</span>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="health-info-zone">
|
|
51
|
+
${statsHtml}
|
|
52
|
+
${progressHtml}
|
|
53
|
+
</div>
|
|
54
|
+
${envAlertHtml}
|
|
55
|
+
</div>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renderCoverageDonut(pct: number, covered: number, total: number): string {
|
|
59
|
+
// SVG donut chart
|
|
60
|
+
const size = 80;
|
|
61
|
+
const stroke = 8;
|
|
62
|
+
const radius = (size - stroke) / 2;
|
|
63
|
+
const circumference = 2 * Math.PI * radius;
|
|
64
|
+
const offset = circumference - (pct / 100) * circumference;
|
|
65
|
+
|
|
66
|
+
const color = pct >= 80 ? "var(--pass)" : pct >= 50 ? "var(--warn, #fbbf24)" : "var(--fail)";
|
|
67
|
+
const trackColor = "var(--border)";
|
|
68
|
+
|
|
69
|
+
return `
|
|
70
|
+
<div class="coverage-donut">
|
|
71
|
+
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
|
72
|
+
<circle cx="${size / 2}" cy="${size / 2}" r="${radius}"
|
|
73
|
+
fill="none" stroke="${trackColor}" stroke-width="${stroke}" />
|
|
74
|
+
<circle cx="${size / 2}" cy="${size / 2}" r="${radius}"
|
|
75
|
+
fill="none" stroke="${color}" stroke-width="${stroke}"
|
|
76
|
+
stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"
|
|
77
|
+
stroke-linecap="round"
|
|
78
|
+
transform="rotate(-90 ${size / 2} ${size / 2})" />
|
|
79
|
+
<text x="${size / 2}" y="${size / 2}" text-anchor="middle" dominant-baseline="central"
|
|
80
|
+
fill="var(--text)" font-size="16" font-weight="700" font-family="inherit">${pct}%</text>
|
|
81
|
+
</svg>
|
|
82
|
+
<div class="donut-label">${covered}/${total} endpoints</div>
|
|
83
|
+
</div>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function renderEnvAlert(message: string): string {
|
|
87
|
+
return `
|
|
88
|
+
<div class="env-alert">
|
|
89
|
+
<span class="env-alert-icon">⚠</span>
|
|
90
|
+
<span>${message}</span>
|
|
91
|
+
</div>`;
|
|
92
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
let _devMode = false;
|
|
2
|
+
|
|
3
|
+
export function setDevMode(enabled: boolean): void {
|
|
4
|
+
_devMode = enabled;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function layout(title: string, content: string, navExtra = ""): string {
|
|
8
|
+
const devScript = _devMode
|
|
9
|
+
? `<script>new EventSource('/dev/reload').onmessage = (e) => { if (e.data === 'reload') location.reload() }</script>`
|
|
10
|
+
: "";
|
|
11
|
+
return `<!DOCTYPE html>
|
|
12
|
+
<html lang="en">
|
|
13
|
+
<head>
|
|
14
|
+
<meta charset="UTF-8">
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
16
|
+
<title>${escapeHtml(title)} — zond</title>
|
|
17
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
18
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
19
|
+
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
20
|
+
<link rel="stylesheet" href="/static/style.css?v=${Date.now()}">
|
|
21
|
+
<script src="/static/htmx.min.js"></script>
|
|
22
|
+
<script>htmx.config.refreshOnHistoryMiss = true;</script>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<nav class="navbar">
|
|
26
|
+
<a href="/" class="nav-brand" style="text-decoration:none;color:inherit;"><span class="logo-dot"></span>zond</a>
|
|
27
|
+
${navExtra}
|
|
28
|
+
</nav>
|
|
29
|
+
<main class="main-container">
|
|
30
|
+
${content}
|
|
31
|
+
</main>
|
|
32
|
+
<footer class="footer"><div class="main-container">zond</div></footer>
|
|
33
|
+
${devScript}
|
|
34
|
+
</body>
|
|
35
|
+
</html>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function fragment(content: string): string {
|
|
39
|
+
return content;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function escapeHtml(str: string): string {
|
|
43
|
+
return str
|
|
44
|
+
.replace(/&/g, "&")
|
|
45
|
+
.replace(/</g, "<")
|
|
46
|
+
.replace(/>/g, ">")
|
|
47
|
+
.replace(/"/g, """);
|
|
48
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { escapeHtml } from "./layout.ts";
|
|
2
|
+
import { formatDuration } from "../../core/reporter/console.ts";
|
|
3
|
+
import type { StoredStepResult } from "../../db/queries.ts";
|
|
4
|
+
|
|
5
|
+
export function statusBadge(total: number, passed: number, failed: number): string {
|
|
6
|
+
if (total === 0) return `<span class="badge badge-skip">empty</span>`;
|
|
7
|
+
if (failed > 0) return `<span class="badge badge-fail">fail</span>`;
|
|
8
|
+
return `<span class="badge badge-pass">pass</span>`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function stepStatusBadge(status: string): string {
|
|
12
|
+
switch (status) {
|
|
13
|
+
case "pass":
|
|
14
|
+
return `<span class="badge badge-pass">✓</span>`;
|
|
15
|
+
case "fail":
|
|
16
|
+
return `<span class="badge badge-fail">✗</span>`;
|
|
17
|
+
case "skip":
|
|
18
|
+
return `<span class="badge badge-skip">○</span>`;
|
|
19
|
+
case "error":
|
|
20
|
+
return `<span class="badge badge-error">✗</span>`;
|
|
21
|
+
default:
|
|
22
|
+
return `<span class="badge">${escapeHtml(status)}</span>`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function methodBadge(method: string): string {
|
|
27
|
+
const m = method.toLowerCase();
|
|
28
|
+
return `<span class="badge-method method-${m}">${method}</span>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render grouped suite results with step details, captures, and chain visualization.
|
|
33
|
+
* Used by both the dashboard panels and the /runs/:id detail page.
|
|
34
|
+
*/
|
|
35
|
+
export function renderSuiteResults(
|
|
36
|
+
results: StoredStepResult[],
|
|
37
|
+
runId: number,
|
|
38
|
+
options?: { idPrefix?: string; suiteMetadata?: Map<string, { description?: string; tags?: string[] }> },
|
|
39
|
+
): string {
|
|
40
|
+
const prefix = options?.idPrefix ?? `r${runId}`;
|
|
41
|
+
|
|
42
|
+
// Group by suite
|
|
43
|
+
const suites = new Map<string, StoredStepResult[]>();
|
|
44
|
+
for (const r of results) {
|
|
45
|
+
const list = suites.get(r.suite_name) ?? [];
|
|
46
|
+
list.push(r);
|
|
47
|
+
suites.set(r.suite_name, list);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Build capture source map
|
|
51
|
+
const captureSourceMap = new Map<string, string>();
|
|
52
|
+
for (const [, steps] of suites) {
|
|
53
|
+
for (const step of steps) {
|
|
54
|
+
if (step.captures && typeof step.captures === "object") {
|
|
55
|
+
for (const varName of Object.keys(step.captures)) {
|
|
56
|
+
captureSourceMap.set(varName, step.test_name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let suitesHtml = "";
|
|
63
|
+
for (const [suiteName, steps] of suites) {
|
|
64
|
+
const suiteHasCaptures = steps.some(s =>
|
|
65
|
+
s.captures && typeof s.captures === "object" && Object.keys(s.captures).length > 0,
|
|
66
|
+
);
|
|
67
|
+
const isChainSuite = suiteHasCaptures || suiteName.endsWith("CRUD");
|
|
68
|
+
|
|
69
|
+
const stepsHtml = steps
|
|
70
|
+
.map((step, i) => {
|
|
71
|
+
const detailId = `detail-${prefix}-${i}`;
|
|
72
|
+
const hasFailed = step.status === "fail" || step.status === "error";
|
|
73
|
+
|
|
74
|
+
let capturesHtml = "";
|
|
75
|
+
if (step.captures && typeof step.captures === "object") {
|
|
76
|
+
const captureEntries = Object.entries(step.captures);
|
|
77
|
+
if (captureEntries.length > 0) {
|
|
78
|
+
capturesHtml = captureEntries.map(([k, v]) =>
|
|
79
|
+
`<span class="capture-badge">${escapeHtml(k)} = ${escapeHtml(String(v))}</span>`,
|
|
80
|
+
).join(" ");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let assertionsHtml = "";
|
|
85
|
+
if (step.assertions.length > 0) {
|
|
86
|
+
const items = step.assertions
|
|
87
|
+
.map(
|
|
88
|
+
(a) =>
|
|
89
|
+
`<li class="${a.passed ? "assertion-pass" : "assertion-fail"}">${escapeHtml(a.field)}: ${escapeHtml(a.rule)} (got ${escapeHtml(String(a.actual))})</li>`,
|
|
90
|
+
)
|
|
91
|
+
.join("");
|
|
92
|
+
assertionsHtml = `<ul class="assertion-list">${items}</ul>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let requestHtml = "";
|
|
96
|
+
if (step.request_method) {
|
|
97
|
+
requestHtml = `<div><strong>Request:</strong> ${escapeHtml(step.request_method)} ${escapeHtml(step.request_url ?? "")}</div>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let reqBodyHtml = "";
|
|
101
|
+
if (hasFailed && step.request_body) {
|
|
102
|
+
reqBodyHtml = `<details class="body-details"><summary>Request Body</summary><pre>${escapeHtml(step.request_body)}</pre></details>`;
|
|
103
|
+
}
|
|
104
|
+
let resBodyHtml = "";
|
|
105
|
+
if (hasFailed && step.response_body) {
|
|
106
|
+
resBodyHtml = `<details class="body-details"><summary>Response Body</summary><pre>${escapeHtml(step.response_body)}</pre></details>`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let errorHtml = "";
|
|
110
|
+
if (step.error_message) {
|
|
111
|
+
errorHtml = `<div><strong>Error:</strong> ${escapeHtml(step.error_message)}</div>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let skipReasonHtml = "";
|
|
115
|
+
if (step.status === "skip" && step.error_message) {
|
|
116
|
+
const match = step.error_message.match(/Depends on missing capture: (\w+)/);
|
|
117
|
+
if (match) {
|
|
118
|
+
const depVar = match[1]!;
|
|
119
|
+
const sourceStep = captureSourceMap.get(depVar);
|
|
120
|
+
skipReasonHtml = sourceStep
|
|
121
|
+
? `<div class="skip-reason">Skipped: depends on <code>${escapeHtml(depVar)}</code> (from step "${escapeHtml(sourceStep)}")</div>`
|
|
122
|
+
: `<div class="skip-reason">Skipped: depends on <code>${escapeHtml(depVar)}</code></div>`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const detailPanel = (hasFailed || skipReasonHtml)
|
|
127
|
+
? `<div class="detail-panel" id="${detailId}" style="display:none">
|
|
128
|
+
${requestHtml}
|
|
129
|
+
${errorHtml}
|
|
130
|
+
${skipReasonHtml}
|
|
131
|
+
${assertionsHtml}
|
|
132
|
+
${reqBodyHtml}
|
|
133
|
+
${resBodyHtml}
|
|
134
|
+
</div>`
|
|
135
|
+
: "";
|
|
136
|
+
|
|
137
|
+
const toggle = (hasFailed || skipReasonHtml)
|
|
138
|
+
? `onclick="var d=document.getElementById('${detailId}');d.style.display=d.style.display==='none'?'block':'none'"`
|
|
139
|
+
: "";
|
|
140
|
+
|
|
141
|
+
const chainedClass = isChainSuite ? " chained" : "";
|
|
142
|
+
const statusClass = (step.status === "fail" || step.status === "error") ? ` step-${step.status}` : "";
|
|
143
|
+
|
|
144
|
+
return `
|
|
145
|
+
<div class="step-row${chainedClass}${statusClass}" ${toggle}>
|
|
146
|
+
<div>${stepStatusBadge(step.status)}</div>
|
|
147
|
+
<div class="step-name">${escapeHtml(step.test_name)}${capturesHtml ? ` ${capturesHtml}` : ""}</div>
|
|
148
|
+
<div class="step-duration">${formatDuration(step.duration_ms)}</div>
|
|
149
|
+
</div>
|
|
150
|
+
${detailPanel}`;
|
|
151
|
+
})
|
|
152
|
+
.join("");
|
|
153
|
+
|
|
154
|
+
const chainClass = isChainSuite ? " chain-suite" : "";
|
|
155
|
+
|
|
156
|
+
const meta = options?.suiteMetadata?.get(suiteName);
|
|
157
|
+
const descriptionHtml = meta?.description
|
|
158
|
+
? `<p class="suite-description">${escapeHtml(meta.description)}</p>`
|
|
159
|
+
: "";
|
|
160
|
+
const tagsHtml = meta?.tags?.length
|
|
161
|
+
? `<div class="suite-tags">${meta.tags.map(t => `<span class="tag-badge">${escapeHtml(t)}</span>`).join(" ")}</div>`
|
|
162
|
+
: "";
|
|
163
|
+
|
|
164
|
+
suitesHtml += `
|
|
165
|
+
<div class="suite-section${chainClass}">
|
|
166
|
+
<h3>${escapeHtml(suiteName)}</h3>
|
|
167
|
+
${descriptionHtml}
|
|
168
|
+
${tagsHtml}
|
|
169
|
+
${isChainSuite ? '<div class="chain-connector">' : ""}
|
|
170
|
+
${stepsHtml}
|
|
171
|
+
${isChainSuite ? "</div>" : ""}
|
|
172
|
+
</div>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return suitesHtml;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Render the "show only failed" toggle + auto-expand failed steps script.
|
|
180
|
+
*/
|
|
181
|
+
export function failedFilterToggle(): string {
|
|
182
|
+
return `
|
|
183
|
+
<label class="failed-filter-toggle" style="display:inline-flex;align-items:center;gap:0.5rem;font-size:0.85rem;cursor:pointer;">
|
|
184
|
+
<input type="checkbox" id="failed-only-toggle" onchange="
|
|
185
|
+
var on = this.checked;
|
|
186
|
+
document.querySelectorAll('.step-row').forEach(function(el) {
|
|
187
|
+
if (on && !el.classList.contains('step-fail') && !el.classList.contains('step-error')) {
|
|
188
|
+
el.style.display = 'none';
|
|
189
|
+
var next = el.nextElementSibling;
|
|
190
|
+
if (next && next.classList.contains('detail-panel')) next.style.display = 'none';
|
|
191
|
+
} else {
|
|
192
|
+
el.style.display = '';
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
"> Show only failed
|
|
196
|
+
</label>`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Script to auto-expand failed step detail panels on page load.
|
|
201
|
+
*/
|
|
202
|
+
export function autoExpandFailedScript(): string {
|
|
203
|
+
return `<script>
|
|
204
|
+
document.querySelectorAll('.step-row.step-fail, .step-row.step-error').forEach(function(el) {
|
|
205
|
+
var next = el.nextElementSibling;
|
|
206
|
+
if (next && next.classList.contains('detail-panel')) next.style.display = 'block';
|
|
207
|
+
});
|
|
208
|
+
</script>`;
|
|
209
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs tab: history of test runs with comparison.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { escapeHtml } from "./layout.ts";
|
|
6
|
+
import { statusBadge } from "./results.ts";
|
|
7
|
+
import { formatDuration } from "../../core/reporter/console.ts";
|
|
8
|
+
import {
|
|
9
|
+
listRunsByCollection,
|
|
10
|
+
countRunsByCollection,
|
|
11
|
+
getResultsByRunId,
|
|
12
|
+
getRunById,
|
|
13
|
+
} from "../../db/queries.ts";
|
|
14
|
+
import type { RunSummary } from "../../db/queries.ts";
|
|
15
|
+
import { renderSuiteResults, failedFilterToggle, autoExpandFailedScript } from "./results.ts";
|
|
16
|
+
|
|
17
|
+
const PAGE_SIZE = 15;
|
|
18
|
+
|
|
19
|
+
export function renderRunsTab(collectionId: number, page = 1): string {
|
|
20
|
+
const offset = (page - 1) * PAGE_SIZE;
|
|
21
|
+
const runs = listRunsByCollection(collectionId, PAGE_SIZE, offset);
|
|
22
|
+
const total = countRunsByCollection(collectionId);
|
|
23
|
+
const hasMore = offset + runs.length < total;
|
|
24
|
+
|
|
25
|
+
if (runs.length === 0 && page === 1) {
|
|
26
|
+
return `<div class="tab-empty">No test runs yet. Click <strong>Run Tests</strong> to get started.</div>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rows = runs.map(r => renderRunRow(r, collectionId)).join("");
|
|
30
|
+
|
|
31
|
+
const loadMore = hasMore
|
|
32
|
+
? `<div style="text-align:center;padding:0.75rem;">
|
|
33
|
+
<button class="btn btn-sm btn-outline"
|
|
34
|
+
hx-get="/panels/runs-tab?collection_id=${collectionId}&page=${page + 1}"
|
|
35
|
+
hx-target="#tab-content" hx-swap="innerHTML">Load more...</button>
|
|
36
|
+
</div>`
|
|
37
|
+
: "";
|
|
38
|
+
|
|
39
|
+
return `
|
|
40
|
+
<div class="runs-list">
|
|
41
|
+
<div class="runs-header">
|
|
42
|
+
<span>Run</span><span>Time</span><span>Results</span><span>Duration</span><span>Status</span>
|
|
43
|
+
</div>
|
|
44
|
+
${rows}
|
|
45
|
+
${loadMore}
|
|
46
|
+
</div>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderRunRow(run: RunSummary, collectionId: number): string {
|
|
50
|
+
const timeAgo = formatTimeAgo(run.started_at);
|
|
51
|
+
const duration = run.duration_ms != null ? formatDuration(run.duration_ms) : "-";
|
|
52
|
+
const total = run.total || 1;
|
|
53
|
+
|
|
54
|
+
// Mini progress bar
|
|
55
|
+
const progressBar = run.total > 0
|
|
56
|
+
? `<div class="progress-bar run-progress">
|
|
57
|
+
<div class="progress-pass" style="width:${(run.passed / total * 100).toFixed(1)}%"></div>
|
|
58
|
+
<div class="progress-fail" style="width:${(run.failed / total * 100).toFixed(1)}%"></div>
|
|
59
|
+
</div>`
|
|
60
|
+
: "";
|
|
61
|
+
|
|
62
|
+
return `
|
|
63
|
+
<div class="run-row"
|
|
64
|
+
hx-get="/panels/run-detail?run_id=${run.id}&collection_id=${collectionId}"
|
|
65
|
+
hx-target="#tab-content" hx-swap="innerHTML">
|
|
66
|
+
<span class="run-id">#${run.id}</span>
|
|
67
|
+
<span class="run-time">${escapeHtml(timeAgo)}</span>
|
|
68
|
+
<span class="run-results">
|
|
69
|
+
${progressBar}
|
|
70
|
+
<span class="run-counts">${run.passed}✓ ${run.failed}✗ ${run.skipped}○</span>
|
|
71
|
+
</span>
|
|
72
|
+
<span class="run-duration">${duration}</span>
|
|
73
|
+
<span>${statusBadge(run.total, run.passed, run.failed)}</span>
|
|
74
|
+
</div>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderRunDetail(runId: number, collectionId: number): string {
|
|
78
|
+
const run = getRunById(runId);
|
|
79
|
+
if (!run) return `<p>Run not found</p>`;
|
|
80
|
+
|
|
81
|
+
const results = getResultsByRunId(runId);
|
|
82
|
+
if (results.length === 0) return `<p class="tab-empty">No results for run #${runId}.</p>`;
|
|
83
|
+
|
|
84
|
+
const timeAgo = formatTimeAgo(run.started_at);
|
|
85
|
+
const duration = run.duration_ms != null ? formatDuration(run.duration_ms) : "-";
|
|
86
|
+
|
|
87
|
+
const backButton = `<button class="btn btn-sm btn-outline" style="margin-bottom:0.75rem;"
|
|
88
|
+
hx-get="/panels/runs-tab?collection_id=${collectionId}"
|
|
89
|
+
hx-target="#tab-content" hx-swap="innerHTML">← Back to runs</button>`;
|
|
90
|
+
|
|
91
|
+
const header = `
|
|
92
|
+
<div class="run-detail-header">
|
|
93
|
+
<strong>Run #${run.id}</strong>
|
|
94
|
+
<span class="text-dim">${escapeHtml(timeAgo)}</span>
|
|
95
|
+
<span>${run.passed}✓ ${run.failed}✗ ${run.skipped}○</span>
|
|
96
|
+
<span class="text-dim">${duration}</span>
|
|
97
|
+
${statusBadge(run.total, run.passed, run.failed)}
|
|
98
|
+
<span style="flex:1;"></span>
|
|
99
|
+
<a href="/api/export/${run.id}/junit" download class="btn btn-sm btn-outline">JUnit</a>
|
|
100
|
+
<a href="/api/export/${run.id}/json" download class="btn btn-sm btn-outline">JSON</a>
|
|
101
|
+
${failedFilterToggle()}
|
|
102
|
+
</div>`;
|
|
103
|
+
|
|
104
|
+
const suitesHtml = renderSuiteResults(results, runId);
|
|
105
|
+
|
|
106
|
+
return backButton + header + suitesHtml + autoExpandFailedScript();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatTimeAgo(isoDate: string): string {
|
|
110
|
+
try {
|
|
111
|
+
const date = new Date(isoDate);
|
|
112
|
+
const now = new Date();
|
|
113
|
+
const diffMs = now.getTime() - date.getTime();
|
|
114
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
115
|
+
if (diffSec < 60) return "just now";
|
|
116
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
117
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
118
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
119
|
+
if (diffHr < 24) return `${diffHr}h ago`;
|
|
120
|
+
const diffDay = Math.floor(diffHr / 24);
|
|
121
|
+
if (diffDay < 7) return `${diffDay}d ago`;
|
|
122
|
+
return date.toLocaleDateString();
|
|
123
|
+
} catch {
|
|
124
|
+
return isoDate;
|
|
125
|
+
}
|
|
126
|
+
}
|