@kirrosh/zond 0.19.0 → 0.21.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.
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Explorer tab: Swagger-like interactive API explorer.
3
+ * Renders endpoint forms, executes requests via server proxy, displays responses.
4
+ */
5
+
6
+ import type { OpenAPIV3 } from "openapi-types";
7
+ import type { CollectionRecord } from "../../db/queries.ts";
8
+ import type { EndpointInfo } from "../../core/generator/types.ts";
9
+ import { escapeHtml } from "./layout.ts";
10
+ import { methodBadge } from "./results.ts";
11
+
12
+ // ── Public API ──
13
+
14
+ export async function renderExplorerTab(collection: CollectionRecord): Promise<string> {
15
+ if (!collection.openapi_spec) {
16
+ return `<div class="tab-empty">No OpenAPI spec configured. Register a spec with <code>setup_api</code> to see the explorer.</div>`;
17
+ }
18
+
19
+ let doc: OpenAPIV3.Document;
20
+ let endpoints: EndpointInfo[];
21
+ try {
22
+ const { readOpenApiSpec, extractEndpoints } = await import("../../core/generator/openapi-reader.ts");
23
+ doc = await readOpenApiSpec(collection.openapi_spec);
24
+ endpoints = extractEndpoints(doc);
25
+ } catch (err) {
26
+ return `<div class="tab-empty">Failed to load OpenAPI spec: ${escapeHtml((err as Error).message)}</div>`;
27
+ }
28
+
29
+ if (endpoints.length === 0) {
30
+ return `<div class="tab-empty">No endpoints found in the OpenAPI spec.</div>`;
31
+ }
32
+
33
+ // Resolve base URLs from spec servers + env
34
+ const baseUrls: string[] = [];
35
+ if (doc.servers && doc.servers.length > 0) {
36
+ for (const s of doc.servers) {
37
+ if (s.url) baseUrls.push(s.url);
38
+ }
39
+ }
40
+
41
+ let envBaseUrl: string | undefined;
42
+ try {
43
+ const { loadEnvironment } = await import("../../core/parser/variables.ts");
44
+ const env = await loadEnvironment(undefined, collection.base_dir ?? collection.test_path);
45
+ if (env.base_url) {
46
+ envBaseUrl = env.base_url;
47
+ if (!baseUrls.includes(envBaseUrl)) baseUrls.unshift(envBaseUrl);
48
+ }
49
+ } catch { /* no env file */ }
50
+
51
+ // Base URL bar
52
+ const baseUrlBar = renderBaseUrlBar(baseUrls, envBaseUrl);
53
+
54
+ // Group endpoints by first tag
55
+ const groups = new Map<string, EndpointInfo[]>();
56
+ for (const ep of endpoints) {
57
+ const tag = ep.tags.length > 0 ? ep.tags[0]! : "Other";
58
+ const list = groups.get(tag) ?? [];
59
+ list.push(ep);
60
+ groups.set(tag, list);
61
+ }
62
+
63
+ let idx = 0;
64
+ const groupsHtml = [...groups.entries()].map(([tag, eps]) => {
65
+ const rows = eps.map(ep => {
66
+ const html = renderEndpointEntry(ep, idx, collection.id);
67
+ idx++;
68
+ return html;
69
+ }).join("");
70
+ return `<details class="explorer-group" open>
71
+ <summary class="explorer-group-title">${escapeHtml(tag)} <span class="tab-count">${eps.length}</span></summary>
72
+ ${rows}
73
+ </details>`;
74
+ }).join("");
75
+
76
+ const script = `<script>
77
+ function explorerToggle(id) {
78
+ var el = document.getElementById(id);
79
+ if (!el) return;
80
+ el.style.display = el.style.display === 'none' ? 'block' : 'none';
81
+ }
82
+ function explorerAddHeader(btn) {
83
+ var container = btn.previousElementSibling;
84
+ var count = container.querySelectorAll('.explorer-header-pair').length;
85
+ var row = document.createElement('div');
86
+ row.className = 'explorer-header-pair';
87
+ row.innerHTML = '<input type="text" name="custom_header_key_' + count + '" placeholder="Header name" class="explorer-input explorer-input-sm">' +
88
+ '<input type="text" name="custom_header_value_' + count + '" placeholder="Value" class="explorer-input explorer-input-sm">' +
89
+ '<button type="button" class="explorer-remove-btn" onclick="this.parentElement.remove()">x</button>';
90
+ container.appendChild(row);
91
+ }
92
+ function explorerGetBaseUrl() {
93
+ var sel = document.getElementById('explorer-base-url-select');
94
+ var custom = document.getElementById('explorer-base-url-custom');
95
+ if (sel && sel.value === '__custom__') return custom ? custom.value : '';
96
+ return sel ? sel.value : (custom ? custom.value : '');
97
+ }
98
+ function explorerBeforeRequest(formId) {
99
+ var form = document.getElementById(formId);
100
+ if (!form) return true;
101
+ var input = form.querySelector('input[name="base_url"]');
102
+ if (input) input.value = explorerGetBaseUrl();
103
+ return true;
104
+ }
105
+ document.addEventListener('change', function(e) {
106
+ if (e.target && e.target.id === 'explorer-base-url-select') {
107
+ var custom = document.getElementById('explorer-base-url-custom');
108
+ if (custom) custom.style.display = e.target.value === '__custom__' ? 'block' : 'none';
109
+ }
110
+ });
111
+ </script>`;
112
+
113
+ return `${baseUrlBar}<div class="explorer-list">${groupsHtml}</div>${script}`;
114
+ }
115
+
116
+ export function renderProxyResponse(status: number, headers: Record<string, string>, body: string, elapsedMs: number): string {
117
+ const statusClass = status < 300 ? "status-2xx" : status < 400 ? "status-3xx" : status < 500 ? "status-4xx" : "status-5xx";
118
+ const statusText = httpStatusText(status);
119
+ const size = body.length < 1024 ? `${body.length} B` : `${(body.length / 1024).toFixed(1)} KB`;
120
+
121
+ // Try to format JSON
122
+ let formattedBody: string;
123
+ const contentType = headers["content-type"] ?? "";
124
+ if (contentType.includes("json") || body.trimStart().startsWith("{") || body.trimStart().startsWith("[")) {
125
+ try {
126
+ const pretty = JSON.stringify(JSON.parse(body), null, 2);
127
+ formattedBody = highlightJson(pretty);
128
+ } catch {
129
+ formattedBody = escapeHtml(body);
130
+ }
131
+ } else {
132
+ formattedBody = escapeHtml(body);
133
+ }
134
+
135
+ const headerEntries = Object.entries(headers);
136
+ const headersHtml = headerEntries.length > 0
137
+ ? `<details class="response-headers">
138
+ <summary>Headers (${headerEntries.length})</summary>
139
+ <pre class="response-headers-pre">${headerEntries.map(([k, v]) => `<span class="json-key">${escapeHtml(k)}</span>: ${escapeHtml(v)}`).join("\n")}</pre>
140
+ </details>`
141
+ : "";
142
+
143
+ return `<div class="explorer-response">
144
+ <div class="response-meta">
145
+ <span class="response-status ${statusClass}">${status} ${escapeHtml(statusText)}</span>
146
+ <span class="response-time">${elapsedMs}ms</span>
147
+ <span class="response-size">${size}</span>
148
+ </div>
149
+ ${headersHtml}
150
+ <div class="response-body"><pre><code>${formattedBody}</code></pre></div>
151
+ </div>`;
152
+ }
153
+
154
+ export function renderProxyError(message: string, elapsedMs: number): string {
155
+ return `<div class="explorer-response explorer-response-error">
156
+ <div class="response-meta">
157
+ <span class="response-status status-5xx">Error</span>
158
+ <span class="response-time">${elapsedMs}ms</span>
159
+ </div>
160
+ <div class="response-error-msg">${escapeHtml(message)}</div>
161
+ </div>`;
162
+ }
163
+
164
+ // ── Private helpers ──
165
+
166
+ function renderBaseUrlBar(baseUrls: string[], envBaseUrl?: string): string {
167
+ if (baseUrls.length === 0) {
168
+ return `<div class="explorer-base-url">
169
+ <label class="explorer-label">Base URL</label>
170
+ <input type="text" id="explorer-base-url-custom" class="explorer-input" placeholder="https://api.example.com" value="">
171
+ </div>`;
172
+ }
173
+
174
+ const options = baseUrls.map(url => {
175
+ const label = url === envBaseUrl ? `${url} (env)` : url;
176
+ return `<option value="${escapeHtml(url)}">${escapeHtml(label)}</option>`;
177
+ }).join("");
178
+
179
+ return `<div class="explorer-base-url">
180
+ <label class="explorer-label">Base URL</label>
181
+ <select id="explorer-base-url-select" class="explorer-input">
182
+ ${options}
183
+ <option value="__custom__">Custom...</option>
184
+ </select>
185
+ <input type="text" id="explorer-base-url-custom" class="explorer-input" placeholder="https://api.example.com" style="display:none;">
186
+ </div>`;
187
+ }
188
+
189
+ function renderEndpointEntry(ep: EndpointInfo, index: number, collectionId: number): string {
190
+ const formId = `explorer-form-${index}`;
191
+ const detailId = `explorer-detail-${index}`;
192
+ const responseId = `explorer-response-${index}`;
193
+ const spinnerId = `explorer-spinner-${index}`;
194
+ const deprecated = ep.deprecated ? ' <span class="warning-badge warning-deprecated">DEPRECATED</span>' : "";
195
+ const securityHint = ep.security.length > 0
196
+ ? ` <span class="explorer-auth-hint" title="Requires: ${escapeHtml(ep.security.join(", "))}">Auth</span>`
197
+ : "";
198
+
199
+ // Separate parameters by location
200
+ const pathParams = ep.parameters.filter(p => p.in === "path");
201
+ const queryParams = ep.parameters.filter(p => p.in === "query");
202
+ const headerParams = ep.parameters.filter(p => p.in === "header");
203
+
204
+ // Request body
205
+ const hasBody = ["POST", "PUT", "PATCH"].includes(ep.method);
206
+ const exampleBody = hasBody && ep.requestBodySchema
207
+ ? JSON.stringify(generateExample(ep.requestBodySchema), null, 2)
208
+ : "";
209
+ const bodyContentType = ep.requestBodyContentType ?? "application/json";
210
+
211
+ const paramsHtml = renderParamsSection(pathParams, queryParams, headerParams);
212
+ const bodyHtml = hasBody ? renderBodySection(exampleBody, bodyContentType) : "";
213
+ const headersHtml = renderCustomHeadersSection();
214
+
215
+ return `
216
+ <div class="explorer-endpoint" onclick="explorerToggle('${detailId}')">
217
+ ${methodBadge(ep.method)}
218
+ <span class="explorer-endpoint-path">${escapeHtml(ep.path)}</span>
219
+ ${deprecated}${securityHint}
220
+ ${ep.summary ? `<span class="explorer-endpoint-summary">${escapeHtml(ep.summary)}</span>` : ""}
221
+ </div>
222
+ <div class="explorer-detail" id="${detailId}" style="display:none" onclick="event.stopPropagation()">
223
+ <form id="${formId}" hx-post="/api/proxy" hx-target="#${responseId}" hx-swap="innerHTML"
224
+ hx-indicator="#${spinnerId}"
225
+ hx-vals='js:{"base_url": explorerGetBaseUrl()}'
226
+ hx-disabled-elt="find .explorer-send-btn">
227
+ <input type="hidden" name="method" value="${ep.method}">
228
+ <input type="hidden" name="path" value="${escapeHtml(ep.path)}">
229
+ <input type="hidden" name="collection_id" value="${collectionId}">
230
+ ${paramsHtml}
231
+ ${bodyHtml}
232
+ ${headersHtml}
233
+ <div class="explorer-actions">
234
+ <button type="submit" class="btn explorer-send-btn">Send</button>
235
+ <span id="${spinnerId}" class="htmx-indicator explorer-spinner">Sending...</span>
236
+ </div>
237
+ </form>
238
+ <div id="${responseId}" class="explorer-response-container"></div>
239
+ </div>`;
240
+ }
241
+
242
+ function renderParamsSection(
243
+ pathParams: OpenAPIV3.ParameterObject[],
244
+ queryParams: OpenAPIV3.ParameterObject[],
245
+ headerParams: OpenAPIV3.ParameterObject[],
246
+ ): string {
247
+ const all = [
248
+ ...pathParams.map(p => ({ ...p, prefix: "param_path_" })),
249
+ ...queryParams.map(p => ({ ...p, prefix: "param_query_" })),
250
+ ...headerParams.map(p => ({ ...p, prefix: "param_header_" })),
251
+ ];
252
+
253
+ if (all.length === 0) return "";
254
+
255
+ const rows = all.map(p => {
256
+ const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
257
+ const type = schema?.type ?? "string";
258
+ const required = p.required ? '<span class="explorer-required">*</span>' : "";
259
+ const locationLabel = p.in === "path" ? "path" : p.in === "query" ? "query" : "header";
260
+ const placeholder = schema?.example != null ? String(schema.example) : (schema?.enum ? schema.enum[0] : "");
261
+ const defaultVal = schema?.default != null ? String(schema.default) : "";
262
+ const description = p.description ? ` title="${escapeHtml(p.description)}"` : "";
263
+
264
+ return `<div class="explorer-param-row"${description}>
265
+ <span class="explorer-param-name">${escapeHtml(p.name)}${required}</span>
266
+ <span class="explorer-param-location">${locationLabel}</span>
267
+ <span class="explorer-param-type">${escapeHtml(type)}${schema?.format ? ` (${escapeHtml(schema.format)})` : ""}</span>
268
+ <input type="text" name="${p.prefix}${escapeHtml(p.name)}" class="explorer-input"
269
+ placeholder="${escapeHtml(placeholder)}" value="${escapeHtml(defaultVal)}"
270
+ ${p.required ? "required" : ""}>
271
+ </div>`;
272
+ }).join("");
273
+
274
+ return `<div class="explorer-section">
275
+ <div class="explorer-section-title">Parameters</div>
276
+ ${rows}
277
+ </div>`;
278
+ }
279
+
280
+ function renderBodySection(exampleBody: string, contentType: string): string {
281
+ return `<div class="explorer-section">
282
+ <div class="explorer-section-title">Request Body
283
+ <select name="content_type" class="explorer-input explorer-input-sm explorer-content-type">
284
+ <option value="application/json"${contentType === "application/json" ? " selected" : ""}>application/json</option>
285
+ <option value="application/x-www-form-urlencoded"${contentType === "application/x-www-form-urlencoded" ? " selected" : ""}>form-urlencoded</option>
286
+ </select>
287
+ </div>
288
+ <textarea name="body" class="explorer-body-editor" rows="8" spellcheck="false">${escapeHtml(exampleBody)}</textarea>
289
+ </div>`;
290
+ }
291
+
292
+ function renderCustomHeadersSection(): string {
293
+ return `<div class="explorer-section">
294
+ <div class="explorer-section-title">Headers</div>
295
+ <div class="explorer-headers-list">
296
+ <div class="explorer-header-pair">
297
+ <input type="text" name="custom_header_key_0" placeholder="Header name" class="explorer-input explorer-input-sm">
298
+ <input type="text" name="custom_header_value_0" placeholder="Value" class="explorer-input explorer-input-sm">
299
+ <button type="button" class="explorer-remove-btn" onclick="this.parentElement.remove()">x</button>
300
+ </div>
301
+ </div>
302
+ <button type="button" class="explorer-add-header-btn" onclick="explorerAddHeader(this)">+ Add header</button>
303
+ </div>`;
304
+ }
305
+
306
+ function generateExample(schema: OpenAPIV3.SchemaObject, depth = 0): unknown {
307
+ if (depth > 5) return {};
308
+
309
+ if (schema.example !== undefined) return schema.example;
310
+
311
+ if (schema.enum && schema.enum.length > 0) return schema.enum[0];
312
+
313
+ if (schema.allOf) {
314
+ const merged: Record<string, unknown> = {};
315
+ for (const sub of schema.allOf) {
316
+ const s = sub as OpenAPIV3.SchemaObject;
317
+ const val = generateExample(s, depth + 1);
318
+ if (typeof val === "object" && val !== null && !Array.isArray(val)) {
319
+ Object.assign(merged, val);
320
+ }
321
+ }
322
+ return merged;
323
+ }
324
+
325
+ if (schema.oneOf && schema.oneOf.length > 0) {
326
+ return generateExample(schema.oneOf[0] as OpenAPIV3.SchemaObject, depth + 1);
327
+ }
328
+
329
+ if (schema.anyOf && schema.anyOf.length > 0) {
330
+ return generateExample(schema.anyOf[0] as OpenAPIV3.SchemaObject, depth + 1);
331
+ }
332
+
333
+ switch (schema.type) {
334
+ case "string":
335
+ if (schema.format === "email") return "user@example.com";
336
+ if (schema.format === "date") return "2026-01-01";
337
+ if (schema.format === "date-time") return "2026-01-01T00:00:00Z";
338
+ if (schema.format === "uri" || schema.format === "url") return "https://example.com";
339
+ if (schema.format === "uuid") return "550e8400-e29b-41d4-a716-446655440000";
340
+ return "string";
341
+ case "integer":
342
+ return schema.minimum != null ? schema.minimum : 0;
343
+ case "number":
344
+ return schema.minimum != null ? schema.minimum : 0.0;
345
+ case "boolean":
346
+ return true;
347
+ case "array": {
348
+ const items = schema.items as OpenAPIV3.SchemaObject | undefined;
349
+ if (items) return [generateExample(items, depth + 1)];
350
+ return [];
351
+ }
352
+ case "object":
353
+ default:
354
+ if (schema.properties) {
355
+ const result: Record<string, unknown> = {};
356
+ for (const [key, propObj] of Object.entries(schema.properties)) {
357
+ result[key] = generateExample(propObj as OpenAPIV3.SchemaObject, depth + 1);
358
+ }
359
+ return result;
360
+ }
361
+ return {};
362
+ }
363
+ }
364
+
365
+ function highlightJson(json: string): string {
366
+ // Split into tokens and non-token text, escape everything properly
367
+ const tokenRe = /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|([-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false)\b|\b(null)\b/g;
368
+ let result = "";
369
+ let lastIndex = 0;
370
+ let m: RegExpExecArray | null;
371
+
372
+ while ((m = tokenRe.exec(json)) !== null) {
373
+ // Escape text between tokens (brackets, commas, whitespace, colons)
374
+ if (m.index > lastIndex) {
375
+ result += escapeHtml(json.slice(lastIndex, m.index));
376
+ }
377
+ const [, key, str, num, bool, nil] = m;
378
+ if (key) result += `<span class="json-key">${escapeHtml(key)}</span>:`;
379
+ else if (str) result += `<span class="json-string">${escapeHtml(str)}</span>`;
380
+ else if (num) result += `<span class="json-number">${num}</span>`;
381
+ else if (bool) result += `<span class="json-boolean">${bool}</span>`;
382
+ else if (nil) result += `<span class="json-null">null</span>`;
383
+ lastIndex = tokenRe.lastIndex;
384
+ }
385
+ // Remaining text after last token
386
+ if (lastIndex < json.length) {
387
+ result += escapeHtml(json.slice(lastIndex));
388
+ }
389
+ return result;
390
+ }
391
+
392
+ function httpStatusText(code: number): string {
393
+ const map: Record<number, string> = {
394
+ 200: "OK", 201: "Created", 204: "No Content",
395
+ 301: "Moved Permanently", 302: "Found", 304: "Not Modified",
396
+ 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
397
+ 405: "Method Not Allowed", 409: "Conflict", 422: "Unprocessable Entity",
398
+ 429: "Too Many Requests",
399
+ 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable",
400
+ };
401
+ return map[code] ?? "";
402
+ }
@@ -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}`;