@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.
Files changed (191) hide show
  1. package/.github/workflows/ci.yml +27 -0
  2. package/.github/workflows/release.yml +97 -0
  3. package/.mcp.json +9 -0
  4. package/APITOOL.md +195 -0
  5. package/BACKLOG.md +62 -0
  6. package/CHANGELOG.md +88 -0
  7. package/LICENSE +21 -0
  8. package/README.md +105 -0
  9. package/bun.lock +291 -0
  10. package/docs/GLOSSARY.md +182 -0
  11. package/docs/INDEX.md +21 -0
  12. package/docs/agent.md +135 -0
  13. package/docs/archive/APITOOL-pre-M22.md +831 -0
  14. package/docs/archive/BACKLOG-AI-NATIVE.md +56 -0
  15. package/docs/archive/M1-M2-parser-runner.md +216 -0
  16. package/docs/archive/M4-M7-reporter-cli.md +179 -0
  17. package/docs/archive/M5-M7-storage-junit.md +300 -0
  18. package/docs/archive/M6-webui.md +339 -0
  19. package/docs/ci.md +274 -0
  20. package/docs/generation-issues.md +67 -0
  21. package/generated/.env.yaml +3 -0
  22. package/install.ps1 +80 -0
  23. package/install.sh +113 -0
  24. package/package.json +46 -0
  25. package/scripts/run-mocked-tests.ts +45 -0
  26. package/seed-demo.ts +53 -0
  27. package/self-tests/auth.yaml +18 -0
  28. package/self-tests/collections-crud.yaml +46 -0
  29. package/self-tests/environments-crud.yaml +48 -0
  30. package/self-tests/export.yaml +32 -0
  31. package/self-tests/runs.yaml +16 -0
  32. package/src/bun-types.d.ts +5 -0
  33. package/src/cli/commands/add-api.ts +51 -0
  34. package/src/cli/commands/ai-generate.ts +106 -0
  35. package/src/cli/commands/chat.ts +43 -0
  36. package/src/cli/commands/ci-init.ts +126 -0
  37. package/src/cli/commands/collections.ts +41 -0
  38. package/src/cli/commands/coverage.ts +65 -0
  39. package/src/cli/commands/doctor.ts +127 -0
  40. package/src/cli/commands/envs.ts +218 -0
  41. package/src/cli/commands/init.ts +84 -0
  42. package/src/cli/commands/mcp.ts +16 -0
  43. package/src/cli/commands/run.ts +137 -0
  44. package/src/cli/commands/runs.ts +108 -0
  45. package/src/cli/commands/serve.ts +22 -0
  46. package/src/cli/commands/update.ts +142 -0
  47. package/src/cli/commands/validate.ts +18 -0
  48. package/src/cli/index.ts +500 -0
  49. package/src/cli/output.ts +24 -0
  50. package/src/cli/runtime.ts +7 -0
  51. package/src/core/agent/agent-loop.ts +116 -0
  52. package/src/core/agent/context-manager.ts +41 -0
  53. package/src/core/agent/system-prompt.ts +33 -0
  54. package/src/core/agent/tools/diagnose-failure.ts +51 -0
  55. package/src/core/agent/tools/explore-api.ts +40 -0
  56. package/src/core/agent/tools/index.ts +48 -0
  57. package/src/core/agent/tools/manage-environment.ts +40 -0
  58. package/src/core/agent/tools/query-results.ts +40 -0
  59. package/src/core/agent/tools/run-tests.ts +38 -0
  60. package/src/core/agent/tools/send-request.ts +44 -0
  61. package/src/core/agent/tools/validate-tests.ts +23 -0
  62. package/src/core/agent/types.ts +22 -0
  63. package/src/core/generator/ai/ai-generator.ts +61 -0
  64. package/src/core/generator/ai/llm-client.ts +159 -0
  65. package/src/core/generator/ai/output-parser.ts +307 -0
  66. package/src/core/generator/ai/prompt-builder.ts +153 -0
  67. package/src/core/generator/ai/types.ts +56 -0
  68. package/src/core/generator/coverage-scanner.ts +87 -0
  69. package/src/core/generator/data-factory.ts +115 -0
  70. package/src/core/generator/index.ts +10 -0
  71. package/src/core/generator/openapi-reader.ts +142 -0
  72. package/src/core/generator/schema-utils.ts +52 -0
  73. package/src/core/generator/serializer.ts +189 -0
  74. package/src/core/generator/types.ts +47 -0
  75. package/src/core/parser/filter.ts +14 -0
  76. package/src/core/parser/index.ts +21 -0
  77. package/src/core/parser/schema.ts +175 -0
  78. package/src/core/parser/types.ts +50 -0
  79. package/src/core/parser/variables.ts +146 -0
  80. package/src/core/parser/yaml-parser.ts +85 -0
  81. package/src/core/reporter/console.ts +175 -0
  82. package/src/core/reporter/index.ts +23 -0
  83. package/src/core/reporter/json.ts +9 -0
  84. package/src/core/reporter/junit.ts +78 -0
  85. package/src/core/reporter/types.ts +12 -0
  86. package/src/core/runner/assertions.ts +172 -0
  87. package/src/core/runner/execute-run.ts +75 -0
  88. package/src/core/runner/executor.ts +150 -0
  89. package/src/core/runner/http-client.ts +69 -0
  90. package/src/core/runner/index.ts +12 -0
  91. package/src/core/runner/types.ts +48 -0
  92. package/src/core/setup-api.ts +97 -0
  93. package/src/core/utils.ts +9 -0
  94. package/src/db/queries.ts +868 -0
  95. package/src/db/schema.ts +215 -0
  96. package/src/mcp/server.ts +47 -0
  97. package/src/mcp/tools/ci-init.ts +57 -0
  98. package/src/mcp/tools/coverage-analysis.ts +58 -0
  99. package/src/mcp/tools/explore-api.ts +84 -0
  100. package/src/mcp/tools/generate-missing-tests.ts +80 -0
  101. package/src/mcp/tools/generate-tests-guide.ts +353 -0
  102. package/src/mcp/tools/manage-environment.ts +123 -0
  103. package/src/mcp/tools/manage-server.ts +87 -0
  104. package/src/mcp/tools/query-db.ts +141 -0
  105. package/src/mcp/tools/run-tests.ts +66 -0
  106. package/src/mcp/tools/save-test-suite.ts +164 -0
  107. package/src/mcp/tools/send-request.ts +53 -0
  108. package/src/mcp/tools/setup-api.ts +49 -0
  109. package/src/mcp/tools/validate-tests.ts +42 -0
  110. package/src/tui/chat-ui.ts +150 -0
  111. package/src/web/routes/api.ts +234 -0
  112. package/src/web/routes/dashboard.ts +348 -0
  113. package/src/web/routes/runs.ts +64 -0
  114. package/src/web/schemas.ts +121 -0
  115. package/src/web/server.ts +134 -0
  116. package/src/web/static/htmx.min.js +1 -0
  117. package/src/web/static/style.css +265 -0
  118. package/src/web/views/layout.ts +46 -0
  119. package/src/web/views/results.ts +209 -0
  120. package/tests/agent/agent-loop.test.ts +61 -0
  121. package/tests/agent/context-manager.test.ts +59 -0
  122. package/tests/agent/system-prompt.test.ts +42 -0
  123. package/tests/agent/tools/diagnose-failure.test.ts +85 -0
  124. package/tests/agent/tools/explore-api.test.ts +59 -0
  125. package/tests/agent/tools/manage-environment.test.ts +78 -0
  126. package/tests/agent/tools/query-results.test.ts +77 -0
  127. package/tests/agent/tools/run-tests.test.ts +89 -0
  128. package/tests/agent/tools/send-request.test.ts +78 -0
  129. package/tests/agent/tools/validate-tests.test.ts +59 -0
  130. package/tests/ai/ai-generator.integration.test.ts +131 -0
  131. package/tests/ai/llm-client.test.ts +145 -0
  132. package/tests/ai/output-parser.test.ts +132 -0
  133. package/tests/ai/prompt-builder.test.ts +67 -0
  134. package/tests/ai/types.test.ts +55 -0
  135. package/tests/cli/args.test.ts +63 -0
  136. package/tests/cli/chat.test.ts +38 -0
  137. package/tests/cli/ci-init.test.ts +112 -0
  138. package/tests/cli/commands.test.ts +316 -0
  139. package/tests/cli/coverage.test.ts +58 -0
  140. package/tests/cli/doctor.test.ts +39 -0
  141. package/tests/cli/envs.test.ts +181 -0
  142. package/tests/cli/init.test.ts +80 -0
  143. package/tests/cli/runs.test.ts +94 -0
  144. package/tests/cli/safe-run.test.ts +103 -0
  145. package/tests/cli/update.test.ts +32 -0
  146. package/tests/core/generator/schema-utils.test.ts +108 -0
  147. package/tests/core/parser/nested-assertions.test.ts +80 -0
  148. package/tests/core/runner/root-body-assertions.test.ts +70 -0
  149. package/tests/db/chat-queries.test.ts +88 -0
  150. package/tests/db/chat-schema.test.ts +37 -0
  151. package/tests/db/environments.test.ts +131 -0
  152. package/tests/db/queries.test.ts +409 -0
  153. package/tests/db/schema.test.ts +141 -0
  154. package/tests/fixtures/.env.yaml +3 -0
  155. package/tests/fixtures/auth-token-test.yaml +8 -0
  156. package/tests/fixtures/bail/suite-a.yaml +6 -0
  157. package/tests/fixtures/bail/suite-b.yaml +6 -0
  158. package/tests/fixtures/crud.yaml +35 -0
  159. package/tests/fixtures/invalid-missing-name.yaml +5 -0
  160. package/tests/fixtures/invalid-no-method.yaml +6 -0
  161. package/tests/fixtures/petstore-auth.json +295 -0
  162. package/tests/fixtures/petstore-simple.json +151 -0
  163. package/tests/fixtures/post-only.yaml +12 -0
  164. package/tests/fixtures/simple.yaml +6 -0
  165. package/tests/fixtures/valid/.env.yaml +1 -0
  166. package/tests/fixtures/valid/a.yaml +5 -0
  167. package/tests/fixtures/valid/b.yml +5 -0
  168. package/tests/generator/coverage-scanner.test.ts +129 -0
  169. package/tests/generator/data-factory.test.ts +133 -0
  170. package/tests/generator/openapi-reader.test.ts +131 -0
  171. package/tests/integration/auth-flow.test.ts +217 -0
  172. package/tests/mcp/coverage-analysis.test.ts +64 -0
  173. package/tests/mcp/explore-api-schemas.test.ts +105 -0
  174. package/tests/mcp/explore-api.test.ts +49 -0
  175. package/tests/mcp/generate-missing-tests.test.ts +69 -0
  176. package/tests/mcp/manage-environment.test.ts +89 -0
  177. package/tests/mcp/save-test-suite.test.ts +116 -0
  178. package/tests/mcp/send-request.test.ts +79 -0
  179. package/tests/mcp/setup-api.test.ts +106 -0
  180. package/tests/mcp/tools.test.ts +248 -0
  181. package/tests/parser/schema.test.ts +134 -0
  182. package/tests/parser/variables.test.ts +227 -0
  183. package/tests/parser/yaml-parser.test.ts +69 -0
  184. package/tests/reporter/console.test.ts +256 -0
  185. package/tests/reporter/json.test.ts +98 -0
  186. package/tests/reporter/junit.test.ts +284 -0
  187. package/tests/runner/assertions.test.ts +262 -0
  188. package/tests/runner/executor.test.ts +310 -0
  189. package/tests/runner/http-client.test.ts +138 -0
  190. package/tests/web/routes.test.ts +160 -0
  191. 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
+ });