@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirrosh/zond",
3
- "version": "0.19.0",
3
+ "version": "0.21.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",
@@ -26,7 +26,7 @@
26
26
  "scripts": {
27
27
  "zond": "bun run src/cli/index.ts",
28
28
  "test": "bun run test:unit && bun run test:mocked",
29
- "test:unit": "bun test tests/db/ tests/parser/ tests/runner/ tests/generator/ tests/core/ tests/diagnostics/ tests/cli/args.test.ts tests/cli/ci-init.test.ts tests/cli/commands.test.ts tests/cli/safe-run.test.ts tests/cli/json-envelope.test.ts tests/cli/describe.test.ts tests/cli/db.test.ts tests/cli/request.test.ts tests/cli/init.test.ts tests/cli/guide.test.ts tests/integration/ tests/web/ tests/reporter/ tests/version-sync.test.ts",
29
+ "test:unit": "bun test tests/db/ tests/parser/ tests/runner/ tests/generator/ tests/core/ tests/diagnostics/ tests/cli/args.test.ts tests/cli/ci-init.test.ts tests/cli/commands.test.ts tests/cli/safe-run.test.ts tests/cli/json-envelope.test.ts tests/cli/describe.test.ts tests/cli/catalog.test.ts tests/cli/db.test.ts tests/cli/request.test.ts tests/cli/init.test.ts tests/cli/guide.test.ts tests/integration/ tests/web/ tests/reporter/ tests/version-sync.test.ts",
30
30
  "test:mocked": "bun run scripts/run-mocked-tests.ts",
31
31
  "check": "tsc --noEmit --project tsconfig.json",
32
32
  "build": "bun build --compile src/cli/index.ts --outfile zond",
@@ -0,0 +1,62 @@
1
+ import { join } from "path";
2
+ import { mkdir } from "fs/promises";
3
+ import { readOpenApiSpec, extractEndpoints, extractSecuritySchemes } from "../../core/generator/index.ts";
4
+ import { buildCatalog, serializeCatalog } from "../../core/generator/catalog-builder.ts";
5
+ import { decycleSchema } from "../../core/generator/schema-utils.ts";
6
+ import { hashSpec } from "../../core/meta/meta-store.ts";
7
+ import { printError, printSuccess } from "../output.ts";
8
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
9
+
10
+ export interface CatalogOptions {
11
+ specPath: string;
12
+ output?: string;
13
+ json?: boolean;
14
+ }
15
+
16
+ export async function catalogCommand(options: CatalogOptions): Promise<number> {
17
+ try {
18
+ const doc = await readOpenApiSpec(options.specPath);
19
+ const endpoints = extractEndpoints(doc);
20
+ const securitySchemes = extractSecuritySchemes(doc);
21
+ const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
22
+ const apiName = (doc as any).info?.title as string | undefined;
23
+ const apiVersion = (doc as any).info?.version as string | undefined;
24
+ const specContent = JSON.stringify(decycleSchema(doc));
25
+
26
+ const catalog = buildCatalog({
27
+ endpoints,
28
+ securitySchemes,
29
+ specSource: options.specPath,
30
+ specHash: hashSpec(specContent),
31
+ apiName,
32
+ apiVersion,
33
+ baseUrl,
34
+ });
35
+
36
+ const outputDir = options.output ?? ".";
37
+ await mkdir(outputDir, { recursive: true });
38
+
39
+ const catalogPath = join(outputDir, ".api-catalog.yaml");
40
+ await Bun.write(catalogPath, serializeCatalog(catalog));
41
+
42
+ if (options.json) {
43
+ printJson(jsonOk("catalog", {
44
+ path: catalogPath,
45
+ endpointCount: catalog.endpointCount,
46
+ apiName: catalog.apiName,
47
+ }));
48
+ } else {
49
+ printSuccess(`Generated API catalog: ${catalogPath} (${catalog.endpointCount} endpoints)`);
50
+ }
51
+
52
+ return 0;
53
+ } catch (err) {
54
+ const message = err instanceof Error ? err.message : String(err);
55
+ if (options.json) {
56
+ printJson(jsonError("catalog", [message]));
57
+ } else {
58
+ printError(message);
59
+ }
60
+ return 2;
61
+ }
62
+ }
@@ -7,6 +7,8 @@ import {
7
7
  scanCoveredEndpoints,
8
8
  filterUncoveredEndpoints,
9
9
  serializeSuite,
10
+ buildCatalog,
11
+ serializeCatalog,
10
12
  } from "../../core/generator/index.ts";
11
13
  import { generateSuites, findUnresolvedVars } from "../../core/generator/suite-generator.ts";
12
14
  import { filterByTag } from "../../core/generator/chunker.ts";
@@ -30,9 +32,12 @@ export interface GenerateOptions {
30
32
  export async function generateCommand(options: GenerateOptions): Promise<number> {
31
33
  try {
32
34
  const doc = await readOpenApiSpec(options.specPath);
33
- let endpoints = extractEndpoints(doc);
35
+ const allEndpoints = extractEndpoints(doc);
36
+ let endpoints = allEndpoints;
34
37
  const securitySchemes = extractSecuritySchemes(doc);
35
38
  const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
39
+ const apiName = (doc as any).info?.title as string | undefined;
40
+ const apiVersion = (doc as any).info?.version as string | undefined;
36
41
  const warnings: string[] = [];
37
42
 
38
43
  // Filter to uncovered only
@@ -92,6 +97,18 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
92
97
  files: { ...(existingMeta?.files ?? {}), ...metaFiles },
93
98
  });
94
99
 
100
+ // Generate .api-catalog.yaml (always uses full unfiltered endpoint list)
101
+ const catalog = buildCatalog({
102
+ endpoints: allEndpoints,
103
+ securitySchemes,
104
+ specSource: options.specPath,
105
+ specHash: hashSpec(specContent),
106
+ apiName,
107
+ apiVersion,
108
+ baseUrl,
109
+ });
110
+ await Bun.write(join(options.output, ".api-catalog.yaml"), serializeCatalog(catalog));
111
+
95
112
  // Sync DB collection spec reference if one is registered for this output directory
96
113
  try {
97
114
  getDb();
@@ -5,6 +5,8 @@ import {
5
5
  extractEndpoints,
6
6
  extractSecuritySchemes,
7
7
  serializeSuite,
8
+ buildCatalog,
9
+ serializeCatalog,
8
10
  } from "../../core/generator/index.ts";
9
11
  import { generateSuites } from "../../core/generator/suite-generator.ts";
10
12
  import { filterByTag } from "../../core/generator/chunker.ts";
@@ -78,6 +80,19 @@ export async function syncCommand(options: SyncOptions): Promise<number> {
78
80
  }
79
81
 
80
82
  if (newEndpoints.length === 0) {
83
+ // Update catalog even when no new endpoints — spec schema may have changed
84
+ const allEndpoints = extractEndpoints(doc);
85
+ const catalog = buildCatalog({
86
+ endpoints: allEndpoints,
87
+ securitySchemes,
88
+ specSource: options.specPath,
89
+ specHash: currentHash,
90
+ apiName: (doc as any).info?.title,
91
+ apiVersion: (doc as any).info?.version,
92
+ baseUrl: (doc as any).servers?.[0]?.url,
93
+ });
94
+ await Bun.write(join(options.testsDir, ".api-catalog.yaml"), serializeCatalog(catalog));
95
+
81
96
  const msg = "Spec changed (hash differs) but no new endpoints detected. Existing tests may need manual review.";
82
97
  warnings.push(msg);
83
98
  if (options.json) {
@@ -168,6 +183,19 @@ export async function syncCommand(options: SyncOptions): Promise<number> {
168
183
  files: { ...meta.files, ...updatedMetaFiles },
169
184
  });
170
185
 
186
+ // Update .api-catalog.yaml with current spec state
187
+ const allEndpoints = extractEndpoints(doc);
188
+ const catalog = buildCatalog({
189
+ endpoints: allEndpoints,
190
+ securitySchemes,
191
+ specSource: options.specPath,
192
+ specHash: currentHash,
193
+ apiName: (doc as any).info?.title,
194
+ apiVersion: (doc as any).info?.version,
195
+ baseUrl: (doc as any).servers?.[0]?.url,
196
+ });
197
+ await Bun.write(join(options.testsDir, ".api-catalog.yaml"), serializeCatalog(catalog));
198
+
171
199
  // Sync DB collection if one is registered for this tests directory
172
200
  try {
173
201
  getDb();
package/src/cli/index.ts CHANGED
@@ -14,6 +14,7 @@ import { generateCommand } from "./commands/generate.ts";
14
14
  import { exportCommand } from "./commands/export.ts";
15
15
  import { syncCommand } from "./commands/sync.ts";
16
16
  import { updateCommand } from "./commands/update.ts";
17
+ import { catalogCommand } from "./commands/catalog.ts";
17
18
  import { printError } from "./output.ts";
18
19
  import { getRuntimeInfo } from "./runtime.ts";
19
20
  import { getDb } from "../db/schema.ts";
@@ -107,6 +108,7 @@ Usage:
107
108
  zond ui Alias for 'serve --open' (start dashboard & open browser)
108
109
  zond ci init Generate CI/CD workflow (GitHub Actions, GitLab CI)
109
110
  zond export postman <path> Export YAML tests as Postman Collection v2.1
111
+ zond catalog <spec> Generate API catalog (compact endpoint reference)
110
112
  zond sync <spec> Detect new/removed endpoints and generate tests for new ones
111
113
  zond update Check for updates and self-update the binary
112
114
 
@@ -186,6 +188,9 @@ Options for 'export postman':
186
188
  --env <file> Also export .env.yaml as Postman environment
187
189
  --collection-name <name> Collection name (default: derived from path)
188
190
 
191
+ Options for 'catalog':
192
+ --output <dir> Output directory (default: current directory)
193
+
189
194
  Options for 'sync':
190
195
  --tests <dir> Path to test files directory (required)
191
196
  --dry-run Show what would be generated without writing files
@@ -521,6 +526,19 @@ async function main(): Promise<number> {
521
526
  });
522
527
  }
523
528
 
529
+ case "catalog": {
530
+ const specPath = positional[0];
531
+ if (!specPath) {
532
+ printError("Missing spec path. Usage: zond catalog <spec> [--output <dir>]");
533
+ return 2;
534
+ }
535
+ return catalogCommand({
536
+ specPath,
537
+ output: typeof flags["output"] === "string" ? flags["output"] : undefined,
538
+ json: jsonFlag,
539
+ });
540
+ }
541
+
524
542
  case "guide": {
525
543
  const specPath = positional[0];
526
544
  if (!specPath) {
@@ -0,0 +1,179 @@
1
+ import type { EndpointInfo, SecuritySchemeInfo } from "./types.ts";
2
+ import { compressSchema, formatParam, isAnySchema } from "./schema-utils.ts";
3
+
4
+ export interface CatalogEndpoint {
5
+ method: string;
6
+ path: string;
7
+ summary?: string;
8
+ tags: string[];
9
+ deprecated?: boolean;
10
+ parameters?: string[];
11
+ requestBody?: string;
12
+ responses: Array<{
13
+ status: number;
14
+ schema?: string;
15
+ }>;
16
+ }
17
+
18
+ export interface ApiCatalog {
19
+ generatedAt: string;
20
+ specSource: string;
21
+ specHash: string;
22
+ apiName?: string;
23
+ apiVersion?: string;
24
+ baseUrl?: string;
25
+ auth: string[];
26
+ endpointCount: number;
27
+ endpoints: CatalogEndpoint[];
28
+ }
29
+
30
+ export interface BuildCatalogParams {
31
+ endpoints: EndpointInfo[];
32
+ securitySchemes: SecuritySchemeInfo[];
33
+ specSource: string;
34
+ specHash: string;
35
+ apiName?: string;
36
+ apiVersion?: string;
37
+ baseUrl?: string;
38
+ }
39
+
40
+ function formatSecurityScheme(s: SecuritySchemeInfo): string {
41
+ let desc = `${s.name}: ${s.type}`;
42
+ if (s.scheme) desc += `/${s.scheme}`;
43
+ if (s.bearerFormat) desc += ` (${s.bearerFormat})`;
44
+ if (s.in && s.apiKeyName) desc += ` (${s.apiKeyName} in ${s.in})`;
45
+ return desc;
46
+ }
47
+
48
+ function formatParamWithLocation(p: import("openapi-types").OpenAPIV3.ParameterObject): string {
49
+ const base = formatParam(p);
50
+ // Insert location before (req) or at the end
51
+ const reqSuffix = " (req)";
52
+ if (base.endsWith(reqSuffix)) {
53
+ return `${base.slice(0, -reqSuffix.length)} (${p.in}, req)`;
54
+ }
55
+ return `${base} (${p.in})`;
56
+ }
57
+
58
+ function buildCatalogEndpoint(ep: EndpointInfo): CatalogEndpoint {
59
+ const result: CatalogEndpoint = {
60
+ method: ep.method.toUpperCase(),
61
+ path: ep.path,
62
+ tags: ep.tags,
63
+ responses: [],
64
+ };
65
+
66
+ if (ep.summary) result.summary = ep.summary;
67
+ if (ep.deprecated) result.deprecated = true;
68
+
69
+ if (ep.parameters.length > 0) {
70
+ result.parameters = ep.parameters.map(formatParamWithLocation);
71
+ }
72
+
73
+ if (ep.requestBodySchema) {
74
+ result.requestBody = isAnySchema(ep.requestBodySchema)
75
+ ? "any"
76
+ : compressSchema(ep.requestBodySchema);
77
+ }
78
+
79
+ for (const resp of ep.responses) {
80
+ const entry: { status: number; schema?: string } = { status: resp.statusCode };
81
+ if (resp.schema && !isAnySchema(resp.schema)) {
82
+ entry.schema = compressSchema(resp.schema);
83
+ }
84
+ result.responses.push(entry);
85
+ }
86
+
87
+ return result;
88
+ }
89
+
90
+ export function buildCatalog(params: BuildCatalogParams): ApiCatalog {
91
+ return {
92
+ generatedAt: new Date().toISOString(),
93
+ specSource: params.specSource,
94
+ specHash: params.specHash,
95
+ ...(params.apiName ? { apiName: params.apiName } : {}),
96
+ ...(params.apiVersion ? { apiVersion: params.apiVersion } : {}),
97
+ ...(params.baseUrl ? { baseUrl: params.baseUrl } : {}),
98
+ auth: params.securitySchemes.map(formatSecurityScheme),
99
+ endpointCount: params.endpoints.length,
100
+ endpoints: params.endpoints.map(buildCatalogEndpoint),
101
+ };
102
+ }
103
+
104
+ // --- YAML serialization ---
105
+
106
+ function yamlEscape(value: string): string {
107
+ if (/[:{}\[\],&*#?|<>=!%@`"']/.test(value) || value.includes("\n")) {
108
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
109
+ }
110
+ return value;
111
+ }
112
+
113
+ function yamlScalar(value: unknown): string {
114
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
115
+ return yamlEscape(String(value));
116
+ }
117
+
118
+ export function serializeCatalog(catalog: ApiCatalog): string {
119
+ const lines: string[] = [];
120
+ lines.push("# Auto-generated by zond. Regenerate with: zond catalog <spec>");
121
+ lines.push(`generatedAt: ${yamlScalar(catalog.generatedAt)}`);
122
+ lines.push(`specSource: ${yamlScalar(catalog.specSource)}`);
123
+ lines.push(`specHash: ${yamlScalar(catalog.specHash)}`);
124
+ if (catalog.apiName) lines.push(`apiName: ${yamlScalar(catalog.apiName)}`);
125
+ if (catalog.apiVersion) lines.push(`apiVersion: ${yamlScalar(catalog.apiVersion)}`);
126
+ if (catalog.baseUrl) lines.push(`baseUrl: ${yamlScalar(catalog.baseUrl)}`);
127
+ lines.push(`endpointCount: ${catalog.endpointCount}`);
128
+
129
+ // Auth
130
+ if (catalog.auth.length > 0) {
131
+ lines.push("auth:");
132
+ for (const a of catalog.auth) {
133
+ lines.push(` - ${yamlScalar(a)}`);
134
+ }
135
+ } else {
136
+ lines.push("auth: []");
137
+ }
138
+
139
+ // Endpoints
140
+ if (catalog.endpoints.length === 0) {
141
+ lines.push("endpoints: []");
142
+ return lines.join("\n") + "\n";
143
+ }
144
+ lines.push("endpoints:");
145
+ for (const ep of catalog.endpoints) {
146
+ lines.push(` - method: ${ep.method}`);
147
+ lines.push(` path: ${yamlScalar(ep.path)}`);
148
+ if (ep.summary) lines.push(` summary: ${yamlScalar(ep.summary)}`);
149
+ if (ep.tags.length > 0) {
150
+ lines.push(` tags: [${ep.tags.map(yamlScalar).join(", ")}]`);
151
+ }
152
+ if (ep.deprecated) lines.push(` deprecated: true`);
153
+
154
+ if (ep.parameters && ep.parameters.length > 0) {
155
+ lines.push(` parameters:`);
156
+ for (const p of ep.parameters) {
157
+ lines.push(` - ${yamlScalar(p)}`);
158
+ }
159
+ }
160
+
161
+ if (ep.requestBody) {
162
+ lines.push(` requestBody: ${yamlScalar(ep.requestBody)}`);
163
+ }
164
+
165
+ if (ep.responses.length > 0) {
166
+ lines.push(` responses:`);
167
+ for (const r of ep.responses) {
168
+ if (r.schema) {
169
+ lines.push(` - status: ${r.status}`);
170
+ lines.push(` schema: ${yamlScalar(r.schema)}`);
171
+ } else {
172
+ lines.push(` - status: ${r.status}`);
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ return lines.join("\n") + "\n";
179
+ }
@@ -10,3 +10,5 @@ export type { GuideOptions } from "./guide-builder.ts";
10
10
  export type { EndpointWarning, WarningCode } from "./endpoint-warnings.ts";
11
11
  export type { EndpointInfo, ResponseInfo, GenerateOptions, SecuritySchemeInfo, CrudGroup } from "./types.ts";
12
12
  export { generateSuites, generateStep, detectCrudGroups, generateCrudSuite, generateSanitySuite, findUnresolvedVars } from "./suite-generator.ts";
13
+ export { buildCatalog, serializeCatalog } from "./catalog-builder.ts";
14
+ export type { ApiCatalog, CatalogEndpoint } from "./catalog-builder.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,
@@ -12,6 +12,7 @@ import {
12
12
  RunDetailSchema,
13
13
  RunIdParam,
14
14
  } from "../schemas.ts";
15
+ import { renderProxyResponse, renderProxyError } from "../views/explorer-tab.ts";
15
16
 
16
17
  const api = new OpenAPIHono();
17
18
 
@@ -114,6 +115,85 @@ api.openapi(runRoute, async (c) => {
114
115
  }
115
116
  });
116
117
 
118
+ // ──────────────────────────────────────────────
119
+ // POST /api/proxy — Explorer proxy for HTTP requests
120
+ // ──────────────────────────────────────────────
121
+
122
+ api.post("/api/proxy", async (c) => {
123
+ const form = await c.req.parseBody();
124
+ const baseUrl = (form["base_url"] as string) ?? "";
125
+ const method = ((form["method"] as string) ?? "GET").toUpperCase();
126
+ let path = (form["path"] as string) ?? "/";
127
+ const body = (form["body"] as string) || undefined;
128
+ const contentType = (form["content_type"] as string) || undefined;
129
+
130
+ if (!baseUrl) {
131
+ return c.html(renderProxyError("Base URL is required", 0));
132
+ }
133
+
134
+ // Substitute path parameters
135
+ for (const [key, value] of Object.entries(form)) {
136
+ if (typeof key === "string" && key.startsWith("param_path_") && value) {
137
+ const paramName = key.slice("param_path_".length);
138
+ path = path.replace(`{${paramName}}`, encodeURIComponent(value as string));
139
+ }
140
+ }
141
+
142
+ // Build query string
143
+ const queryParams = new URLSearchParams();
144
+ for (const [key, value] of Object.entries(form)) {
145
+ if (typeof key === "string" && key.startsWith("param_query_") && value) {
146
+ queryParams.set(key.slice("param_query_".length), value as string);
147
+ }
148
+ }
149
+
150
+ // Build headers
151
+ const headers: Record<string, string> = {};
152
+ for (const [key, value] of Object.entries(form)) {
153
+ if (typeof key === "string" && key.startsWith("param_header_") && value) {
154
+ headers[key.slice("param_header_".length)] = value as string;
155
+ }
156
+ }
157
+ // Custom headers
158
+ for (let i = 0; i < 50; i++) {
159
+ const k = form[`custom_header_key_${i}`] as string | undefined;
160
+ const v = form[`custom_header_value_${i}`] as string | undefined;
161
+ if (!k && !v) break;
162
+ if (k && v) headers[k] = v;
163
+ }
164
+
165
+ if (contentType && body) {
166
+ headers["Content-Type"] = contentType;
167
+ }
168
+
169
+ // Build URL
170
+ let url: URL;
171
+ try {
172
+ url = new URL(path, baseUrl.endsWith("/") ? baseUrl : baseUrl + "/");
173
+ queryParams.forEach((v, k) => url.searchParams.set(k, v));
174
+ } catch (err) {
175
+ return c.html(renderProxyError(`Invalid URL: ${baseUrl}${path} — ${(err as Error).message}`, 0));
176
+ }
177
+
178
+ const startTime = performance.now();
179
+ try {
180
+ const resp = await fetch(url.toString(), {
181
+ method,
182
+ headers,
183
+ body: ["GET", "HEAD"].includes(method) ? undefined : (body || undefined),
184
+ });
185
+ const elapsed = Math.round(performance.now() - startTime);
186
+ const respBody = await resp.text();
187
+ const respHeaders: Record<string, string> = {};
188
+ resp.headers.forEach((v, k) => { respHeaders[k] = v; });
189
+
190
+ return c.html(renderProxyResponse(resp.status, respHeaders, respBody, elapsed));
191
+ } catch (err) {
192
+ const elapsed = Math.round(performance.now() - startTime);
193
+ return c.html(renderProxyError((err as Error).message, elapsed));
194
+ }
195
+ });
196
+
117
197
  // ──────────────────────────────────────────────
118
198
  // Export helpers
119
199
  // ──────────────────────────────────────────────
@@ -5,6 +5,7 @@ import { renderHealthStrip } from "../views/health-strip.ts";
5
5
  import { renderEndpointsTab } from "../views/endpoints-tab.ts";
6
6
  import { renderSuitesTab } from "../views/suites-tab.ts";
7
7
  import { renderRunsTab, renderRunDetail } from "../views/runs-tab.ts";
8
+ import { renderExplorerTab } from "../views/explorer-tab.ts";
8
9
  import { buildCollectionState, invalidateCollectionCache } from "../data/collection-state.ts";
9
10
  import {
10
11
  listCollections,
@@ -81,6 +82,16 @@ dashboard.get("/panels/endpoints", async (c) => {
81
82
  return c.html(renderEndpointsTab(state, filters));
82
83
  });
83
84
 
85
+ dashboard.get("/panels/explorer", async (c) => {
86
+ const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
87
+ if (isNaN(collectionId)) return c.html("");
88
+
89
+ const collection = getCollectionById(collectionId);
90
+ if (!collection) return c.html("");
91
+
92
+ return c.html(await renderExplorerTab(collection));
93
+ });
94
+
84
95
  dashboard.get("/panels/suites", async (c) => {
85
96
  const collectionId = parseInt(c.req.query("collection_id") ?? "", 10);
86
97
  if (isNaN(collectionId)) return c.html("");
@@ -243,6 +254,10 @@ async function renderCollectionContent(collection: CollectionRecord): Promise<st
243
254
  hx-get="/panels/runs-tab?collection_id=${collection.id}"
244
255
  hx-target="#tab-content" hx-swap="innerHTML"
245
256
  onclick="activateTab(this)">Runs <span class="tab-count">${runCount}</span></button>
257
+ <button class="tab-btn" data-tab="explorer"
258
+ hx-get="/panels/explorer?collection_id=${collection.id}"
259
+ hx-target="#tab-content" hx-swap="innerHTML"
260
+ onclick="activateTab(this)">Explorer</button>
246
261
  </div>`;
247
262
 
248
263
  // Default tab content (endpoints)
@@ -253,6 +268,28 @@ async function renderCollectionContent(collection: CollectionRecord): Promise<st
253
268
  document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('tab-active'));
254
269
  el.classList.add('tab-active');
255
270
  }
271
+ function switchToSuite(suiteName) {
272
+ var suitesBtn = document.querySelector('[data-tab="suites"]');
273
+ if (!suitesBtn) return;
274
+ suitesBtn.click();
275
+ document.addEventListener('htmx:afterSwap', function handler(e) {
276
+ if (e.detail.target && e.detail.target.id === 'tab-content') {
277
+ document.removeEventListener('htmx:afterSwap', handler);
278
+ setTimeout(function() {
279
+ var rows = document.querySelectorAll('.suite-row[data-suite-name]');
280
+ for (var i = 0; i < rows.length; i++) {
281
+ if (rows[i].dataset.suiteName === suiteName) {
282
+ rows[i].scrollIntoView({ behavior: 'smooth', block: 'center' });
283
+ rows[i].click();
284
+ rows[i].classList.add('suite-highlight');
285
+ setTimeout(function() { rows[i].classList.remove('suite-highlight'); }, 2000);
286
+ break;
287
+ }
288
+ }
289
+ }, 50);
290
+ }
291
+ });
292
+ }
256
293
  </script>`;
257
294
 
258
295
  return `