@kirrosh/zond 0.19.0 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirrosh/zond",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "API testing platform — define tests in YAML, run from CLI or WebUI, generate from OpenAPI specs",
5
5
  "license": "MIT",
6
6
  "module": "index.ts",
package/src/db/queries.ts CHANGED
@@ -255,7 +255,9 @@ export function saveResults(runId: number, suiteResults: TestRunResult[]): void
255
255
  db.transaction(() => {
256
256
  for (const suite of suiteResults) {
257
257
  for (const step of suite.steps) {
258
- const keepBody = step.status === "fail" || step.status === "error";
258
+ const maxBodySize = 50_000;
259
+ const truncBody = (s: string | null | undefined) =>
260
+ s && s.length > maxBodySize ? s.slice(0, maxBodySize) + "\n...[truncated]" : (s ?? null);
259
261
  stmt.run({
260
262
  $run_id: runId,
261
263
  $suite_name: suite.suite_name,
@@ -264,10 +266,10 @@ export function saveResults(runId: number, suiteResults: TestRunResult[]): void
264
266
  $duration_ms: step.duration_ms,
265
267
  $request_method: step.request.method,
266
268
  $request_url: step.request.url,
267
- $request_body: step.request.body ?? null,
269
+ $request_body: truncBody(step.request.body),
268
270
  $response_status: step.response?.status ?? null,
269
- $response_body: keepBody ? (step.response?.body ?? null) : null,
270
- $response_headers: keepBody && step.response?.headers
271
+ $response_body: truncBody(step.response?.body),
272
+ $response_headers: step.response?.headers
271
273
  ? JSON.stringify(step.response.headers)
272
274
  : null,
273
275
  $error_message: step.error ?? null,
@@ -40,6 +40,7 @@ export interface StepViewState {
40
40
  durationMs?: number;
41
41
  requestMethod?: string;
42
42
  requestUrl?: string;
43
+ requestBody?: string;
43
44
  responseStatus?: number;
44
45
  responseBody?: string;
45
46
  assertions?: { field: string; rule: string; passed: boolean; actual?: unknown; expected?: unknown }[];
@@ -281,6 +282,7 @@ export async function buildCollectionState(collection: CollectionRecord): Promis
281
282
  durationMs: r.duration_ms ?? undefined,
282
283
  requestMethod: r.request_method ?? undefined,
283
284
  requestUrl: r.request_url ?? undefined,
285
+ requestBody: r.request_body ?? undefined,
284
286
  responseStatus: r.response_status ?? undefined,
285
287
  responseBody: r.response_body ?? undefined,
286
288
  assertions: Array.isArray(r.assertions) ? r.assertions : undefined,
@@ -253,6 +253,28 @@ async function renderCollectionContent(collection: CollectionRecord): Promise<st
253
253
  document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('tab-active'));
254
254
  el.classList.add('tab-active');
255
255
  }
256
+ function switchToSuite(suiteName) {
257
+ var suitesBtn = document.querySelector('[data-tab="suites"]');
258
+ if (!suitesBtn) return;
259
+ suitesBtn.click();
260
+ document.addEventListener('htmx:afterSwap', function handler(e) {
261
+ if (e.detail.target && e.detail.target.id === 'tab-content') {
262
+ document.removeEventListener('htmx:afterSwap', handler);
263
+ setTimeout(function() {
264
+ var rows = document.querySelectorAll('.suite-row[data-suite-name]');
265
+ for (var i = 0; i < rows.length; i++) {
266
+ if (rows[i].dataset.suiteName === suiteName) {
267
+ rows[i].scrollIntoView({ behavior: 'smooth', block: 'center' });
268
+ rows[i].click();
269
+ rows[i].classList.add('suite-highlight');
270
+ setTimeout(function() { rows[i].classList.remove('suite-highlight'); }, 2000);
271
+ break;
272
+ }
273
+ }
274
+ }, 50);
275
+ }
276
+ });
277
+ }
256
278
  </script>`;
257
279
 
258
280
  return `
@@ -816,6 +816,37 @@ h3 { font-size: 1.05rem; margin: 1rem 0 0.25rem; }
816
816
  .history-row:hover { background: var(--bg-hover); }
817
817
  .pagination { display: flex; gap: 0.5rem; margin: 1.5rem 0; align-items: center; }
818
818
 
819
+ /* ── Step method+path+status labels ── */
820
+ .step-path { font-family: var(--font-mono); font-size: 0.8rem; }
821
+ .step-status-code {
822
+ display: inline-block;
823
+ font-family: var(--font-mono);
824
+ font-size: 0.65rem;
825
+ font-weight: 600;
826
+ padding: 0.1rem 0.35rem;
827
+ border-radius: 3px;
828
+ margin-left: 0.35rem;
829
+ vertical-align: middle;
830
+ }
831
+ .step-status-code.status-ok { background: var(--pass-dim); color: var(--pass); }
832
+ .step-status-code.status-error { background: var(--fail-dim); color: var(--fail); }
833
+ .step-name-dim { color: var(--text-dim); font-size: 0.7rem; margin-left: 0.5rem; }
834
+
835
+ /* ── Suite link (endpoints → suites navigation) ── */
836
+ .suite-link {
837
+ color: var(--accent);
838
+ text-decoration: none;
839
+ cursor: pointer;
840
+ }
841
+ .suite-link:hover { text-decoration: underline; }
842
+ .suite-highlight {
843
+ animation: suite-flash 2s ease-out;
844
+ }
845
+ @keyframes suite-flash {
846
+ 0% { background: rgba(61, 139, 253, 0.25); }
847
+ 100% { background: transparent; }
848
+ }
849
+
819
850
  /* ── Responsive ── */
820
851
  @media (max-width: 768px) {
821
852
  .health-strip { grid-template-columns: 1fr; gap: 1rem; }
@@ -93,7 +93,7 @@ function renderWarningBadges(warnings: string[]): string {
93
93
  if (w === "deprecated") return '<span class="warning-badge warning-deprecated">DEPRECATED</span>';
94
94
  if (w === "no_response_schema") return '<span class="warning-badge warning-schema">NO SCHEMA</span>';
95
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>';
96
+ if (w.startsWith("required_params_no_examples")) return "";
97
97
  return `<span class="warning-badge">${escapeHtml(w)}</span>`;
98
98
  }).join(" ");
99
99
  }
@@ -140,7 +140,8 @@ function renderEndpointDetail(ep: EndpointViewState): string {
140
140
 
141
141
  return `<div class="covering-suite">
142
142
  ${icon}
143
- <span class="suite-ref">${escapeHtml(step.file)}</span>
143
+ <a class="suite-ref suite-link" href="#" data-suite="${escapeHtml(step.suiteName)}"
144
+ onclick="event.stopPropagation();switchToSuite(this.dataset.suite)">${escapeHtml(step.file)}</a>
144
145
  <span class="dim" style="font-size:0.75rem;">&rarr; "${escapeHtml(step.stepName)}"</span>
145
146
  <span style="margin-left:auto;display:flex;align-items:center;gap:0.5rem;">${statusBadge}${duration}</span>
146
147
  </div>${assertionsHtml}${hintHtml}`;
@@ -149,13 +150,16 @@ function renderEndpointDetail(ep: EndpointViewState): string {
149
150
  }
150
151
 
151
152
  // Fallback: just file names
152
- const files = ep.coveringFiles.map(f =>
153
- `<div class="covering-suite">
153
+ const files = ep.coveringFiles.map(f => {
154
+ const fileName = basename(f);
155
+ const suiteName = fileName.replace(/\.(ya?ml)$/i, "");
156
+ return `<div class="covering-suite">
154
157
  <span class="step-icon" style="color:var(--text-dim);">&#9675;</span>
155
- <span class="suite-ref">${escapeHtml(basename(f))}</span>
158
+ <a class="suite-ref suite-link" href="#" data-suite="${escapeHtml(suiteName)}"
159
+ onclick="event.stopPropagation();switchToSuite(this.dataset.suite)">${escapeHtml(fileName)}</a>
156
160
  <span class="dim" style="font-size:0.75rem;">not run</span>
157
- </div>`
158
- ).join("");
161
+ </div>`;
162
+ }).join("");
159
163
  return files;
160
164
  }
161
165
 
@@ -98,11 +98,11 @@ export function renderSuiteResults(
98
98
  }
99
99
 
100
100
  let reqBodyHtml = "";
101
- if (hasFailed && step.request_body) {
101
+ if (step.request_body) {
102
102
  reqBodyHtml = `<details class="body-details"><summary>Request Body</summary><pre>${escapeHtml(step.request_body)}</pre></details>`;
103
103
  }
104
104
  let resBodyHtml = "";
105
- if (hasFailed && step.response_body) {
105
+ if (step.response_body) {
106
106
  resBodyHtml = `<details class="body-details"><summary>Response Body</summary><pre>${escapeHtml(step.response_body)}</pre></details>`;
107
107
  }
108
108
 
@@ -123,7 +123,8 @@ export function renderSuiteResults(
123
123
  }
124
124
  }
125
125
 
126
- const detailPanel = (hasFailed || skipReasonHtml)
126
+ const hasContent = requestHtml || errorHtml || skipReasonHtml || assertionsHtml || reqBodyHtml || resBodyHtml;
127
+ const detailPanel = hasContent
127
128
  ? `<div class="detail-panel" id="${detailId}" style="display:none">
128
129
  ${requestHtml}
129
130
  ${errorHtml}
@@ -134,7 +135,7 @@ export function renderSuiteResults(
134
135
  </div>`
135
136
  : "";
136
137
 
137
- const toggle = (hasFailed || skipReasonHtml)
138
+ const toggle = hasContent
138
139
  ? `onclick="var d=document.getElementById('${detailId}');d.style.display=d.style.display==='none'?'block':'none'"`
139
140
  : "";
140
141
 
@@ -144,7 +145,7 @@ export function renderSuiteResults(
144
145
  return `
145
146
  <div class="step-row${chainedClass}${statusClass}" ${toggle}>
146
147
  <div>${stepStatusBadge(step.status)}</div>
147
- <div class="step-name">${escapeHtml(step.test_name)}${capturesHtml ? ` ${capturesHtml}` : ""}</div>
148
+ <div class="step-name">${step.request_method && step.request_url ? (() => { let p: string; try { p = new URL(step.request_url).pathname; } catch { p = step.request_url; } const sc = step.response_status ? ` <span class="step-status-code ${step.response_status >= 400 ? "status-error" : "status-ok"}">${step.response_status}</span>` : ""; return `${methodBadge(step.request_method)} <span class="step-path">${escapeHtml(p)}</span>${sc} <span class="step-name-dim">${escapeHtml(step.test_name)}</span>`; })() : escapeHtml(step.test_name)}${capturesHtml ? ` ${capturesHtml}` : ""}</div>
148
149
  <div class="step-duration">${formatDuration(step.duration_ms)}</div>
149
150
  </div>
150
151
  ${detailPanel}`;
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { CollectionState, SuiteViewState, StepViewState } from "../data/collection-state.ts";
6
6
  import { escapeHtml } from "./layout.ts";
7
+ import { methodBadge } from "./results.ts";
7
8
  import { basename } from "node:path";
8
9
 
9
10
  export function renderSuitesTab(state: CollectionState): string {
@@ -55,7 +56,7 @@ function renderSuiteRow(suite: SuiteViewState, index: number): string {
55
56
  : `<div style="font-size:0.75rem;color:var(--text-dim);padding:0.5rem;">No run results yet</div>`;
56
57
 
57
58
  return `
58
- <div class="suite-row"
59
+ <div class="suite-row" data-suite-name="${escapeHtml(suite.name)}"
59
60
  onclick="var d=document.getElementById('${detailId}');d.style.display=d.style.display==='none'?'block':'none'">
60
61
  <div class="suite-info">
61
62
  <div class="suite-name">${escapeHtml(suite.name)}</div>
@@ -87,6 +88,21 @@ function renderStepRow(step: StepViewState, suiteIdx: number, stepIdx: number):
87
88
  ? `<span class="step-duration">${step.durationMs}ms</span>`
88
89
  : `<span class="step-duration">-</span>`;
89
90
 
91
+ // Primary label: prefer METHOD /path [status] over step name
92
+ let primaryLabel: string;
93
+ let nameLabel = "";
94
+ if (step.requestMethod && step.requestUrl) {
95
+ let urlPath: string;
96
+ try { urlPath = new URL(step.requestUrl).pathname; } catch { urlPath = step.requestUrl; }
97
+ const statusTag = step.responseStatus
98
+ ? ` <span class="step-status-code ${step.responseStatus >= 400 ? "status-error" : "status-ok"}">${step.responseStatus}</span>`
99
+ : "";
100
+ primaryLabel = `${methodBadge(step.requestMethod)} <span class="step-path">${escapeHtml(urlPath)}</span>${statusTag}`;
101
+ nameLabel = ` <span class="step-name-dim">${escapeHtml(step.name)}</span>`;
102
+ } else {
103
+ primaryLabel = escapeHtml(step.name);
104
+ }
105
+
90
106
  // Captures
91
107
  const captureHtml = step.captures && Object.keys(step.captures).length > 0
92
108
  ? `<span class="step-captures">${Object.entries(step.captures).map(([k, v]) =>
@@ -95,8 +111,13 @@ function renderStepRow(step: StepViewState, suiteIdx: number, stepIdx: number):
95
111
  : `<span class="step-captures"></span>`;
96
112
 
97
113
  const detailId = `s-${suiteIdx}-step-${stepIdx}`;
98
- const hasDetail = (step.status === "fail" || step.status === "error") &&
99
- ((step.assertions && step.assertions.length > 0) || step.hint || step.responseBody);
114
+ const hasDetail =
115
+ (step.assertions && step.assertions.length > 0) ||
116
+ step.hint ||
117
+ step.responseBody ||
118
+ step.requestBody ||
119
+ step.errorMessage ||
120
+ (step.requestMethod && step.requestUrl);
100
121
 
101
122
  const clickHandler = hasDetail
102
123
  ? ` onclick="event.stopPropagation();var d=document.getElementById('${detailId}');d.style.display=d.style.display==='none'?'block':'none'"`
@@ -134,6 +155,13 @@ function renderStepRow(step: StepViewState, suiteIdx: number, stepIdx: number):
134
155
  detailContent += `<div class="failure-hint"><span>&#9888;</span> ${escapeHtml(step.hint)}</div>`;
135
156
  }
136
157
 
158
+ // Request body toggle
159
+ if (step.requestBody) {
160
+ const truncatedReq = step.requestBody.length > 2000 ? step.requestBody.slice(0, 2000) + "..." : step.requestBody;
161
+ detailContent += `<div class="req-res-toggle" onclick="event.stopPropagation();var b=this.nextElementSibling;b.style.display=b.style.display==='none'?'block':'none'">&#9660; Request Body</div>
162
+ <div class="req-res-body" style="display:none;"><pre style="font-size:0.7rem;margin:0.25rem 0;">${escapeHtml(truncatedReq)}</pre></div>`;
163
+ }
164
+
137
165
  // Response body toggle
138
166
  if (step.responseBody) {
139
167
  const truncated = step.responseBody.length > 2000 ? step.responseBody.slice(0, 2000) + "..." : step.responseBody;
@@ -146,7 +174,7 @@ function renderStepRow(step: StepViewState, suiteIdx: number, stepIdx: number):
146
174
 
147
175
  return `<div class="step-row"${clickHandler}>
148
176
  ${icon}
149
- <span class="step-label"${labelStyle}>${escapeHtml(step.name)}</span>
177
+ <span class="step-label"${labelStyle}>${primaryLabel}${nameLabel}</span>
150
178
  ${captureHtml}
151
179
  ${duration}
152
180
  </div>${detailPanel}`;