@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,77 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const mockListRuns = mock(() => [
|
|
4
|
+
{ id: 1, started_at: "2024-01-01", total: 5, passed: 4, failed: 1, skipped: 0 },
|
|
5
|
+
{ id: 2, started_at: "2024-01-02", total: 3, passed: 3, failed: 0, skipped: 0 },
|
|
6
|
+
]);
|
|
7
|
+
const mockGetRunById = mock((): unknown => ({
|
|
8
|
+
id: 1, started_at: "2024-01-01", finished_at: "2024-01-01", total: 5, passed: 4, failed: 1, skipped: 0,
|
|
9
|
+
trigger: "cli", environment: "staging", duration_ms: 1234,
|
|
10
|
+
}));
|
|
11
|
+
const mockGetResultsByRunId = mock(() => [
|
|
12
|
+
{ suite_name: "API", test_name: "GET /pets", status: "pass", duration_ms: 100 },
|
|
13
|
+
{ suite_name: "API", test_name: "POST /pets", status: "fail", duration_ms: 200, error_message: "404" },
|
|
14
|
+
]);
|
|
15
|
+
const mockListCollections = mock(() => [
|
|
16
|
+
{ id: 1, name: "Petstore", test_path: "./tests/", total_runs: 5 },
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
mock.module("../../../src/db/queries.ts", () => ({
|
|
20
|
+
listRuns: mockListRuns,
|
|
21
|
+
getRunById: mockGetRunById,
|
|
22
|
+
getResultsByRunId: mockGetResultsByRunId,
|
|
23
|
+
listCollections: mockListCollections,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
mock.module("../../../src/db/schema.ts", () => ({
|
|
27
|
+
getDb: mock(() => ({})),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
afterAll(() => { mock.restore(); });
|
|
31
|
+
|
|
32
|
+
import { queryResultsTool } from "../../../src/core/agent/tools/query-results.ts";
|
|
33
|
+
|
|
34
|
+
const toolOpts = { toolCallId: "test", messages: [] as any[] };
|
|
35
|
+
|
|
36
|
+
describe("queryResultsTool", () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
mockListRuns.mockClear();
|
|
39
|
+
mockGetRunById.mockClear();
|
|
40
|
+
mockGetResultsByRunId.mockClear();
|
|
41
|
+
mockListCollections.mockClear();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("is an AI SDK v6 tool with inputSchema", () => {
|
|
45
|
+
expect(queryResultsTool).toHaveProperty("inputSchema");
|
|
46
|
+
expect(queryResultsTool).toHaveProperty("execute");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("list_runs action returns runs", async () => {
|
|
50
|
+
const result = await queryResultsTool.execute!({ action: "list_runs" }, toolOpts) as any;
|
|
51
|
+
expect(result).toEqual({ runs: mockListRuns() });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("get_run action returns run details with results", async () => {
|
|
55
|
+
const result = await queryResultsTool.execute!({ action: "get_run", runId: 1 }, toolOpts);
|
|
56
|
+
expect(result).toHaveProperty("run");
|
|
57
|
+
expect(result).toHaveProperty("results");
|
|
58
|
+
expect(mockGetRunById).toHaveBeenCalledWith(1);
|
|
59
|
+
expect(mockGetResultsByRunId).toHaveBeenCalledWith(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("get_run with missing run returns error", async () => {
|
|
63
|
+
mockGetRunById.mockReturnValueOnce(null);
|
|
64
|
+
const result = await queryResultsTool.execute!({ action: "get_run", runId: 999 }, toolOpts);
|
|
65
|
+
expect(result).toEqual({ error: "Run 999 not found" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("list_collections action returns collections", async () => {
|
|
69
|
+
const result = await queryResultsTool.execute!({ action: "list_collections" }, toolOpts) as any;
|
|
70
|
+
expect(result).toEqual({ collections: mockListCollections() });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("unknown action returns error", async () => {
|
|
74
|
+
const result = await queryResultsTool.execute!({ action: "unknown" as any }, toolOpts);
|
|
75
|
+
expect(result).toEqual({ error: "Unknown action: unknown" });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// Mock executeRun before importing the tool
|
|
4
|
+
mock.module("../../../src/core/runner/execute-run.ts", () => ({
|
|
5
|
+
executeRun: mock(() => Promise.resolve({
|
|
6
|
+
runId: 1,
|
|
7
|
+
results: [{
|
|
8
|
+
suite_name: "Pet API",
|
|
9
|
+
started_at: "2024-01-01T00:00:00Z",
|
|
10
|
+
finished_at: "2024-01-01T00:00:01Z",
|
|
11
|
+
total: 3,
|
|
12
|
+
passed: 2,
|
|
13
|
+
failed: 1,
|
|
14
|
+
skipped: 0,
|
|
15
|
+
steps: [],
|
|
16
|
+
}],
|
|
17
|
+
})),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
afterAll(() => { mock.restore(); });
|
|
21
|
+
|
|
22
|
+
import { runTestsTool } from "../../../src/core/agent/tools/run-tests.ts";
|
|
23
|
+
import { executeRun } from "../../../src/core/runner/execute-run.ts";
|
|
24
|
+
|
|
25
|
+
const toolOpts = { toolCallId: "test", messages: [] as any[] };
|
|
26
|
+
|
|
27
|
+
describe("runTestsTool", () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
(executeRun as ReturnType<typeof mock>).mockClear();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("is an AI SDK v6 tool with inputSchema", () => {
|
|
33
|
+
expect(runTestsTool).toHaveProperty("inputSchema");
|
|
34
|
+
expect(runTestsTool).toHaveProperty("execute");
|
|
35
|
+
expect(runTestsTool).toHaveProperty("description");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("returns structured result on success", async () => {
|
|
39
|
+
const result = await runTestsTool.execute!({ testPath: "tests/api.yaml" }, toolOpts);
|
|
40
|
+
expect(result).toEqual({
|
|
41
|
+
runId: 1,
|
|
42
|
+
total: 3,
|
|
43
|
+
passed: 2,
|
|
44
|
+
failed: 1,
|
|
45
|
+
skipped: 0,
|
|
46
|
+
status: "has_failures",
|
|
47
|
+
});
|
|
48
|
+
expect(executeRun).toHaveBeenCalledWith({
|
|
49
|
+
testPath: "tests/api.yaml",
|
|
50
|
+
envName: undefined,
|
|
51
|
+
safe: undefined,
|
|
52
|
+
trigger: "agent",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("passes envName and safe mode", async () => {
|
|
57
|
+
await runTestsTool.execute!({ testPath: "tests/", envName: "staging", safe: true }, toolOpts);
|
|
58
|
+
expect(executeRun).toHaveBeenCalledWith({
|
|
59
|
+
testPath: "tests/",
|
|
60
|
+
envName: "staging",
|
|
61
|
+
safe: true,
|
|
62
|
+
trigger: "agent",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("returns all_passed when no failures", async () => {
|
|
67
|
+
(executeRun as ReturnType<typeof mock>).mockResolvedValueOnce({
|
|
68
|
+
runId: 2,
|
|
69
|
+
results: [{
|
|
70
|
+
suite_name: "OK",
|
|
71
|
+
started_at: "2024-01-01T00:00:00Z",
|
|
72
|
+
finished_at: "2024-01-01T00:00:01Z",
|
|
73
|
+
total: 5,
|
|
74
|
+
passed: 5,
|
|
75
|
+
failed: 0,
|
|
76
|
+
skipped: 0,
|
|
77
|
+
steps: [],
|
|
78
|
+
}],
|
|
79
|
+
});
|
|
80
|
+
const result = await runTestsTool.execute!({ testPath: "tests/" }, toolOpts);
|
|
81
|
+
expect((result as any).status).toBe("all_passed");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("returns structured error on failure", async () => {
|
|
85
|
+
(executeRun as ReturnType<typeof mock>).mockRejectedValueOnce(new Error("No test files found"));
|
|
86
|
+
const result = await runTestsTool.execute!({ testPath: "bad/path" }, toolOpts);
|
|
87
|
+
expect(result).toEqual({ error: "No test files found" });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
|
|
3
|
+
mock.module("../../../src/core/runner/http-client.ts", () => ({
|
|
4
|
+
executeRequest: mock(() => Promise.resolve({
|
|
5
|
+
status: 200,
|
|
6
|
+
headers: { "content-type": "application/json" },
|
|
7
|
+
body: '{"ok":true}',
|
|
8
|
+
body_parsed: { ok: true },
|
|
9
|
+
duration_ms: 30,
|
|
10
|
+
})),
|
|
11
|
+
DEFAULT_FETCH_OPTIONS: { timeout: 30000, retries: 0, retry_delay: 1000, follow_redirects: true },
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
mock.module("../../../src/core/parser/variables.ts", () => ({
|
|
15
|
+
loadEnvironment: mock(() => Promise.resolve({ base_url: "https://api.test.com" })),
|
|
16
|
+
substituteString: mock((template: string, vars: Record<string, unknown>) => {
|
|
17
|
+
return template.replace(/\{\{(.+?)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
|
|
18
|
+
}),
|
|
19
|
+
substituteDeep: mock((value: any, vars: Record<string, unknown>) => {
|
|
20
|
+
if (typeof value === "object" && value !== null) {
|
|
21
|
+
const result: Record<string, unknown> = {};
|
|
22
|
+
for (const [k, v] of Object.entries(value)) {
|
|
23
|
+
result[k] = typeof v === "string"
|
|
24
|
+
? v.replace(/\{\{(.+?)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`))
|
|
25
|
+
: v;
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}),
|
|
31
|
+
GENERATORS: {},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
afterAll(() => { mock.restore(); });
|
|
35
|
+
|
|
36
|
+
import { sendRequestTool } from "../../../src/core/agent/tools/send-request.ts";
|
|
37
|
+
import { executeRequest } from "../../../src/core/runner/http-client.ts";
|
|
38
|
+
|
|
39
|
+
const toolOpts = { toolCallId: "test", messages: [] as any[] };
|
|
40
|
+
|
|
41
|
+
describe("sendRequestTool", () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
(executeRequest as ReturnType<typeof mock>).mockClear();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("is an AI SDK v6 tool with inputSchema", () => {
|
|
47
|
+
expect(sendRequestTool).toHaveProperty("inputSchema");
|
|
48
|
+
expect(sendRequestTool).toHaveProperty("execute");
|
|
49
|
+
expect(sendRequestTool).toHaveProperty("description");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("sends request and returns compact result (no headers)", async () => {
|
|
53
|
+
const result = await sendRequestTool.execute!({ method: "GET", url: "https://api.test.com/data" }, toolOpts);
|
|
54
|
+
expect(result).toEqual({
|
|
55
|
+
status: 200,
|
|
56
|
+
body: { ok: true },
|
|
57
|
+
duration_ms: 30,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("passes headers and body", async () => {
|
|
62
|
+
await sendRequestTool.execute!({
|
|
63
|
+
method: "POST",
|
|
64
|
+
url: "https://api.test.com/data",
|
|
65
|
+
headers: { "Authorization": "Bearer token" },
|
|
66
|
+
body: '{"name":"test"}',
|
|
67
|
+
}, toolOpts);
|
|
68
|
+
expect(executeRequest).toHaveBeenCalledTimes(1);
|
|
69
|
+
const call = (executeRequest as ReturnType<typeof mock>).mock.calls[0]![0];
|
|
70
|
+
expect(call.method).toBe("POST");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns error on failure", async () => {
|
|
74
|
+
(executeRequest as ReturnType<typeof mock>).mockRejectedValueOnce(new Error("connection refused"));
|
|
75
|
+
const result = await sendRequestTool.execute!({ method: "GET", url: "https://bad.host/" }, toolOpts);
|
|
76
|
+
expect(result).toEqual({ error: "connection refused" });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
|
|
3
|
+
mock.module("../../../src/core/parser/yaml-parser.ts", () => ({
|
|
4
|
+
parse: mock(() => Promise.resolve([
|
|
5
|
+
{ name: "Suite A", tests: [{ name: "t1" }, { name: "t2" }] },
|
|
6
|
+
])),
|
|
7
|
+
parseDirectorySafe: mock(() => Promise.resolve({
|
|
8
|
+
suites: [{ name: "Suite A", tests: [{ name: "t1" }] }],
|
|
9
|
+
errors: [],
|
|
10
|
+
})),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
afterAll(() => { mock.restore(); });
|
|
14
|
+
|
|
15
|
+
import { validateTestsTool } from "../../../src/core/agent/tools/validate-tests.ts";
|
|
16
|
+
import { parse } from "../../../src/core/parser/yaml-parser.ts";
|
|
17
|
+
|
|
18
|
+
const toolOpts = { toolCallId: "test", messages: [] as any[] };
|
|
19
|
+
|
|
20
|
+
describe("validateTestsTool", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
(parse as ReturnType<typeof mock>).mockClear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("is an AI SDK v6 tool with inputSchema", () => {
|
|
26
|
+
expect(validateTestsTool).toHaveProperty("inputSchema");
|
|
27
|
+
expect(validateTestsTool).toHaveProperty("execute");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns valid result for correct YAML", async () => {
|
|
31
|
+
const result = await validateTestsTool.execute!({ testPath: "tests/api.yaml" }, toolOpts);
|
|
32
|
+
expect(result).toEqual({
|
|
33
|
+
valid: true,
|
|
34
|
+
suiteCount: 1,
|
|
35
|
+
totalTests: 2,
|
|
36
|
+
suites: [{ name: "Suite A", testCount: 2 }],
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns structured error on parse failure", async () => {
|
|
41
|
+
(parse as ReturnType<typeof mock>).mockRejectedValueOnce(new Error("Invalid YAML in test.yaml: bad indent"));
|
|
42
|
+
const result = await validateTestsTool.execute!({ testPath: "bad.yaml" }, toolOpts);
|
|
43
|
+
expect(result).toEqual({
|
|
44
|
+
valid: false,
|
|
45
|
+
error: "Invalid YAML in test.yaml: bad indent",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns valid=true for empty suite list", async () => {
|
|
50
|
+
(parse as ReturnType<typeof mock>).mockResolvedValueOnce([]);
|
|
51
|
+
const result = await validateTestsTool.execute!({ testPath: "empty/" }, toolOpts);
|
|
52
|
+
expect(result).toEqual({
|
|
53
|
+
valid: true,
|
|
54
|
+
suiteCount: 0,
|
|
55
|
+
totalTests: 0,
|
|
56
|
+
suites: [],
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll } from "bun:test";
|
|
2
|
+
import { generateWithAI } from "../../src/core/generator/ai/ai-generator.ts";
|
|
3
|
+
import { resolveProviderConfig } from "../../src/core/generator/ai/types.ts";
|
|
4
|
+
import { validateSuite } from "../../src/core/parser/schema.ts";
|
|
5
|
+
|
|
6
|
+
// This test requires a running Ollama instance with llama3.2:3b model
|
|
7
|
+
// Skip if Ollama is not available
|
|
8
|
+
|
|
9
|
+
let ollamaAvailable = false;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
try {
|
|
13
|
+
const resp = await fetch("http://localhost:11434/api/version");
|
|
14
|
+
if (resp.ok) {
|
|
15
|
+
// Check if model is available
|
|
16
|
+
const tagsResp = await fetch("http://localhost:11434/api/tags");
|
|
17
|
+
if (tagsResp.ok) {
|
|
18
|
+
const tags = await tagsResp.json() as { models: Array<{ name: string }> };
|
|
19
|
+
ollamaAvailable = tags.models?.some(m => m.name.startsWith("llama3.2")) ?? false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
ollamaAvailable = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!ollamaAvailable) {
|
|
27
|
+
console.log("SKIP: Ollama not available or llama3.2 not installed");
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("AI Generator E2E with Ollama", () => {
|
|
32
|
+
test("generates valid YAML from petstore spec", async () => {
|
|
33
|
+
if (!ollamaAvailable) return;
|
|
34
|
+
|
|
35
|
+
const provider = resolveProviderConfig({
|
|
36
|
+
provider: "ollama",
|
|
37
|
+
model: "llama3.2:3b",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const result = await generateWithAI({
|
|
41
|
+
specPath: "tests/fixtures/petstore-simple.json",
|
|
42
|
+
prompt: "Create a pet and then get it by ID to verify it exists",
|
|
43
|
+
provider,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Should produce non-empty YAML
|
|
47
|
+
expect(result.yaml.length).toBeGreaterThan(50);
|
|
48
|
+
expect(result.model).toBe("llama3.2:3b");
|
|
49
|
+
expect(result.rawResponse.length).toBeGreaterThan(0);
|
|
50
|
+
|
|
51
|
+
// YAML should contain expected keywords
|
|
52
|
+
expect(result.yaml).toContain("name:");
|
|
53
|
+
expect(result.yaml).toContain("tests:");
|
|
54
|
+
expect(result.yaml).toContain("expect:");
|
|
55
|
+
|
|
56
|
+
// Should contain at least one HTTP method
|
|
57
|
+
const hasMethod = /\b(GET|POST|PUT|PATCH|DELETE):/m.test(result.yaml);
|
|
58
|
+
expect(hasMethod).toBe(true);
|
|
59
|
+
|
|
60
|
+
console.log("Generated YAML:\n" + result.yaml);
|
|
61
|
+
}, 120_000); // 2 min timeout for LLM
|
|
62
|
+
|
|
63
|
+
test("generated YAML parses and validates with Zod schema", async () => {
|
|
64
|
+
if (!ollamaAvailable) return;
|
|
65
|
+
|
|
66
|
+
const provider = resolveProviderConfig({
|
|
67
|
+
provider: "ollama",
|
|
68
|
+
model: "llama3.2:3b",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const result = await generateWithAI({
|
|
72
|
+
specPath: "tests/fixtures/petstore-simple.json",
|
|
73
|
+
prompt: "Create a new pet with name and species, verify creation with GET",
|
|
74
|
+
provider,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Split multi-document YAML
|
|
78
|
+
const docs = result.yaml.split(/\n---\n/).filter(Boolean);
|
|
79
|
+
expect(docs.length).toBeGreaterThanOrEqual(1);
|
|
80
|
+
|
|
81
|
+
for (const doc of docs) {
|
|
82
|
+
const parsed = Bun.YAML.parse(doc);
|
|
83
|
+
// Should not throw — validates with Zod
|
|
84
|
+
const suite = validateSuite(parsed);
|
|
85
|
+
expect(suite.name).toBeTruthy();
|
|
86
|
+
expect(suite.tests.length).toBeGreaterThanOrEqual(1);
|
|
87
|
+
|
|
88
|
+
for (const step of suite.tests) {
|
|
89
|
+
expect(step.name).toBeTruthy();
|
|
90
|
+
expect(step.method).toBeTruthy();
|
|
91
|
+
expect(step.path).toBeTruthy();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}, 120_000);
|
|
95
|
+
|
|
96
|
+
test("generates test with 409 conflict scenario (retry up to 3 times)", async () => {
|
|
97
|
+
if (!ollamaAvailable) return;
|
|
98
|
+
|
|
99
|
+
const provider = resolveProviderConfig({
|
|
100
|
+
provider: "ollama",
|
|
101
|
+
model: "llama3.2:3b",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Small models sometimes produce invalid JSON, retry a few times
|
|
105
|
+
let lastError: Error | null = null;
|
|
106
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
107
|
+
try {
|
|
108
|
+
const result = await generateWithAI({
|
|
109
|
+
specPath: "tests/fixtures/petstore-simple.json",
|
|
110
|
+
prompt: "Test uniqueness: create a pet, then create a duplicate pet with the same name and expect 409 conflict error response",
|
|
111
|
+
provider,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(result.yaml.length).toBeGreaterThan(50);
|
|
115
|
+
expect(result.yaml).toContain("POST:");
|
|
116
|
+
// Should reference 409 or conflict scenario
|
|
117
|
+
const has409 = result.yaml.includes("409") || result.yaml.toLowerCase().includes("conflict") || result.yaml.toLowerCase().includes("duplicate");
|
|
118
|
+
expect(has409).toBe(true);
|
|
119
|
+
|
|
120
|
+
console.log(`409 Conflict YAML (attempt ${attempt}):\n` + result.yaml);
|
|
121
|
+
return; // success
|
|
122
|
+
} catch (err) {
|
|
123
|
+
lastError = err as Error;
|
|
124
|
+
console.log(`Attempt ${attempt} failed: ${lastError.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// If all retries failed, skip gracefully (small model limitation)
|
|
129
|
+
console.log(`SKIP: All 3 attempts failed for 409 scenario. Last error: ${lastError?.message}`);
|
|
130
|
+
}, 300_000);
|
|
131
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { chatCompletion } from "../../src/core/generator/ai/llm-client.ts";
|
|
3
|
+
import type { AIProviderConfig } from "../../src/core/generator/ai/types.ts";
|
|
4
|
+
|
|
5
|
+
describe("llm-client", () => {
|
|
6
|
+
test("sends correct headers for openai provider", async () => {
|
|
7
|
+
let capturedRequest: { url: string; headers: Record<string, string>; body: any } | null = null;
|
|
8
|
+
|
|
9
|
+
const originalFetch = globalThis.fetch;
|
|
10
|
+
globalThis.fetch = (async (input: any, init: any) => {
|
|
11
|
+
capturedRequest = {
|
|
12
|
+
url: typeof input === "string" ? input : input.url,
|
|
13
|
+
headers: Object.fromEntries(Object.entries(init?.headers ?? {})),
|
|
14
|
+
body: JSON.parse(init?.body ?? "{}"),
|
|
15
|
+
};
|
|
16
|
+
return new Response(JSON.stringify({
|
|
17
|
+
choices: [{ message: { content: '{"suites":[]}' } }],
|
|
18
|
+
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
|
19
|
+
}), { status: 200 });
|
|
20
|
+
}) as unknown as typeof fetch;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const config: AIProviderConfig = {
|
|
24
|
+
provider: "openai",
|
|
25
|
+
baseUrl: "https://api.openai.com/v1",
|
|
26
|
+
apiKey: "sk-test-key",
|
|
27
|
+
model: "gpt-4o",
|
|
28
|
+
temperature: 0.2,
|
|
29
|
+
maxTokens: 4096,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const result = await chatCompletion(config, [
|
|
33
|
+
{ role: "system", content: "You are helpful" },
|
|
34
|
+
{ role: "user", content: "Hello" },
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
expect(capturedRequest).not.toBeNull();
|
|
38
|
+
expect(capturedRequest!.url).toBe("https://api.openai.com/v1/chat/completions");
|
|
39
|
+
expect(capturedRequest!.headers["Authorization"]).toBe("Bearer sk-test-key");
|
|
40
|
+
expect(capturedRequest!.headers["Content-Type"]).toBe("application/json");
|
|
41
|
+
expect(capturedRequest!.body.model).toBe("gpt-4o");
|
|
42
|
+
expect(capturedRequest!.body.messages.length).toBe(2);
|
|
43
|
+
expect(capturedRequest!.body.response_format).toEqual({ type: "json_object" });
|
|
44
|
+
expect(result.content).toBe('{"suites":[]}');
|
|
45
|
+
expect(result.usage.promptTokens).toBe(10);
|
|
46
|
+
expect(result.usage.completionTokens).toBe(5);
|
|
47
|
+
} finally {
|
|
48
|
+
globalThis.fetch = originalFetch;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("sends correct format for anthropic provider", async () => {
|
|
53
|
+
let capturedRequest: { url: string; headers: Record<string, string>; body: any } | null = null;
|
|
54
|
+
|
|
55
|
+
const originalFetch = globalThis.fetch;
|
|
56
|
+
globalThis.fetch = (async (input: any, init: any) => {
|
|
57
|
+
capturedRequest = {
|
|
58
|
+
url: typeof input === "string" ? input : input.url,
|
|
59
|
+
headers: Object.fromEntries(Object.entries(init?.headers ?? {})),
|
|
60
|
+
body: JSON.parse(init?.body ?? "{}"),
|
|
61
|
+
};
|
|
62
|
+
return new Response(JSON.stringify({
|
|
63
|
+
content: [{ type: "text", text: '{"suites":[]}' }],
|
|
64
|
+
usage: { input_tokens: 20, output_tokens: 10 },
|
|
65
|
+
}), { status: 200 });
|
|
66
|
+
}) as unknown as typeof fetch;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const config: AIProviderConfig = {
|
|
70
|
+
provider: "anthropic",
|
|
71
|
+
baseUrl: "https://api.anthropic.com",
|
|
72
|
+
apiKey: "sk-ant-test",
|
|
73
|
+
model: "claude-sonnet-4-20250514",
|
|
74
|
+
temperature: 0.2,
|
|
75
|
+
maxTokens: 4096,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const result = await chatCompletion(config, [
|
|
79
|
+
{ role: "system", content: "System prompt" },
|
|
80
|
+
{ role: "user", content: "User message" },
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
expect(capturedRequest).not.toBeNull();
|
|
84
|
+
expect(capturedRequest!.url).toBe("https://api.anthropic.com/v1/messages");
|
|
85
|
+
expect(capturedRequest!.headers["x-api-key"]).toBe("sk-ant-test");
|
|
86
|
+
expect(capturedRequest!.headers["anthropic-version"]).toBe("2023-06-01");
|
|
87
|
+
// System should be top-level, not in messages
|
|
88
|
+
expect(capturedRequest!.body.system).toBe("System prompt");
|
|
89
|
+
expect(capturedRequest!.body.messages.length).toBe(1); // only user message
|
|
90
|
+
expect(capturedRequest!.body.messages[0].role).toBe("user");
|
|
91
|
+
expect(result.content).toBe('{"suites":[]}');
|
|
92
|
+
expect(result.usage.promptTokens).toBe(20);
|
|
93
|
+
expect(result.usage.completionTokens).toBe(10);
|
|
94
|
+
} finally {
|
|
95
|
+
globalThis.fetch = originalFetch;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("uses ollama path without Authorization when no apiKey", async () => {
|
|
100
|
+
let capturedHeaders: Record<string, string> = {};
|
|
101
|
+
|
|
102
|
+
const originalFetch = globalThis.fetch;
|
|
103
|
+
globalThis.fetch = (async (_input: any, init: any) => {
|
|
104
|
+
capturedHeaders = Object.fromEntries(Object.entries(init?.headers ?? {}));
|
|
105
|
+
return new Response(JSON.stringify({
|
|
106
|
+
choices: [{ message: { content: "test" } }],
|
|
107
|
+
}), { status: 200 });
|
|
108
|
+
}) as unknown as typeof fetch;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const config: AIProviderConfig = {
|
|
112
|
+
provider: "ollama",
|
|
113
|
+
baseUrl: "http://localhost:11434/v1",
|
|
114
|
+
model: "llama3.2",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
await chatCompletion(config, [{ role: "user", content: "hi" }]);
|
|
118
|
+
expect(capturedHeaders["Authorization"]).toBeUndefined();
|
|
119
|
+
} finally {
|
|
120
|
+
globalThis.fetch = originalFetch;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("throws on HTTP error", async () => {
|
|
125
|
+
const originalFetch = globalThis.fetch;
|
|
126
|
+
globalThis.fetch = (async () => {
|
|
127
|
+
return new Response("Rate limit exceeded", { status: 429 });
|
|
128
|
+
}) as unknown as typeof fetch;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const config: AIProviderConfig = {
|
|
132
|
+
provider: "openai",
|
|
133
|
+
baseUrl: "https://api.openai.com/v1",
|
|
134
|
+
apiKey: "sk-test",
|
|
135
|
+
model: "gpt-4o",
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
await expect(
|
|
139
|
+
chatCompletion(config, [{ role: "user", content: "hi" }])
|
|
140
|
+
).rejects.toThrow("429");
|
|
141
|
+
} finally {
|
|
142
|
+
globalThis.fetch = originalFetch;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|