@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,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { setupApi } from "../../core/setup-api.ts";
|
|
6
|
+
import { resetDb } from "../../db/schema.ts";
|
|
7
|
+
import { TOOL_DESCRIPTIONS } from "../descriptions.js";
|
|
8
|
+
|
|
9
|
+
function findProjectRoot(fromPath: string): string | null {
|
|
10
|
+
let current = existsSync(fromPath) ? fromPath : dirname(fromPath);
|
|
11
|
+
while (true) {
|
|
12
|
+
if (existsSync(join(current, ".git")) || existsSync(join(current, "package.json"))) {
|
|
13
|
+
return current;
|
|
14
|
+
}
|
|
15
|
+
const parent = dirname(current);
|
|
16
|
+
if (parent === current) return null;
|
|
17
|
+
current = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registerSetupApiTool(server: McpServer, dbPath?: string) {
|
|
22
|
+
server.registerTool("setup_api", {
|
|
23
|
+
description: TOOL_DESCRIPTIONS.setup_api,
|
|
24
|
+
inputSchema: {
|
|
25
|
+
name: z.string().describe("API name (e.g. 'petstore')"),
|
|
26
|
+
specPath: z.optional(z.string()).describe("Path or URL to OpenAPI spec"),
|
|
27
|
+
dir: z.optional(z.string()).describe("Base directory (default: ./apis/<name>/)"),
|
|
28
|
+
envVars: z.optional(z.string()).describe("Environment variables as JSON string (e.g. '{\"base_url\": \"...\", \"token\": \"...\"}')"),
|
|
29
|
+
force: z.optional(z.boolean()).describe("If true, delete existing API with same name and recreate from scratch"),
|
|
30
|
+
},
|
|
31
|
+
}, async ({ name, specPath, dir, envVars, force }) => {
|
|
32
|
+
try {
|
|
33
|
+
let parsedEnvVars: Record<string, string> | undefined;
|
|
34
|
+
if (envVars) {
|
|
35
|
+
try {
|
|
36
|
+
parsedEnvVars = JSON.parse(envVars);
|
|
37
|
+
} catch {
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: "text" as const, text: JSON.stringify({ error: "envVars must be a valid JSON string" }, null, 2) }],
|
|
40
|
+
isError: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Auto-chdir to project root when dir is an absolute path
|
|
46
|
+
if (dir && isAbsolute(resolve(dir))) {
|
|
47
|
+
const resolvedDir = resolve(dir);
|
|
48
|
+
const root = findProjectRoot(resolvedDir);
|
|
49
|
+
if (root && root !== process.cwd()) {
|
|
50
|
+
process.chdir(root);
|
|
51
|
+
resetDb();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const result = await setupApi({
|
|
56
|
+
name,
|
|
57
|
+
spec: specPath,
|
|
58
|
+
dir,
|
|
59
|
+
envVars: parsedEnvVars,
|
|
60
|
+
dbPath,
|
|
61
|
+
force,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const envFilePath = join(result.baseDir, ".env.yaml");
|
|
65
|
+
const response = {
|
|
66
|
+
...result,
|
|
67
|
+
nextSteps: [
|
|
68
|
+
`Edit ${envFilePath} to add credentials (auth_token, api_key, base_url, etc.)`,
|
|
69
|
+
`File is already git-ignored via .gitignore`,
|
|
70
|
+
`Then run: run_tests(testPath: "${result.testPath}")`,
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }],
|
|
76
|
+
};
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: "text" as const, text: JSON.stringify({ error: (err as Error).message }, null, 2) }],
|
|
80
|
+
isError: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { parse } from "../../core/parser/yaml-parser.ts";
|
|
4
|
+
import { TOOL_DESCRIPTIONS } from "../descriptions.js";
|
|
5
|
+
|
|
6
|
+
export function registerValidateTestsTool(server: McpServer) {
|
|
7
|
+
server.registerTool("validate_tests", {
|
|
8
|
+
description: TOOL_DESCRIPTIONS.validate_tests,
|
|
9
|
+
inputSchema: {
|
|
10
|
+
testPath: z.string().describe("Path to test YAML file or directory"),
|
|
11
|
+
},
|
|
12
|
+
}, async ({ testPath }) => {
|
|
13
|
+
try {
|
|
14
|
+
const suites = await parse(testPath);
|
|
15
|
+
const totalSteps = suites.reduce((sum, s) => sum + s.tests.length, 0);
|
|
16
|
+
|
|
17
|
+
const result = {
|
|
18
|
+
valid: true,
|
|
19
|
+
suites: suites.length,
|
|
20
|
+
tests: totalSteps,
|
|
21
|
+
details: suites.map(s => ({
|
|
22
|
+
name: s.name,
|
|
23
|
+
tests: s.tests.length,
|
|
24
|
+
tags: s.tags,
|
|
25
|
+
description: s.description,
|
|
26
|
+
source: (s as any)._source,
|
|
27
|
+
})),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
32
|
+
};
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return {
|
|
35
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
36
|
+
valid: false,
|
|
37
|
+
error: (err as Error).message,
|
|
38
|
+
}, null, 2) }],
|
|
39
|
+
isError: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createInterface } from "readline";
|
|
2
|
+
import { runAgentTurn } from "../core/agent/agent-loop.ts";
|
|
3
|
+
import { trimContext } from "../core/agent/context-manager.ts";
|
|
4
|
+
import type { AgentConfig, ToolEvent } from "../core/agent/types.ts";
|
|
5
|
+
import type { ModelMessage } from "ai";
|
|
6
|
+
|
|
7
|
+
// ── ANSI helpers ──
|
|
8
|
+
const RESET = "\x1b[0m";
|
|
9
|
+
const BOLD = "\x1b[1m";
|
|
10
|
+
const DIM = "\x1b[2m";
|
|
11
|
+
const CYAN = "\x1b[36m";
|
|
12
|
+
const GREEN = "\x1b[32m";
|
|
13
|
+
const YELLOW = "\x1b[33m";
|
|
14
|
+
const MAGENTA = "\x1b[35m";
|
|
15
|
+
|
|
16
|
+
// Mouse reporting escape sequences
|
|
17
|
+
const DISABLE_MOUSE = "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l";
|
|
18
|
+
// Regex to strip mouse escape sequences from input
|
|
19
|
+
// Covers SGR extended, X10, urxvt mouse protocols
|
|
20
|
+
const MOUSE_SEQ_RE = /\x1b\[\<[\d;]*[mM]|\x1b\[M[\s\S]{3}|\x1b\[\d+;\d+;\d+M/g;
|
|
21
|
+
|
|
22
|
+
function printToolEvent(event: ToolEvent) {
|
|
23
|
+
const args = Object.entries(event.args)
|
|
24
|
+
.filter(([, v]) => v !== undefined)
|
|
25
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
26
|
+
.slice(0, 3)
|
|
27
|
+
.join(", ");
|
|
28
|
+
|
|
29
|
+
const result = event.result as Record<string, unknown> | null;
|
|
30
|
+
let summary = "done";
|
|
31
|
+
if (result) {
|
|
32
|
+
if ("error" in result) summary = `error: ${result.error}`;
|
|
33
|
+
else if ("status" in result) summary = String(result.status);
|
|
34
|
+
else if ("valid" in result) summary = result.valid ? "valid" : "invalid";
|
|
35
|
+
else if ("runId" in result) summary = `run #${result.runId}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
process.stdout.write(` ${MAGENTA}↳ ${event.toolName}${RESET}${DIM}(${args})${RESET} → ${summary}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function startChatUI(config: AgentConfig): Promise<void> {
|
|
42
|
+
const { provider } = config.provider;
|
|
43
|
+
const model = config.provider.model;
|
|
44
|
+
const safeLabel = config.safeMode ? ` ${YELLOW}[SAFE]${RESET}` : "";
|
|
45
|
+
|
|
46
|
+
// Disable mouse reporting that may be left over from other TUI apps
|
|
47
|
+
process.stdout.write(DISABLE_MOUSE);
|
|
48
|
+
|
|
49
|
+
console.log(`\n${BOLD}${CYAN}zond chat${RESET} — ${provider}/${model}${safeLabel}`);
|
|
50
|
+
console.log(`${DIM}Commands: /clear, /tokens, /quit | Ctrl+C to exit${RESET}\n`);
|
|
51
|
+
|
|
52
|
+
const messages: ModelMessage[] = [];
|
|
53
|
+
let totalIn = 0;
|
|
54
|
+
let totalOut = 0;
|
|
55
|
+
let busy = false;
|
|
56
|
+
|
|
57
|
+
const rl = createInterface({
|
|
58
|
+
input: process.stdin,
|
|
59
|
+
output: process.stdout,
|
|
60
|
+
prompt: `${BOLD}${CYAN}> ${RESET}`,
|
|
61
|
+
terminal: true,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
rl.prompt();
|
|
65
|
+
|
|
66
|
+
rl.on("line", async (raw: string) => {
|
|
67
|
+
// Strip mouse escape sequences that leak into input
|
|
68
|
+
const text = raw.replace(MOUSE_SEQ_RE, "").trim();
|
|
69
|
+
|
|
70
|
+
if (!text || busy) {
|
|
71
|
+
if (!busy) rl.prompt();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (text === "/quit" || text === "/exit") {
|
|
76
|
+
rl.close();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (text === "/clear") {
|
|
81
|
+
messages.length = 0;
|
|
82
|
+
totalIn = 0;
|
|
83
|
+
totalOut = 0;
|
|
84
|
+
console.log(`${DIM}Conversation cleared.${RESET}\n`);
|
|
85
|
+
rl.prompt();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (text === "/tokens") {
|
|
90
|
+
console.log(`${DIM}Tokens: ${totalIn} in / ${totalOut} out${RESET}\n`);
|
|
91
|
+
rl.prompt();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
busy = true;
|
|
96
|
+
messages.push({ role: "user", content: text });
|
|
97
|
+
|
|
98
|
+
// Trim context if conversation is long
|
|
99
|
+
const trimmed = trimContext(
|
|
100
|
+
messages.map((m) => ({
|
|
101
|
+
role: m.role as "user" | "assistant",
|
|
102
|
+
content: typeof m.content === "string" ? m.content : "",
|
|
103
|
+
})),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
process.stdout.write(`\n${DIM}thinking...${RESET}`);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const result = await runAgentTurn(
|
|
110
|
+
trimmed.map((m) => ({ role: m.role, content: m.content })),
|
|
111
|
+
config,
|
|
112
|
+
(event) => {
|
|
113
|
+
process.stdout.write(`\r\x1b[K`);
|
|
114
|
+
printToolEvent(event);
|
|
115
|
+
process.stdout.write(`${DIM}thinking...${RESET}`);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
process.stdout.write(`\r\x1b[K`);
|
|
120
|
+
|
|
121
|
+
const responseText = result.text || "(no response)";
|
|
122
|
+
console.log(`\n${GREEN}${BOLD}AI${RESET} ${responseText}\n`);
|
|
123
|
+
|
|
124
|
+
totalIn += result.inputTokens;
|
|
125
|
+
totalOut += result.outputTokens;
|
|
126
|
+
messages.push({ role: "assistant", content: responseText });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
process.stdout.write(`\r\x1b[K`);
|
|
129
|
+
console.log(`\n${YELLOW}Error: ${(err as Error).message}${RESET}\n`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
busy = false;
|
|
133
|
+
rl.prompt();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
rl.on("close", () => {
|
|
137
|
+
// Disable mouse reporting on exit too
|
|
138
|
+
process.stdout.write(DISABLE_MOUSE);
|
|
139
|
+
console.log(`\n${DIM}Bye! (${totalIn} in / ${totalOut} out tokens)${RESET}`);
|
|
140
|
+
process.exit(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Also disable mouse on SIGINT
|
|
144
|
+
process.on("SIGINT", () => {
|
|
145
|
+
process.stdout.write(DISABLE_MOUSE);
|
|
146
|
+
rl.close();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await new Promise<void>(() => {});
|
|
150
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified collection state builder for Web UI.
|
|
3
|
+
* Aggregates spec endpoints, disk suites, coverage, run results, warnings, env diagnostics.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CollectionRecord, RunRecord, StoredStepResult } from "../../db/queries.ts";
|
|
7
|
+
import { listRunsByCollection, getResultsByRunId, getRunById } from "../../db/queries.ts";
|
|
8
|
+
import type { EndpointWarning } from "../../core/generator/endpoint-warnings.ts";
|
|
9
|
+
import { envHint, statusHint, classifyFailure, computeSharedEnvIssue } from "../../core/diagnostics/failure-hints.ts";
|
|
10
|
+
import { join, basename } from "node:path";
|
|
11
|
+
|
|
12
|
+
// ── Types ──
|
|
13
|
+
|
|
14
|
+
export interface CoveringStep {
|
|
15
|
+
suiteName: string;
|
|
16
|
+
file: string; // relative filename (e.g. "auth-login.yaml")
|
|
17
|
+
stepName: string;
|
|
18
|
+
status: "pass" | "fail" | "error" | "skip" | null; // null = not run
|
|
19
|
+
responseStatus?: number;
|
|
20
|
+
durationMs?: number;
|
|
21
|
+
hint?: string; // failure hint
|
|
22
|
+
assertions?: { field: string; rule: string; passed: boolean; actual?: unknown; expected?: unknown }[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EndpointViewState {
|
|
26
|
+
method: string;
|
|
27
|
+
path: string;
|
|
28
|
+
summary?: string;
|
|
29
|
+
deprecated: boolean;
|
|
30
|
+
hasCoverage: boolean;
|
|
31
|
+
runStatus: "passing" | "api_error" | "test_failed" | "not_run" | "no_tests";
|
|
32
|
+
warnings: string[];
|
|
33
|
+
coveringFiles: string[];
|
|
34
|
+
coveringSteps: CoveringStep[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StepViewState {
|
|
38
|
+
name: string;
|
|
39
|
+
status: "pass" | "fail" | "error" | "skip";
|
|
40
|
+
durationMs?: number;
|
|
41
|
+
requestMethod?: string;
|
|
42
|
+
requestUrl?: string;
|
|
43
|
+
responseStatus?: number;
|
|
44
|
+
responseBody?: string;
|
|
45
|
+
assertions?: { field: string; rule: string; passed: boolean; actual?: unknown; expected?: unknown }[];
|
|
46
|
+
captures?: Record<string, unknown>;
|
|
47
|
+
hint?: string;
|
|
48
|
+
errorMessage?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SuiteViewState {
|
|
52
|
+
name: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
tags: string[];
|
|
55
|
+
stepCount: number;
|
|
56
|
+
filePath: string;
|
|
57
|
+
status: "passed" | "failed" | "not_run" | "parse_error";
|
|
58
|
+
runResult?: { passed: number; failed: number; skipped: number };
|
|
59
|
+
parseError?: string;
|
|
60
|
+
steps: StepViewState[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CollectionState {
|
|
64
|
+
collection: CollectionRecord;
|
|
65
|
+
endpoints: EndpointViewState[];
|
|
66
|
+
totalEndpoints: number;
|
|
67
|
+
coveragePct: number;
|
|
68
|
+
coveredCount: number;
|
|
69
|
+
suites: SuiteViewState[];
|
|
70
|
+
latestRun: RunRecord | null;
|
|
71
|
+
latestRunResults: StoredStepResult[];
|
|
72
|
+
envAlert: string | null;
|
|
73
|
+
warnings: EndpointWarning[];
|
|
74
|
+
// Run stats
|
|
75
|
+
runPassed: number;
|
|
76
|
+
runFailed: number;
|
|
77
|
+
runSkipped: number;
|
|
78
|
+
runTotal: number;
|
|
79
|
+
runDurationMs: number | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Cache ──
|
|
83
|
+
|
|
84
|
+
interface CacheEntry {
|
|
85
|
+
state: CollectionState;
|
|
86
|
+
timestamp: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const cache = new Map<number, CacheEntry>();
|
|
90
|
+
const CACHE_TTL_MS = 30_000;
|
|
91
|
+
|
|
92
|
+
export function invalidateCollectionCache(collectionId: number): void {
|
|
93
|
+
cache.delete(collectionId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Builder ──
|
|
97
|
+
|
|
98
|
+
export async function buildCollectionState(collection: CollectionRecord): Promise<CollectionState> {
|
|
99
|
+
const cached = cache.get(collection.id);
|
|
100
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
101
|
+
return cached.state;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Load spec endpoints
|
|
105
|
+
let specEndpoints: import("../../core/generator/types.ts").EndpointInfo[] = [];
|
|
106
|
+
let warnings: EndpointWarning[] = [];
|
|
107
|
+
if (collection.openapi_spec) {
|
|
108
|
+
try {
|
|
109
|
+
const { readOpenApiSpec, extractEndpoints } = await import("../../core/generator/openapi-reader.ts");
|
|
110
|
+
const { analyzeEndpoints } = await import("../../core/generator/endpoint-warnings.ts");
|
|
111
|
+
const doc = await readOpenApiSpec(collection.openapi_spec);
|
|
112
|
+
specEndpoints = extractEndpoints(doc);
|
|
113
|
+
warnings = analyzeEndpoints(specEndpoints);
|
|
114
|
+
} catch { /* spec unavailable */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Scan coverage from disk
|
|
118
|
+
const { scanCoveredEndpoints } = await import("../../core/generator/coverage-scanner.ts");
|
|
119
|
+
const { specPathToRegex, normalizePath } = await import("../../core/generator/coverage-scanner.ts");
|
|
120
|
+
let coveredEndpoints: import("../../core/generator/coverage-scanner.ts").CoveredEndpoint[] = [];
|
|
121
|
+
try {
|
|
122
|
+
coveredEndpoints = await scanCoveredEndpoints(collection.test_path);
|
|
123
|
+
} catch { /* no tests on disk */ }
|
|
124
|
+
|
|
125
|
+
// Parse suites from disk
|
|
126
|
+
const { parseDirectorySafe } = await import("../../core/parser/yaml-parser.ts");
|
|
127
|
+
let diskSuites: import("../../core/parser/yaml-parser.ts").ParseDirectoryResult = { suites: [], errors: [] };
|
|
128
|
+
try {
|
|
129
|
+
diskSuites = await parseDirectorySafe(collection.test_path);
|
|
130
|
+
} catch { /* test dir missing */ }
|
|
131
|
+
|
|
132
|
+
// Get latest run
|
|
133
|
+
const runs = listRunsByCollection(collection.id, 1, 0);
|
|
134
|
+
const latestRun = runs.length > 0 ? (getRunById(runs[0]!.id) ?? null) : null;
|
|
135
|
+
const latestRunResults = latestRun ? getResultsByRunId(latestRun.id) : [];
|
|
136
|
+
|
|
137
|
+
// Build result maps: suite_name -> step statuses
|
|
138
|
+
const suiteResultMap = new Map<string, StoredStepResult[]>();
|
|
139
|
+
for (const r of latestRunResults) {
|
|
140
|
+
const list = suiteResultMap.get(r.suite_name) ?? [];
|
|
141
|
+
list.push(r);
|
|
142
|
+
suiteResultMap.set(r.suite_name, list);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Build endpoint -> run status map
|
|
146
|
+
// key: "METHOD /path" from results
|
|
147
|
+
const endpointRunStatusMap = new Map<string, "passing" | "api_error" | "test_failed">();
|
|
148
|
+
for (const r of latestRunResults) {
|
|
149
|
+
if (r.request_method && r.request_url) {
|
|
150
|
+
// Extract path from URL
|
|
151
|
+
let urlPath: string;
|
|
152
|
+
try {
|
|
153
|
+
const u = new URL(r.request_url);
|
|
154
|
+
urlPath = u.pathname;
|
|
155
|
+
} catch {
|
|
156
|
+
urlPath = r.request_url;
|
|
157
|
+
}
|
|
158
|
+
const key = `${r.request_method} ${normalizePath(urlPath)}`;
|
|
159
|
+
const current = endpointRunStatusMap.get(key);
|
|
160
|
+
if (r.status === "fail" || r.status === "error") {
|
|
161
|
+
const ft = classifyFailure(r.status, r.response_status);
|
|
162
|
+
endpointRunStatusMap.set(key, ft === "api_error" ? "api_error" : "test_failed");
|
|
163
|
+
} else if (r.status === "pass" && current !== "test_failed" && current !== "api_error") {
|
|
164
|
+
endpointRunStatusMap.set(key, "passing");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Build endpoint view states
|
|
170
|
+
const warningsMap = new Map<string, string[]>();
|
|
171
|
+
for (const w of warnings) {
|
|
172
|
+
warningsMap.set(`${w.method} ${w.path}`, w.warnings);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Build map: normalized "METHOD /path" -> list of StoredStepResult
|
|
176
|
+
const resultsByEndpoint = new Map<string, StoredStepResult[]>();
|
|
177
|
+
for (const r of latestRunResults) {
|
|
178
|
+
if (r.request_method && r.request_url) {
|
|
179
|
+
let urlPath: string;
|
|
180
|
+
try { urlPath = new URL(r.request_url).pathname; } catch { urlPath = r.request_url; }
|
|
181
|
+
const key = `${r.request_method} ${normalizePath(urlPath)}`;
|
|
182
|
+
const list = resultsByEndpoint.get(key) ?? [];
|
|
183
|
+
list.push(r);
|
|
184
|
+
resultsByEndpoint.set(key, list);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Map suite name -> file basename for display
|
|
189
|
+
const suiteNameToFile = new Map<string, string>();
|
|
190
|
+
for (const s of diskSuites.suites) {
|
|
191
|
+
suiteNameToFile.set(s.name, basename(s.filePath ?? s.name));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Env file path for hints
|
|
195
|
+
const envFilePath = collection.base_dir
|
|
196
|
+
? join(collection.base_dir, ".env.yaml").replace(/\\/g, "/")
|
|
197
|
+
: undefined;
|
|
198
|
+
|
|
199
|
+
const endpoints: EndpointViewState[] = specEndpoints.map(ep => {
|
|
200
|
+
const specRegex = specPathToRegex(ep.path);
|
|
201
|
+
const covering = coveredEndpoints.filter(
|
|
202
|
+
c => c.method === ep.method && specRegex.test(normalizePath(c.path)),
|
|
203
|
+
);
|
|
204
|
+
const hasCoverage = covering.length > 0;
|
|
205
|
+
|
|
206
|
+
// Determine run status
|
|
207
|
+
let runStatus: EndpointViewState["runStatus"] = "no_tests";
|
|
208
|
+
if (hasCoverage) {
|
|
209
|
+
runStatus = "not_run";
|
|
210
|
+
for (const [key, status] of endpointRunStatusMap) {
|
|
211
|
+
const [method, path] = [key.split(" ")[0], key.split(" ").slice(1).join(" ")];
|
|
212
|
+
if (method === ep.method && specRegex.test(path!)) {
|
|
213
|
+
runStatus = status;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Build covering steps from run results
|
|
220
|
+
const coveringSteps: CoveringStep[] = [];
|
|
221
|
+
for (const [key, results] of resultsByEndpoint) {
|
|
222
|
+
const [method, path] = [key.split(" ")[0], key.split(" ").slice(1).join(" ")];
|
|
223
|
+
if (method === ep.method && specRegex.test(path!)) {
|
|
224
|
+
for (const r of results) {
|
|
225
|
+
const hint = (r.status === "fail" || r.status === "error")
|
|
226
|
+
? (envHint(r.request_url, r.error_message, envFilePath) ?? statusHint(r.response_status) ?? undefined)
|
|
227
|
+
: undefined;
|
|
228
|
+
coveringSteps.push({
|
|
229
|
+
suiteName: r.suite_name,
|
|
230
|
+
file: suiteNameToFile.get(r.suite_name) ?? r.suite_name,
|
|
231
|
+
stepName: r.test_name,
|
|
232
|
+
status: r.status as CoveringStep["status"],
|
|
233
|
+
responseStatus: r.response_status ?? undefined,
|
|
234
|
+
durationMs: r.duration_ms ?? undefined,
|
|
235
|
+
hint,
|
|
236
|
+
assertions: Array.isArray(r.assertions) ? r.assertions : undefined,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
method: ep.method,
|
|
244
|
+
path: ep.path,
|
|
245
|
+
summary: ep.summary,
|
|
246
|
+
deprecated: ep.deprecated ?? false,
|
|
247
|
+
hasCoverage,
|
|
248
|
+
runStatus,
|
|
249
|
+
warnings: warningsMap.get(`${ep.method} ${ep.path}`) ?? [],
|
|
250
|
+
coveringFiles: covering.map(c => c.file),
|
|
251
|
+
coveringSteps,
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Coverage stats
|
|
256
|
+
const totalEndpoints = endpoints.length;
|
|
257
|
+
const coveredCount = endpoints.filter(e => e.hasCoverage).length;
|
|
258
|
+
const coveragePct = totalEndpoints > 0 ? Math.round((coveredCount / totalEndpoints) * 100) : 0;
|
|
259
|
+
|
|
260
|
+
// Build suite view states
|
|
261
|
+
const suites: SuiteViewState[] = diskSuites.suites.map(s => {
|
|
262
|
+
const results = suiteResultMap.get(s.name);
|
|
263
|
+
let status: SuiteViewState["status"] = "not_run";
|
|
264
|
+
let runResult: SuiteViewState["runResult"] | undefined;
|
|
265
|
+
const steps: StepViewState[] = [];
|
|
266
|
+
|
|
267
|
+
if (results) {
|
|
268
|
+
const passed = results.filter(r => r.status === "pass").length;
|
|
269
|
+
const failed = results.filter(r => r.status === "fail" || r.status === "error").length;
|
|
270
|
+
const skipped = results.filter(r => r.status === "skip").length;
|
|
271
|
+
runResult = { passed, failed, skipped };
|
|
272
|
+
status = failed > 0 ? "failed" : "passed";
|
|
273
|
+
|
|
274
|
+
for (const r of results) {
|
|
275
|
+
const hint = (r.status === "fail" || r.status === "error")
|
|
276
|
+
? (envHint(r.request_url, r.error_message, envFilePath) ?? statusHint(r.response_status) ?? undefined)
|
|
277
|
+
: undefined;
|
|
278
|
+
steps.push({
|
|
279
|
+
name: r.test_name,
|
|
280
|
+
status: r.status as StepViewState["status"],
|
|
281
|
+
durationMs: r.duration_ms ?? undefined,
|
|
282
|
+
requestMethod: r.request_method ?? undefined,
|
|
283
|
+
requestUrl: r.request_url ?? undefined,
|
|
284
|
+
responseStatus: r.response_status ?? undefined,
|
|
285
|
+
responseBody: r.response_body ?? undefined,
|
|
286
|
+
assertions: Array.isArray(r.assertions) ? r.assertions : undefined,
|
|
287
|
+
captures: r.captures && typeof r.captures === "object" ? r.captures as Record<string, unknown> : undefined,
|
|
288
|
+
hint,
|
|
289
|
+
errorMessage: r.error_message ?? undefined,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
name: s.name,
|
|
296
|
+
description: s.description,
|
|
297
|
+
tags: s.tags ?? [],
|
|
298
|
+
stepCount: s.tests.length,
|
|
299
|
+
filePath: s.filePath ?? "",
|
|
300
|
+
status,
|
|
301
|
+
runResult,
|
|
302
|
+
steps,
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Add parse errors as suites
|
|
307
|
+
for (const err of diskSuites.errors) {
|
|
308
|
+
suites.push({
|
|
309
|
+
name: err.file,
|
|
310
|
+
tags: [],
|
|
311
|
+
stepCount: 0,
|
|
312
|
+
filePath: err.file,
|
|
313
|
+
status: "parse_error",
|
|
314
|
+
parseError: err.error,
|
|
315
|
+
steps: [],
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Env diagnostics
|
|
320
|
+
let envAlert: string | null = null;
|
|
321
|
+
const failedResults = latestRunResults.filter(r => r.status === "fail" || r.status === "error");
|
|
322
|
+
if (failedResults.length > 0) {
|
|
323
|
+
const envFilePath = collection.base_dir
|
|
324
|
+
? join(collection.base_dir, ".env.yaml").replace(/\\/g, "/")
|
|
325
|
+
: undefined;
|
|
326
|
+
|
|
327
|
+
const failuresWithHints = failedResults.map(r => ({
|
|
328
|
+
hint: envHint(r.request_url, r.error_message, envFilePath) ?? undefined,
|
|
329
|
+
}));
|
|
330
|
+
envAlert = computeSharedEnvIssue(failuresWithHints, envFilePath);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Run stats
|
|
334
|
+
const runPassed = latestRun?.passed ?? 0;
|
|
335
|
+
const runFailed = latestRun?.failed ?? 0;
|
|
336
|
+
const runSkipped = latestRun?.skipped ?? 0;
|
|
337
|
+
const runTotal = latestRun?.total ?? 0;
|
|
338
|
+
const runDurationMs = latestRun?.duration_ms ?? null;
|
|
339
|
+
|
|
340
|
+
const state: CollectionState = {
|
|
341
|
+
collection,
|
|
342
|
+
endpoints,
|
|
343
|
+
totalEndpoints,
|
|
344
|
+
coveragePct,
|
|
345
|
+
coveredCount,
|
|
346
|
+
suites,
|
|
347
|
+
latestRun,
|
|
348
|
+
latestRunResults,
|
|
349
|
+
envAlert,
|
|
350
|
+
warnings,
|
|
351
|
+
runPassed,
|
|
352
|
+
runFailed,
|
|
353
|
+
runSkipped,
|
|
354
|
+
runTotal,
|
|
355
|
+
runDurationMs,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
cache.set(collection.id, { state, timestamp: Date.now() });
|
|
359
|
+
return state;
|
|
360
|
+
}
|