@kirrosh/apitool 0.4.3
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/.github/workflows/ci.yml +27 -0
- package/.github/workflows/release.yml +97 -0
- package/.mcp.json +9 -0
- package/APITOOL.md +195 -0
- package/BACKLOG.md +62 -0
- package/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/bun.lock +291 -0
- package/docs/GLOSSARY.md +182 -0
- package/docs/INDEX.md +21 -0
- package/docs/agent.md +135 -0
- package/docs/archive/APITOOL-pre-M22.md +831 -0
- package/docs/archive/BACKLOG-AI-NATIVE.md +56 -0
- package/docs/archive/M1-M2-parser-runner.md +216 -0
- package/docs/archive/M4-M7-reporter-cli.md +179 -0
- package/docs/archive/M5-M7-storage-junit.md +300 -0
- package/docs/archive/M6-webui.md +339 -0
- package/docs/ci.md +274 -0
- package/docs/generation-issues.md +67 -0
- package/generated/.env.yaml +3 -0
- package/install.ps1 +80 -0
- package/install.sh +113 -0
- package/package.json +46 -0
- package/scripts/run-mocked-tests.ts +45 -0
- package/seed-demo.ts +53 -0
- package/self-tests/auth.yaml +18 -0
- package/self-tests/collections-crud.yaml +46 -0
- package/self-tests/environments-crud.yaml +48 -0
- package/self-tests/export.yaml +32 -0
- package/self-tests/runs.yaml +16 -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 +126 -0
- package/src/cli/commands/collections.ts +41 -0
- package/src/cli/commands/coverage.ts +65 -0
- package/src/cli/commands/doctor.ts +127 -0
- package/src/cli/commands/envs.ts +218 -0
- package/src/cli/commands/init.ts +84 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/run.ts +137 -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 +500 -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 +33 -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 +48 -0
- package/src/core/agent/tools/manage-environment.ts +40 -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/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/coverage-scanner.ts +87 -0
- package/src/core/generator/data-factory.ts +115 -0
- package/src/core/generator/index.ts +10 -0
- package/src/core/generator/openapi-reader.ts +142 -0
- package/src/core/generator/schema-utils.ts +52 -0
- package/src/core/generator/serializer.ts +189 -0
- package/src/core/generator/types.ts +47 -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 +50 -0
- package/src/core/parser/variables.ts +146 -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 +172 -0
- package/src/core/runner/execute-run.ts +75 -0
- package/src/core/runner/executor.ts +150 -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 +97 -0
- package/src/core/utils.ts +9 -0
- package/src/db/queries.ts +868 -0
- package/src/db/schema.ts +215 -0
- package/src/mcp/server.ts +47 -0
- package/src/mcp/tools/ci-init.ts +57 -0
- package/src/mcp/tools/coverage-analysis.ts +58 -0
- package/src/mcp/tools/explore-api.ts +84 -0
- package/src/mcp/tools/generate-missing-tests.ts +80 -0
- package/src/mcp/tools/generate-tests-guide.ts +353 -0
- package/src/mcp/tools/manage-environment.ts +123 -0
- package/src/mcp/tools/manage-server.ts +87 -0
- package/src/mcp/tools/query-db.ts +141 -0
- package/src/mcp/tools/run-tests.ts +66 -0
- package/src/mcp/tools/save-test-suite.ts +164 -0
- package/src/mcp/tools/send-request.ts +53 -0
- package/src/mcp/tools/setup-api.ts +49 -0
- package/src/mcp/tools/validate-tests.ts +42 -0
- package/src/tui/chat-ui.ts +150 -0
- package/src/web/routes/api.ts +234 -0
- package/src/web/routes/dashboard.ts +348 -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 +265 -0
- package/src/web/views/layout.ts +46 -0
- package/src/web/views/results.ts +209 -0
- package/tests/agent/agent-loop.test.ts +61 -0
- package/tests/agent/context-manager.test.ts +59 -0
- package/tests/agent/system-prompt.test.ts +42 -0
- package/tests/agent/tools/diagnose-failure.test.ts +85 -0
- package/tests/agent/tools/explore-api.test.ts +59 -0
- package/tests/agent/tools/manage-environment.test.ts +78 -0
- package/tests/agent/tools/query-results.test.ts +77 -0
- package/tests/agent/tools/run-tests.test.ts +89 -0
- package/tests/agent/tools/send-request.test.ts +78 -0
- package/tests/agent/tools/validate-tests.test.ts +59 -0
- package/tests/ai/ai-generator.integration.test.ts +131 -0
- package/tests/ai/llm-client.test.ts +145 -0
- package/tests/ai/output-parser.test.ts +132 -0
- package/tests/ai/prompt-builder.test.ts +67 -0
- package/tests/ai/types.test.ts +55 -0
- package/tests/cli/args.test.ts +63 -0
- package/tests/cli/chat.test.ts +38 -0
- package/tests/cli/ci-init.test.ts +112 -0
- package/tests/cli/commands.test.ts +316 -0
- package/tests/cli/coverage.test.ts +58 -0
- package/tests/cli/doctor.test.ts +39 -0
- package/tests/cli/envs.test.ts +181 -0
- package/tests/cli/init.test.ts +80 -0
- package/tests/cli/runs.test.ts +94 -0
- package/tests/cli/safe-run.test.ts +103 -0
- package/tests/cli/update.test.ts +32 -0
- package/tests/core/generator/schema-utils.test.ts +108 -0
- package/tests/core/parser/nested-assertions.test.ts +80 -0
- package/tests/core/runner/root-body-assertions.test.ts +70 -0
- package/tests/db/chat-queries.test.ts +88 -0
- package/tests/db/chat-schema.test.ts +37 -0
- package/tests/db/environments.test.ts +131 -0
- package/tests/db/queries.test.ts +409 -0
- package/tests/db/schema.test.ts +141 -0
- package/tests/fixtures/.env.yaml +3 -0
- package/tests/fixtures/auth-token-test.yaml +8 -0
- package/tests/fixtures/bail/suite-a.yaml +6 -0
- package/tests/fixtures/bail/suite-b.yaml +6 -0
- package/tests/fixtures/crud.yaml +35 -0
- package/tests/fixtures/invalid-missing-name.yaml +5 -0
- package/tests/fixtures/invalid-no-method.yaml +6 -0
- package/tests/fixtures/petstore-auth.json +295 -0
- package/tests/fixtures/petstore-simple.json +151 -0
- package/tests/fixtures/post-only.yaml +12 -0
- package/tests/fixtures/simple.yaml +6 -0
- package/tests/fixtures/valid/.env.yaml +1 -0
- package/tests/fixtures/valid/a.yaml +5 -0
- package/tests/fixtures/valid/b.yml +5 -0
- package/tests/generator/coverage-scanner.test.ts +129 -0
- package/tests/generator/data-factory.test.ts +133 -0
- package/tests/generator/openapi-reader.test.ts +131 -0
- package/tests/integration/auth-flow.test.ts +217 -0
- package/tests/mcp/coverage-analysis.test.ts +64 -0
- package/tests/mcp/explore-api-schemas.test.ts +105 -0
- package/tests/mcp/explore-api.test.ts +49 -0
- package/tests/mcp/generate-missing-tests.test.ts +69 -0
- package/tests/mcp/manage-environment.test.ts +89 -0
- package/tests/mcp/save-test-suite.test.ts +116 -0
- package/tests/mcp/send-request.test.ts +79 -0
- package/tests/mcp/setup-api.test.ts +106 -0
- package/tests/mcp/tools.test.ts +248 -0
- package/tests/parser/schema.test.ts +134 -0
- package/tests/parser/variables.test.ts +227 -0
- package/tests/parser/yaml-parser.test.ts +69 -0
- package/tests/reporter/console.test.ts +256 -0
- package/tests/reporter/json.test.ts +98 -0
- package/tests/reporter/junit.test.ts +284 -0
- package/tests/runner/assertions.test.ts +262 -0
- package/tests/runner/executor.test.ts +310 -0
- package/tests/runner/http-client.test.ts +138 -0
- package/tests/web/routes.test.ts +160 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { AIProviderConfig } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export interface ChatMessage {
|
|
4
|
+
role: "system" | "user" | "assistant";
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ChatCompletionResult {
|
|
9
|
+
content: string;
|
|
10
|
+
usage: {
|
|
11
|
+
promptTokens?: number;
|
|
12
|
+
completionTokens?: number;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function chatCompletion(
|
|
17
|
+
config: AIProviderConfig,
|
|
18
|
+
messages: ChatMessage[],
|
|
19
|
+
): Promise<ChatCompletionResult> {
|
|
20
|
+
if (config.provider === "anthropic") {
|
|
21
|
+
return callAnthropic(config, messages);
|
|
22
|
+
}
|
|
23
|
+
return callOpenAICompatible(config, messages);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function callOpenAICompatible(
|
|
27
|
+
config: AIProviderConfig,
|
|
28
|
+
messages: ChatMessage[],
|
|
29
|
+
): Promise<ChatCompletionResult> {
|
|
30
|
+
const url = `${config.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
|
31
|
+
|
|
32
|
+
// For ollama/custom providers, inject system prompt into first user message
|
|
33
|
+
// to avoid issues with thinking models (e.g. qwen3) that break with separate system messages
|
|
34
|
+
let apiMessages: Array<{ role: string; content: string }>;
|
|
35
|
+
if (config.provider === "ollama" || config.provider === "custom") {
|
|
36
|
+
const systemMsgs = messages.filter((m) => m.role === "system");
|
|
37
|
+
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
38
|
+
if (systemMsgs.length > 0 && nonSystem.length > 0) {
|
|
39
|
+
const systemText = systemMsgs.map((m) => m.content).join("\n\n");
|
|
40
|
+
apiMessages = nonSystem.map((m, i) =>
|
|
41
|
+
i === 0 ? { role: m.role, content: `${systemText}\n\n${m.content}` } : { role: m.role, content: m.content }
|
|
42
|
+
);
|
|
43
|
+
} else {
|
|
44
|
+
apiMessages = messages.map((m) => ({ role: m.role, content: m.content }));
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
apiMessages = messages.map((m) => ({ role: m.role, content: m.content }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const body: Record<string, unknown> = {
|
|
51
|
+
model: config.model,
|
|
52
|
+
messages: apiMessages,
|
|
53
|
+
temperature: config.temperature ?? 0.2,
|
|
54
|
+
max_tokens: config.maxTokens ?? 4096,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Request JSON output where supported (OpenAI, newer Ollama models)
|
|
58
|
+
if (config.provider === "openai") {
|
|
59
|
+
body.response_format = { type: "json_object" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const headers: Record<string, string> = {
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
};
|
|
65
|
+
if (config.apiKey) {
|
|
66
|
+
headers["Authorization"] = `Bearer ${config.apiKey}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const resp = await fetch(url, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers,
|
|
72
|
+
body: JSON.stringify(body),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!resp.ok) {
|
|
76
|
+
const text = await resp.text();
|
|
77
|
+
throw new Error(`LLM request failed (${resp.status}): ${text}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const data = (await resp.json()) as {
|
|
81
|
+
choices: Array<{ message: { content: string; reasoning?: string } }>;
|
|
82
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const msg = data.choices?.[0]?.message;
|
|
86
|
+
// Thinking models (e.g. qwen3) may put output in `reasoning` with empty `content`
|
|
87
|
+
const content = msg?.content || msg?.reasoning || "";
|
|
88
|
+
return {
|
|
89
|
+
content,
|
|
90
|
+
usage: {
|
|
91
|
+
promptTokens: data.usage?.prompt_tokens,
|
|
92
|
+
completionTokens: data.usage?.completion_tokens,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function callAnthropic(
|
|
98
|
+
config: AIProviderConfig,
|
|
99
|
+
messages: ChatMessage[],
|
|
100
|
+
): Promise<ChatCompletionResult> {
|
|
101
|
+
const url = `${config.baseUrl.replace(/\/+$/, "")}/v1/messages`;
|
|
102
|
+
|
|
103
|
+
// Separate system prompt from user/assistant messages
|
|
104
|
+
const systemMessages = messages.filter((m) => m.role === "system");
|
|
105
|
+
const nonSystemMessages = messages.filter((m) => m.role !== "system");
|
|
106
|
+
|
|
107
|
+
const systemText = systemMessages.map((m) => m.content).join("\n\n");
|
|
108
|
+
|
|
109
|
+
const body: Record<string, unknown> = {
|
|
110
|
+
model: config.model,
|
|
111
|
+
max_tokens: config.maxTokens ?? 4096,
|
|
112
|
+
temperature: config.temperature ?? 0.2,
|
|
113
|
+
messages: nonSystemMessages.map((m) => ({
|
|
114
|
+
role: m.role,
|
|
115
|
+
content: m.content,
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (systemText) {
|
|
120
|
+
body.system = systemText;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const headers: Record<string, string> = {
|
|
124
|
+
"Content-Type": "application/json",
|
|
125
|
+
"anthropic-version": "2023-06-01",
|
|
126
|
+
};
|
|
127
|
+
if (config.apiKey) {
|
|
128
|
+
headers["x-api-key"] = config.apiKey;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const resp = await fetch(url, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers,
|
|
134
|
+
body: JSON.stringify(body),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (!resp.ok) {
|
|
138
|
+
const text = await resp.text();
|
|
139
|
+
throw new Error(`Anthropic request failed (${resp.status}): ${text}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const data = (await resp.json()) as {
|
|
143
|
+
content: Array<{ type: string; text: string }>;
|
|
144
|
+
usage?: { input_tokens?: number; output_tokens?: number };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const content = data.content
|
|
148
|
+
?.filter((b) => b.type === "text")
|
|
149
|
+
.map((b) => b.text)
|
|
150
|
+
.join("") ?? "";
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
content,
|
|
154
|
+
usage: {
|
|
155
|
+
promptTokens: data.usage?.input_tokens,
|
|
156
|
+
completionTokens: data.usage?.output_tokens,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type { RawSuite } from "../serializer.ts";
|
|
2
|
+
import { serializeSuite } from "../serializer.ts";
|
|
3
|
+
import { TestSuiteSchema } from "../../parser/schema.ts";
|
|
4
|
+
|
|
5
|
+
export interface ParseResult {
|
|
6
|
+
suites: RawSuite[];
|
|
7
|
+
yaml: string;
|
|
8
|
+
errors: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseAIResponse(raw: string): ParseResult {
|
|
12
|
+
const errors: string[] = [];
|
|
13
|
+
|
|
14
|
+
// Sanitize first (fix template vars, NaN, etc.) so extractJson's
|
|
15
|
+
// bracket-matching isn't confused by bare {{...}} tokens
|
|
16
|
+
const sanitized = sanitizeJson(raw);
|
|
17
|
+
|
|
18
|
+
// Extract JSON from response (handle fences, leading text)
|
|
19
|
+
const json = extractJson(sanitized);
|
|
20
|
+
if (!json) {
|
|
21
|
+
return { suites: [], yaml: "", errors: ["Could not find valid JSON in LLM response"] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let parsed: unknown;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(json);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return { suites: [], yaml: "", errors: [`Invalid JSON: ${(e as Error).message}`] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Normalize to array of suite objects
|
|
32
|
+
let suiteObjects: unknown[];
|
|
33
|
+
if (Array.isArray(parsed)) {
|
|
34
|
+
suiteObjects = parsed;
|
|
35
|
+
} else if (typeof parsed === "object" && parsed !== null) {
|
|
36
|
+
const obj = parsed as Record<string, unknown>;
|
|
37
|
+
if (Array.isArray(obj.suites)) {
|
|
38
|
+
suiteObjects = obj.suites;
|
|
39
|
+
} else if (obj.name && Array.isArray(obj.tests)) {
|
|
40
|
+
// Single suite object
|
|
41
|
+
suiteObjects = [obj];
|
|
42
|
+
} else {
|
|
43
|
+
return { suites: [], yaml: "", errors: ["JSON does not contain a valid suite structure"] };
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
return { suites: [], yaml: "", errors: ["Expected JSON object or array"] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const validSuites: RawSuite[] = [];
|
|
50
|
+
const yamlParts: string[] = [];
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < suiteObjects.length; i++) {
|
|
53
|
+
const suiteObj = suiteObjects[i];
|
|
54
|
+
if (typeof suiteObj !== "object" || suiteObj === null) {
|
|
55
|
+
errors.push(`Suite ${i + 1}: not a valid object`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Transform method keys to the format our schema expects
|
|
60
|
+
const rawSuite = transformSuite(suiteObj as Record<string, unknown>);
|
|
61
|
+
|
|
62
|
+
// Skip suites without tests — can't serialize them
|
|
63
|
+
if (!Array.isArray((rawSuite as any).tests) || (rawSuite as any).tests.length === 0) {
|
|
64
|
+
errors.push(`Suite "${(rawSuite as any).name ?? i + 1}": no tests defined, skipped`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Validate against Zod schema
|
|
69
|
+
const result = TestSuiteSchema.safeParse(rawSuite);
|
|
70
|
+
if (!result.success) {
|
|
71
|
+
// Try to auto-fix and re-validate
|
|
72
|
+
const fixed = autoFixSuite(rawSuite);
|
|
73
|
+
const retry = TestSuiteSchema.safeParse(fixed);
|
|
74
|
+
if (retry.success) {
|
|
75
|
+
validSuites.push(fixed as unknown as RawSuite);
|
|
76
|
+
yamlParts.push(serializeSuite(fixed as unknown as RawSuite));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const issues = result.error.issues.map((issue) =>
|
|
80
|
+
`${issue.path.join(".")}: ${issue.message}`
|
|
81
|
+
).join("; ");
|
|
82
|
+
errors.push(`Suite "${(rawSuite as any).name ?? i + 1}" validation: ${issues}`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
validSuites.push(rawSuite as unknown as RawSuite);
|
|
87
|
+
yamlParts.push(serializeSuite(rawSuite as unknown as RawSuite));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (validSuites.length === 0 && errors.length === 0) {
|
|
91
|
+
errors.push("No test suites found in LLM response");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
suites: validSuites,
|
|
96
|
+
yaml: yamlParts.join("\n---\n"),
|
|
97
|
+
errors,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fix common LLM JSON mistakes before parsing */
|
|
102
|
+
function sanitizeJson(json: string): string {
|
|
103
|
+
let s = json;
|
|
104
|
+
|
|
105
|
+
// 1. Fix broken template vars: {{$randomString} → {{$randomString}}
|
|
106
|
+
s = s.replace(/\{\{([$]?\w+)\}(?!\})/g, '{{$1}}');
|
|
107
|
+
|
|
108
|
+
// 2. Quote bare (unquoted) {{...}} template vars outside of JSON strings.
|
|
109
|
+
// Walk character-by-character to track string context.
|
|
110
|
+
s = quoteBareTemplateVars(s);
|
|
111
|
+
|
|
112
|
+
// 3. Replace bare NaN / Infinity / undefined with null
|
|
113
|
+
s = s.replace(/\bNaN\b/g, 'null');
|
|
114
|
+
s = s.replace(/\bundefined\b/g, 'null');
|
|
115
|
+
s = s.replace(/-?\bInfinity\b/g, 'null');
|
|
116
|
+
|
|
117
|
+
// 4. Remove trailing commas before } or ]
|
|
118
|
+
s = s.replace(/,(\s*[}\]])/g, '$1');
|
|
119
|
+
|
|
120
|
+
// 5. Try parsing; if it fails, attempt to fix unbalanced brackets
|
|
121
|
+
try { JSON.parse(s); } catch {
|
|
122
|
+
s = fixUnbalancedBrackets(s);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return s;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Wrap bare {{...}} tokens (outside JSON strings) in quotes */
|
|
129
|
+
function quoteBareTemplateVars(json: string): string {
|
|
130
|
+
const result: string[] = [];
|
|
131
|
+
let inString = false;
|
|
132
|
+
let escape = false;
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < json.length; i++) {
|
|
135
|
+
const ch = json[i]!;
|
|
136
|
+
|
|
137
|
+
if (escape) { escape = false; result.push(ch); continue; }
|
|
138
|
+
if (ch === '\\' && inString) { escape = true; result.push(ch); continue; }
|
|
139
|
+
if (ch === '"') { inString = !inString; result.push(ch); continue; }
|
|
140
|
+
|
|
141
|
+
// Inside a JSON string, {{ }} are fine — leave them as-is
|
|
142
|
+
if (inString) { result.push(ch); continue; }
|
|
143
|
+
|
|
144
|
+
// Outside a string: check for {{ start
|
|
145
|
+
if (ch === '{' && json[i + 1] === '{') {
|
|
146
|
+
// Find the closing }}
|
|
147
|
+
const end = json.indexOf('}}', i + 2);
|
|
148
|
+
if (end !== -1) {
|
|
149
|
+
const tpl = json.slice(i, end + 2); // e.g. "{{$randomInt}}"
|
|
150
|
+
result.push('"', tpl, '"');
|
|
151
|
+
i = end + 1; // skip past }}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
result.push(ch);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return result.join('');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Remove excess closing brackets/braces by tracking depth */
|
|
163
|
+
function fixUnbalancedBrackets(json: string): string {
|
|
164
|
+
const result: string[] = [];
|
|
165
|
+
let inString = false;
|
|
166
|
+
let escape = false;
|
|
167
|
+
const stack: string[] = [];
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < json.length; i++) {
|
|
170
|
+
const ch = json[i]!;
|
|
171
|
+
if (escape) { escape = false; result.push(ch); continue; }
|
|
172
|
+
if (ch === '\\' && inString) { escape = true; result.push(ch); continue; }
|
|
173
|
+
if (ch === '"') { inString = !inString; result.push(ch); continue; }
|
|
174
|
+
if (inString) { result.push(ch); continue; }
|
|
175
|
+
|
|
176
|
+
if (ch === '{' || ch === '[') {
|
|
177
|
+
stack.push(ch);
|
|
178
|
+
result.push(ch);
|
|
179
|
+
} else if (ch === '}' || ch === ']') {
|
|
180
|
+
const expected = ch === '}' ? '{' : '[';
|
|
181
|
+
if (stack.length > 0 && stack[stack.length - 1] === expected) {
|
|
182
|
+
stack.pop();
|
|
183
|
+
result.push(ch);
|
|
184
|
+
}
|
|
185
|
+
// else: skip the excess closing bracket
|
|
186
|
+
} else {
|
|
187
|
+
result.push(ch);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return result.join('');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function extractJson(raw: string): string | null {
|
|
195
|
+
// Try 1: Extract from ```json ... ``` fences (use last match for thinking models)
|
|
196
|
+
const fenceMatches = [...raw.matchAll(/```(?:json)?\s*\n?([\s\S]*?)```/g)];
|
|
197
|
+
if (fenceMatches.length > 0) {
|
|
198
|
+
return fenceMatches[fenceMatches.length - 1]![1]!.trim();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Try 2: Find all balanced JSON blocks, return the largest one
|
|
202
|
+
// (thinking models put explanatory JSON snippets before the final answer)
|
|
203
|
+
const candidates: string[] = [];
|
|
204
|
+
for (let pos = 0; pos < raw.length; pos++) {
|
|
205
|
+
const ch = raw[pos];
|
|
206
|
+
if (ch !== "{" && ch !== "[") continue;
|
|
207
|
+
|
|
208
|
+
const open = ch;
|
|
209
|
+
const close = ch === "{" ? "}" : "]";
|
|
210
|
+
let depth = 0;
|
|
211
|
+
let inString = false;
|
|
212
|
+
let escape = false;
|
|
213
|
+
|
|
214
|
+
for (let i = pos; i < raw.length; i++) {
|
|
215
|
+
const c = raw[i];
|
|
216
|
+
if (escape) { escape = false; continue; }
|
|
217
|
+
if (c === "\\") { escape = true; continue; }
|
|
218
|
+
if (c === '"') { inString = !inString; continue; }
|
|
219
|
+
if (inString) continue;
|
|
220
|
+
|
|
221
|
+
if (c === open) depth++;
|
|
222
|
+
if (c === close) {
|
|
223
|
+
depth--;
|
|
224
|
+
if (depth === 0) {
|
|
225
|
+
candidates.push(raw.slice(pos, i + 1));
|
|
226
|
+
pos = i; // skip past this block
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (candidates.length === 0) return null;
|
|
234
|
+
|
|
235
|
+
// Return the largest candidate (most likely the full answer, not a snippet)
|
|
236
|
+
return candidates.reduce((a, b) => a.length >= b.length ? a : b);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function fixBodyAssertions(body: Record<string, unknown>): Record<string, unknown> {
|
|
240
|
+
const fixed: Record<string, unknown> = {};
|
|
241
|
+
for (const [key, val] of Object.entries(body)) {
|
|
242
|
+
if (val === null || val === undefined) {
|
|
243
|
+
// null/undefined → { exists: true }
|
|
244
|
+
fixed[key] = { exists: true };
|
|
245
|
+
} else if (typeof val === "string") {
|
|
246
|
+
// bare string → { type: string }
|
|
247
|
+
fixed[key] = { type: val };
|
|
248
|
+
} else if (typeof val === "object" && val !== null) {
|
|
249
|
+
const rule = val as Record<string, unknown>;
|
|
250
|
+
// Coerce "true"/"false" strings to boolean for `exists`
|
|
251
|
+
if (typeof rule.exists === "string") {
|
|
252
|
+
rule.exists = rule.exists === "true";
|
|
253
|
+
}
|
|
254
|
+
fixed[key] = rule;
|
|
255
|
+
} else {
|
|
256
|
+
fixed[key] = val;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return fixed;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function transformSuite(obj: Record<string, unknown>): Record<string, unknown> {
|
|
263
|
+
const tests = obj.tests as Array<Record<string, unknown>> | undefined;
|
|
264
|
+
if (!Array.isArray(tests)) return obj;
|
|
265
|
+
|
|
266
|
+
const transformedTests = tests.map((step) => {
|
|
267
|
+
// Ensure expect exists
|
|
268
|
+
if (!step.expect) {
|
|
269
|
+
step.expect = {};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const expect = step.expect as Record<string, unknown>;
|
|
273
|
+
if (expect.body && typeof expect.body === "object" && expect.body !== null) {
|
|
274
|
+
expect.body = fixBodyAssertions(expect.body as Record<string, unknown>);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return step;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return { ...obj, tests: transformedTests };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Deep-fix a suite that failed validation — coerce types LLMs commonly get wrong */
|
|
284
|
+
function autoFixSuite(obj: Record<string, unknown>): Record<string, unknown> {
|
|
285
|
+
const tests = obj.tests as Array<Record<string, unknown>> | undefined;
|
|
286
|
+
if (!Array.isArray(tests)) return obj;
|
|
287
|
+
|
|
288
|
+
const fixedTests = tests.map((step) => {
|
|
289
|
+
const expect = step.expect as Record<string, unknown> | undefined;
|
|
290
|
+
if (!expect) return step;
|
|
291
|
+
|
|
292
|
+
// Fix status as string → number
|
|
293
|
+
if (typeof expect.status === "string") {
|
|
294
|
+
const n = parseInt(expect.status as string, 10);
|
|
295
|
+
if (!isNaN(n)) expect.status = n;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Fix body assertions again (in case transformSuite missed edge cases)
|
|
299
|
+
if (expect.body && typeof expect.body === "object" && expect.body !== null) {
|
|
300
|
+
expect.body = fixBodyAssertions(expect.body as Record<string, unknown>);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return step;
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return { ...obj, tests: fixedTests };
|
|
307
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../types.ts";
|
|
2
|
+
import type { ChatMessage } from "./llm-client.ts";
|
|
3
|
+
import { compressSchema, formatParam } from "../schema-utils.ts";
|
|
4
|
+
|
|
5
|
+
const SYSTEM_PROMPT = `You are an API test generator. You produce JSON output that represents test suites for an API testing tool.
|
|
6
|
+
|
|
7
|
+
OUTPUT FORMAT — return a JSON object with a single key "suites" containing an array of suite objects:
|
|
8
|
+
{
|
|
9
|
+
"suites": [
|
|
10
|
+
{
|
|
11
|
+
"name": "Suite Name",
|
|
12
|
+
"base_url": "{{base_url}}",
|
|
13
|
+
"headers": {
|
|
14
|
+
"Authorization": "Bearer {{auth_token}}"
|
|
15
|
+
},
|
|
16
|
+
"tests": [
|
|
17
|
+
{
|
|
18
|
+
"name": "Authenticate",
|
|
19
|
+
"POST": "/auth/login",
|
|
20
|
+
"json": { "username": "{{auth_username}}", "password": "{{auth_password}}" },
|
|
21
|
+
"expect": {
|
|
22
|
+
"status": 200,
|
|
23
|
+
"body": {
|
|
24
|
+
"token": { "type": "string", "capture": "auth_token" }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "Create item",
|
|
30
|
+
"POST": "/items",
|
|
31
|
+
"json": { "name": "{{$randomString}}" },
|
|
32
|
+
"expect": {
|
|
33
|
+
"status": 201,
|
|
34
|
+
"body": {
|
|
35
|
+
"id": { "type": "number", "capture": "created_id" }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "Verify created",
|
|
41
|
+
"GET": "/items/{{created_id}}",
|
|
42
|
+
"expect": {
|
|
43
|
+
"status": 200,
|
|
44
|
+
"body": {
|
|
45
|
+
"id": { "equals": "{{created_id}}" }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
RULES:
|
|
55
|
+
1. Each test step has exactly ONE HTTP method key: GET, POST, PUT, PATCH, or DELETE. The value is the path.
|
|
56
|
+
2. Use "json" for request bodies (objects). Use "form" for form data. Use "query" for query parameters.
|
|
57
|
+
3. "expect" contains "status" (number) and optional "body" with field assertions.
|
|
58
|
+
4. Body assertions are objects with these optional keys:
|
|
59
|
+
- "type": "string" | "number" | "integer" | "boolean" | "array" | "object"
|
|
60
|
+
- "equals": exact value match
|
|
61
|
+
- "contains": substring match (strings)
|
|
62
|
+
- "matches": regex pattern
|
|
63
|
+
- "gt": greater than (numbers)
|
|
64
|
+
- "lt": less than (numbers)
|
|
65
|
+
- "exists": true (must be boolean true, NEVER a string)
|
|
66
|
+
- "capture": variable name — SAVES the response value into a variable for later steps
|
|
67
|
+
5. Use {{variable}} syntax to reference captured values in paths, bodies, and assertions.
|
|
68
|
+
6. ONLY these built-in generators exist: {{$randomInt}}, {{$uuid}}, {{$timestamp}}, {{$randomEmail}}, {{$randomString}}, {{$randomName}}. Do NOT invent others like $randomString(N) or $randomWord — they do not exist.
|
|
69
|
+
7. Use {{base_url}} for the base URL — never hardcode it.
|
|
70
|
+
8. Steps execute sequentially — a capture in step 1 is available in step 2+.
|
|
71
|
+
9. Generate realistic test data. Use generators for uniqueness where needed.
|
|
72
|
+
10. Output ONLY the JSON object, no markdown fences or extra text.
|
|
73
|
+
|
|
74
|
+
CRITICAL — common mistakes to avoid:
|
|
75
|
+
- NEVER use "equals" to save a value. "equals" COMPARES, "capture" SAVES. To extract a token: {"capture": "auth_token"} NOT {"equals": "{{auth_token}}"}.
|
|
76
|
+
- NEVER prefix double braces with a dollar sign. Correct: {{my_var}}. Wrong: $` + `{{my_var}}. Generators also use plain braces: {{$randomString}} not $` + `{{$randomString}}.
|
|
77
|
+
- If the API has authentication (JWT, Bearer token), ALWAYS add a login step FIRST that captures the token, then set suite-level "headers": {"Authorization": "Bearer {{auth_token}}"}.
|
|
78
|
+
- For login credentials, use environment variables {{auth_username}} and {{auth_password}}, NOT generators like {{$randomEmail}}.
|
|
79
|
+
- "exists" value MUST be boolean true or false, NEVER the string "true".`;
|
|
80
|
+
|
|
81
|
+
export function buildMessages(
|
|
82
|
+
endpoints: EndpointInfo[],
|
|
83
|
+
securitySchemes: SecuritySchemeInfo[],
|
|
84
|
+
userPrompt: string,
|
|
85
|
+
baseUrl?: string,
|
|
86
|
+
): ChatMessage[] {
|
|
87
|
+
const apiContext = compressEndpoints(endpoints, securitySchemes);
|
|
88
|
+
|
|
89
|
+
const userMessage = `API SPECIFICATION:
|
|
90
|
+
${apiContext}
|
|
91
|
+
${baseUrl ? `\nBase URL: ${baseUrl}` : ""}
|
|
92
|
+
|
|
93
|
+
USER REQUEST:
|
|
94
|
+
${userPrompt}
|
|
95
|
+
|
|
96
|
+
Generate test suites as JSON following the rules in your instructions.`;
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
100
|
+
{ role: "user", content: userMessage },
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function compressEndpoints(
|
|
105
|
+
endpoints: EndpointInfo[],
|
|
106
|
+
securitySchemes: SecuritySchemeInfo[],
|
|
107
|
+
): string {
|
|
108
|
+
const lines: string[] = [];
|
|
109
|
+
|
|
110
|
+
// Security schemes
|
|
111
|
+
if (securitySchemes.length > 0) {
|
|
112
|
+
lines.push("SECURITY:");
|
|
113
|
+
for (const s of securitySchemes) {
|
|
114
|
+
let desc = ` ${s.name}: ${s.type}`;
|
|
115
|
+
if (s.scheme) desc += ` (${s.scheme})`;
|
|
116
|
+
if (s.in && s.apiKeyName) desc += ` (${s.apiKeyName} in ${s.in})`;
|
|
117
|
+
lines.push(desc);
|
|
118
|
+
}
|
|
119
|
+
lines.push("");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Endpoints
|
|
123
|
+
lines.push("ENDPOINTS:");
|
|
124
|
+
for (const ep of endpoints) {
|
|
125
|
+
const summary = ep.summary ? ` — ${ep.summary}` : "";
|
|
126
|
+
const security = ep.security.length > 0 ? ` [auth: ${ep.security.join(", ")}]` : "";
|
|
127
|
+
lines.push(`${ep.method} ${ep.path}${summary}${security}`);
|
|
128
|
+
|
|
129
|
+
// Parameters
|
|
130
|
+
const pathParams = ep.parameters.filter((p) => p.in === "path");
|
|
131
|
+
const queryParams = ep.parameters.filter((p) => p.in === "query");
|
|
132
|
+
if (pathParams.length > 0) {
|
|
133
|
+
lines.push(` Path params: ${pathParams.map((p) => formatParam(p)).join(", ")}`);
|
|
134
|
+
}
|
|
135
|
+
if (queryParams.length > 0) {
|
|
136
|
+
lines.push(` Query params: ${queryParams.map((p) => formatParam(p)).join(", ")}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Request body
|
|
140
|
+
if (ep.requestBodySchema) {
|
|
141
|
+
lines.push(` Body: ${compressSchema(ep.requestBodySchema)}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Responses
|
|
145
|
+
for (const resp of ep.responses) {
|
|
146
|
+
const schemaStr = resp.schema ? ` ${compressSchema(resp.schema)}` : "";
|
|
147
|
+
lines.push(` ${resp.statusCode}: ${resp.description}${schemaStr}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface AIProviderConfig {
|
|
2
|
+
provider: "ollama" | "openai" | "anthropic" | "custom";
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
model: string;
|
|
6
|
+
temperature?: number;
|
|
7
|
+
maxTokens?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AIGenerateOptions {
|
|
11
|
+
specPath: string;
|
|
12
|
+
prompt: string;
|
|
13
|
+
provider: AIProviderConfig;
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
collectionId?: number;
|
|
16
|
+
/** Filter to a single endpoint by method+path */
|
|
17
|
+
filterEndpoint?: { method: string; path: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AIGenerateResult {
|
|
21
|
+
yaml: string;
|
|
22
|
+
rawResponse: string;
|
|
23
|
+
promptTokens?: number;
|
|
24
|
+
completionTokens?: number;
|
|
25
|
+
model: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const PROVIDER_DEFAULTS: Record<string, Partial<AIProviderConfig>> = {
|
|
29
|
+
ollama: {
|
|
30
|
+
baseUrl: "http://localhost:11434/v1",
|
|
31
|
+
model: "llama3.2:3b",
|
|
32
|
+
},
|
|
33
|
+
openai: {
|
|
34
|
+
baseUrl: "https://api.openai.com/v1",
|
|
35
|
+
model: "gpt-4o",
|
|
36
|
+
},
|
|
37
|
+
anthropic: {
|
|
38
|
+
baseUrl: "https://api.anthropic.com",
|
|
39
|
+
model: "claude-sonnet-4-20250514",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function resolveProviderConfig(partial: Partial<AIProviderConfig> & { provider: AIProviderConfig["provider"] }): AIProviderConfig {
|
|
44
|
+
const defaults = PROVIDER_DEFAULTS[partial.provider] ?? {};
|
|
45
|
+
// Thinking models (e.g. qwen3) use reasoning tokens that count toward max_tokens,
|
|
46
|
+
// so local models need a higher budget than cloud APIs
|
|
47
|
+
const defaultMaxTokens = partial.provider === "ollama" || partial.provider === "custom" ? 8192 : 4096;
|
|
48
|
+
return {
|
|
49
|
+
provider: partial.provider,
|
|
50
|
+
baseUrl: partial.baseUrl ?? defaults.baseUrl ?? "",
|
|
51
|
+
apiKey: partial.apiKey,
|
|
52
|
+
model: partial.model ?? defaults.model ?? "",
|
|
53
|
+
temperature: partial.temperature ?? 0.2,
|
|
54
|
+
maxTokens: partial.maxTokens ?? defaultMaxTokens,
|
|
55
|
+
};
|
|
56
|
+
}
|