@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,256 @@
1
+ import { describe, test, expect, mock, afterEach } from "bun:test";
2
+ import {
3
+ formatDuration,
4
+ formatStep,
5
+ formatFailures,
6
+ formatSuiteResult,
7
+ formatGrandTotal,
8
+ consoleReporter,
9
+ } from "../../src/core/reporter/console.ts";
10
+ import type { TestRunResult, StepResult } from "../../src/core/runner/types.ts";
11
+
12
+ function makeStep(overrides?: Partial<StepResult>): StepResult {
13
+ return {
14
+ name: "Test step",
15
+ status: "pass",
16
+ duration_ms: 100,
17
+ request: { method: "GET", url: "http://localhost/test", headers: {} },
18
+ response: {
19
+ status: 200,
20
+ headers: {},
21
+ body: "{}",
22
+ body_parsed: {},
23
+ duration_ms: 100,
24
+ },
25
+ assertions: [],
26
+ captures: {},
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function makeResult(overrides?: Partial<TestRunResult>): TestRunResult {
32
+ return {
33
+ suite_name: "Test Suite",
34
+ started_at: "2024-01-01T00:00:00.000Z",
35
+ finished_at: "2024-01-01T00:00:01.000Z",
36
+ total: 1,
37
+ passed: 1,
38
+ failed: 0,
39
+ skipped: 0,
40
+ steps: [makeStep()],
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ // --- formatDuration ---
46
+
47
+ describe("formatDuration", () => {
48
+ test("milliseconds for < 1000ms", () => {
49
+ expect(formatDuration(450)).toBe("450ms");
50
+ expect(formatDuration(0)).toBe("0ms");
51
+ expect(formatDuration(999)).toBe("999ms");
52
+ });
53
+
54
+ test("seconds with one decimal for >= 1000ms", () => {
55
+ expect(formatDuration(1000)).toBe("1.0s");
56
+ expect(formatDuration(1200)).toBe("1.2s");
57
+ expect(formatDuration(59999)).toBe("60.0s");
58
+ });
59
+
60
+ test("minutes + seconds for >= 60000ms", () => {
61
+ expect(formatDuration(60000)).toBe("1m");
62
+ expect(formatDuration(65000)).toBe("1m 5s");
63
+ expect(formatDuration(125000)).toBe("2m 5s");
64
+ });
65
+ });
66
+
67
+ // --- formatStep (no color) ---
68
+
69
+ describe("formatStep", () => {
70
+ test("pass step shows checkmark", () => {
71
+ const out = formatStep(makeStep({ name: "Create user", duration_ms: 450 }), false);
72
+ expect(out).toContain("\u2713");
73
+ expect(out).toContain("Create user");
74
+ expect(out).toContain("(450ms)");
75
+ });
76
+
77
+ test("fail step shows cross", () => {
78
+ const out = formatStep(makeStep({ name: "Update user", status: "fail", duration_ms: 310 }), false);
79
+ expect(out).toContain("\u2717");
80
+ expect(out).toContain("Update user");
81
+ expect(out).toContain("(310ms)");
82
+ });
83
+
84
+ test("skip step shows circle", () => {
85
+ const out = formatStep(makeStep({ name: "Verify", status: "skip", duration_ms: 0 }), false);
86
+ expect(out).toContain("\u25CB");
87
+ expect(out).toContain("Verify");
88
+ expect(out).toContain("(skipped)");
89
+ });
90
+
91
+ test("error step shows cross with error label", () => {
92
+ const out = formatStep(makeStep({ name: "Broken", status: "error", duration_ms: 0 }), false);
93
+ expect(out).toContain("\u2717");
94
+ expect(out).toContain("Broken");
95
+ expect(out).toContain("(error)");
96
+ });
97
+ });
98
+
99
+ // --- formatFailures ---
100
+
101
+ describe("formatFailures", () => {
102
+ test("shows failed assertion details", () => {
103
+ const step = makeStep({
104
+ status: "fail",
105
+ assertions: [
106
+ { field: "status", rule: "equals 200", passed: false, actual: 500, expected: 200 },
107
+ { field: "body.name", rule: "type string", passed: true, actual: "John", expected: "string" },
108
+ ],
109
+ });
110
+ const out = formatFailures(step, false);
111
+ expect(out).toContain("status: expected equals 200 but got 500");
112
+ expect(out).not.toContain("body.name"); // passed assertion not shown
113
+ });
114
+
115
+ test("shows error message for error steps", () => {
116
+ const step = makeStep({ status: "error", error: "Connection refused" });
117
+ const out = formatFailures(step, false);
118
+ expect(out).toContain("Error: Connection refused");
119
+ });
120
+
121
+ test("returns empty for step with no failures", () => {
122
+ const step = makeStep({
123
+ status: "fail",
124
+ assertions: [{ field: "status", rule: "equals 200", passed: true, actual: 200, expected: 200 }],
125
+ });
126
+ const out = formatFailures(step, false);
127
+ expect(out).toBe("");
128
+ });
129
+ });
130
+
131
+ // --- formatSuiteResult ---
132
+
133
+ describe("formatSuiteResult", () => {
134
+ test("formats mixed results correctly", () => {
135
+ const result = makeResult({
136
+ suite_name: "Users CRUD",
137
+ total: 3,
138
+ passed: 1,
139
+ failed: 1,
140
+ skipped: 1,
141
+ steps: [
142
+ makeStep({ name: "Create user", status: "pass", duration_ms: 450 }),
143
+ makeStep({
144
+ name: "Update user",
145
+ status: "fail",
146
+ duration_ms: 310,
147
+ assertions: [{ field: "status", rule: "equals 200", passed: false, actual: 500, expected: 200 }],
148
+ }),
149
+ makeStep({ name: "Verify deleted", status: "skip", duration_ms: 0 }),
150
+ ],
151
+ });
152
+
153
+ const out = formatSuiteResult(result, false);
154
+ expect(out).toContain("Users CRUD");
155
+ expect(out).toContain("\u2713");
156
+ expect(out).toContain("\u2717");
157
+ expect(out).toContain("\u25CB");
158
+ expect(out).toContain("1 passed");
159
+ expect(out).toContain("1 failed");
160
+ expect(out).toContain("1 skipped");
161
+ expect(out).toContain("status: expected equals 200 but got 500");
162
+ });
163
+
164
+ test("handles suite with zero steps", () => {
165
+ const result = makeResult({ total: 0, passed: 0, failed: 0, skipped: 0, steps: [] });
166
+ const out = formatSuiteResult(result, false);
167
+ expect(out).toContain("0 tests");
168
+ });
169
+ });
170
+
171
+ // --- formatGrandTotal ---
172
+
173
+ describe("formatGrandTotal", () => {
174
+ test("aggregates multiple suites", () => {
175
+ const results = [
176
+ makeResult({
177
+ passed: 3, failed: 0, skipped: 0, total: 3,
178
+ started_at: "2024-01-01T00:00:00.000Z",
179
+ finished_at: "2024-01-01T00:00:01.000Z",
180
+ }),
181
+ makeResult({
182
+ passed: 1, failed: 1, skipped: 1, total: 3,
183
+ started_at: "2024-01-01T00:00:00.000Z",
184
+ finished_at: "2024-01-01T00:00:02.000Z",
185
+ }),
186
+ ];
187
+
188
+ const out = formatGrandTotal(results, false);
189
+ expect(out).toContain("Total:");
190
+ expect(out).toContain("4 passed");
191
+ expect(out).toContain("1 failed");
192
+ expect(out).toContain("1 skipped");
193
+ expect(out).toContain("2.0s");
194
+ });
195
+ });
196
+
197
+ // --- consoleReporter.report ---
198
+
199
+ function captureConsoleLog() {
200
+ const origLog = console.log;
201
+ let output = "";
202
+ console.log = mock((...args: unknown[]) => {
203
+ output += args.map(String).join(" ") + "\n";
204
+ });
205
+ return {
206
+ getOutput: () => output,
207
+ restore: () => { console.log = origLog; },
208
+ };
209
+ }
210
+
211
+ describe("consoleReporter.report", () => {
212
+ let restoreFn: (() => void) | undefined;
213
+
214
+ afterEach(() => {
215
+ restoreFn?.();
216
+ });
217
+
218
+ test("writes output to stdout", () => {
219
+ const cap = captureConsoleLog();
220
+ restoreFn = cap.restore;
221
+
222
+ consoleReporter.report([makeResult({ suite_name: "My Suite" })], { color: false });
223
+ expect(cap.getOutput()).toContain("My Suite");
224
+ expect(cap.getOutput()).toContain("\u2713");
225
+ });
226
+
227
+ test("handles empty results", () => {
228
+ const cap = captureConsoleLog();
229
+ restoreFn = cap.restore;
230
+
231
+ consoleReporter.report([], { color: false });
232
+ expect(cap.getOutput()).toContain("No test suites found");
233
+ });
234
+
235
+ test("shows grand total for multiple suites", () => {
236
+ const cap = captureConsoleLog();
237
+ restoreFn = cap.restore;
238
+
239
+ consoleReporter.report(
240
+ [makeResult({ suite_name: "A" }), makeResult({ suite_name: "B" })],
241
+ { color: false },
242
+ );
243
+ const output = cap.getOutput();
244
+ expect(output).toContain("A");
245
+ expect(output).toContain("B");
246
+ expect(output).toContain("Total:");
247
+ });
248
+
249
+ test("no ANSI codes when color is false", () => {
250
+ const cap = captureConsoleLog();
251
+ restoreFn = cap.restore;
252
+
253
+ consoleReporter.report([makeResult()], { color: false });
254
+ expect(cap.getOutput()).not.toContain("\x1b[");
255
+ });
256
+ });
@@ -0,0 +1,98 @@
1
+ import { describe, test, expect, mock, afterEach } from "bun:test";
2
+ import { jsonReporter } from "../../src/core/reporter/json.ts";
3
+ import type { TestRunResult } from "../../src/core/runner/types.ts";
4
+
5
+ function makeResult(overrides?: Partial<TestRunResult>): TestRunResult {
6
+ return {
7
+ suite_name: "Test Suite",
8
+ started_at: "2024-01-01T00:00:00.000Z",
9
+ finished_at: "2024-01-01T00:00:01.000Z",
10
+ total: 1,
11
+ passed: 1,
12
+ failed: 0,
13
+ skipped: 0,
14
+ steps: [
15
+ {
16
+ name: "Step 1",
17
+ status: "pass",
18
+ duration_ms: 100,
19
+ request: { method: "GET", url: "http://localhost/test", headers: {} },
20
+ response: {
21
+ status: 200,
22
+ headers: { "content-type": "application/json" },
23
+ body: '{"ok":true}',
24
+ body_parsed: { ok: true },
25
+ duration_ms: 100,
26
+ },
27
+ assertions: [{ field: "status", rule: "equals 200", passed: true, actual: 200, expected: 200 }],
28
+ captures: {},
29
+ },
30
+ ],
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function captureConsoleLog() {
36
+ const origLog = console.log;
37
+ let output = "";
38
+ console.log = mock((...args: unknown[]) => {
39
+ output += args.map(String).join(" ") + "\n";
40
+ });
41
+ return {
42
+ getOutput: () => output,
43
+ restore: () => { console.log = origLog; },
44
+ };
45
+ }
46
+
47
+ describe("JSON Reporter", () => {
48
+ let restoreFn: (() => void) | undefined;
49
+
50
+ afterEach(() => {
51
+ restoreFn?.();
52
+ });
53
+
54
+ test("outputs valid JSON matching input", () => {
55
+ const cap = captureConsoleLog();
56
+ restoreFn = cap.restore;
57
+
58
+ const results = [makeResult()];
59
+ jsonReporter.report(results);
60
+
61
+ const parsed = JSON.parse(cap.getOutput().trim());
62
+ expect(parsed).toEqual(results);
63
+ });
64
+
65
+ test("outputs pretty-printed JSON with 2-space indent", () => {
66
+ const cap = captureConsoleLog();
67
+ restoreFn = cap.restore;
68
+
69
+ jsonReporter.report([makeResult()]);
70
+
71
+ const output = cap.getOutput();
72
+ expect(output).toContain("\n");
73
+ expect(output).toContain(' "suite_name"');
74
+ });
75
+
76
+ test("handles multiple results", () => {
77
+ const cap = captureConsoleLog();
78
+ restoreFn = cap.restore;
79
+
80
+ const results = [makeResult({ suite_name: "Suite A" }), makeResult({ suite_name: "Suite B" })];
81
+ jsonReporter.report(results);
82
+
83
+ const parsed = JSON.parse(cap.getOutput().trim());
84
+ expect(parsed).toHaveLength(2);
85
+ expect(parsed[0].suite_name).toBe("Suite A");
86
+ expect(parsed[1].suite_name).toBe("Suite B");
87
+ });
88
+
89
+ test("handles empty results", () => {
90
+ const cap = captureConsoleLog();
91
+ restoreFn = cap.restore;
92
+
93
+ jsonReporter.report([]);
94
+
95
+ const parsed = JSON.parse(cap.getOutput().trim());
96
+ expect(parsed).toEqual([]);
97
+ });
98
+ });
@@ -0,0 +1,284 @@
1
+ import { describe, test, expect, mock, afterEach } from "bun:test";
2
+ import { junitReporter } from "../../src/core/reporter/junit.ts";
3
+ import type { TestRunResult, StepResult } from "../../src/core/runner/types.ts";
4
+
5
+ // ──────────────────────────────────────────────
6
+ // Helpers
7
+ // ──────────────────────────────────────────────
8
+
9
+ function makeStep(overrides?: Partial<StepResult>): StepResult {
10
+ return {
11
+ name: "Get user",
12
+ status: "pass",
13
+ duration_ms: 450,
14
+ request: { method: "GET", url: "http://localhost/users/1", headers: {} },
15
+ response: {
16
+ status: 200,
17
+ headers: { "content-type": "application/json" },
18
+ body: '{"id":1}',
19
+ body_parsed: { id: 1 },
20
+ duration_ms: 450,
21
+ },
22
+ assertions: [{ field: "status", rule: "equals 200", passed: true, actual: 200, expected: 200 }],
23
+ captures: {},
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function makeResult(overrides?: Partial<TestRunResult>): TestRunResult {
29
+ return {
30
+ suite_name: "Users CRUD",
31
+ started_at: "2024-01-01T00:00:00.000Z",
32
+ finished_at: "2024-01-01T00:00:01.000Z",
33
+ total: 1,
34
+ passed: 1,
35
+ failed: 0,
36
+ skipped: 0,
37
+ steps: [makeStep()],
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ function captureLog() {
43
+ const origLog = console.log;
44
+ let output = "";
45
+ console.log = mock((...args: unknown[]) => {
46
+ output += args.map(String).join(" ") + "\n";
47
+ });
48
+ return {
49
+ get: () => output.trim(),
50
+ restore: () => { console.log = origLog; },
51
+ };
52
+ }
53
+
54
+ // ──────────────────────────────────────────────
55
+ // XML structure
56
+ // ──────────────────────────────────────────────
57
+
58
+ describe("JUnit Reporter — XML structure", () => {
59
+ let restoreFn: (() => void) | undefined;
60
+ afterEach(() => restoreFn?.());
61
+
62
+ test("outputs XML declaration and root element", () => {
63
+ const cap = captureLog();
64
+ restoreFn = cap.restore;
65
+
66
+ junitReporter.report([makeResult()]);
67
+ const xml = cap.get();
68
+
69
+ expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
70
+ expect(xml).toContain("<testsuites");
71
+ expect(xml).toContain("</testsuites>");
72
+ });
73
+
74
+ test("root testsuites has correct aggregated attributes", () => {
75
+ const cap = captureLog();
76
+ restoreFn = cap.restore;
77
+
78
+ const results = [
79
+ makeResult({ total: 3, passed: 2, failed: 1, steps: [makeStep(), makeStep({ status: "fail", duration_ms: 310 }), makeStep()] }),
80
+ makeResult({ suite_name: "Suite B", total: 2, passed: 2, steps: [makeStep(), makeStep()] }),
81
+ ];
82
+ junitReporter.report(results);
83
+ const xml = cap.get();
84
+
85
+ expect(xml).toContain('tests="5"');
86
+ expect(xml).toContain('failures="1"');
87
+ });
88
+
89
+ test("each result becomes a testsuite element", () => {
90
+ const cap = captureLog();
91
+ restoreFn = cap.restore;
92
+
93
+ junitReporter.report([
94
+ makeResult({ suite_name: "Suite A" }),
95
+ makeResult({ suite_name: "Suite B" }),
96
+ ]);
97
+ const xml = cap.get();
98
+
99
+ expect(xml).toContain('name="Suite A"');
100
+ expect(xml).toContain('name="Suite B"');
101
+ expect((xml.match(/<testsuite /g) ?? []).length).toBe(2);
102
+ });
103
+
104
+ test("testsuite attributes: tests, failures, errors, skipped", () => {
105
+ const cap = captureLog();
106
+ restoreFn = cap.restore;
107
+
108
+ junitReporter.report([makeResult({ total: 2, passed: 1, failed: 1, skipped: 0 })]);
109
+ const xml = cap.get();
110
+
111
+ expect(xml).toContain('tests="2"');
112
+ expect(xml).toContain('failures="1"');
113
+ expect(xml).toContain('errors="0"');
114
+ expect(xml).toContain('skipped="0"');
115
+ });
116
+
117
+ test("passing step renders self-closing testcase", () => {
118
+ const cap = captureLog();
119
+ restoreFn = cap.restore;
120
+
121
+ junitReporter.report([makeResult()]);
122
+ const xml = cap.get();
123
+
124
+ expect(xml).toContain('<testcase name="Get user"');
125
+ expect(xml).toContain('"/>');
126
+ expect(xml).not.toContain("<failure");
127
+ expect(xml).not.toContain("<error");
128
+ expect(xml).not.toContain("<skipped");
129
+ });
130
+
131
+ test("skipped step renders <skipped/> element", () => {
132
+ const cap = captureLog();
133
+ restoreFn = cap.restore;
134
+
135
+ const step = makeStep({ name: "Verify deleted", status: "skip", duration_ms: 0 });
136
+ junitReporter.report([makeResult({ total: 1, passed: 0, skipped: 1, steps: [step] })]);
137
+ const xml = cap.get();
138
+
139
+ expect(xml).toContain('<testcase name="Verify deleted"');
140
+ expect(xml).toContain("<skipped/>");
141
+ });
142
+
143
+ test("failed step renders <failure> with message", () => {
144
+ const cap = captureLog();
145
+ restoreFn = cap.restore;
146
+
147
+ const step = makeStep({
148
+ name: "Update user",
149
+ status: "fail",
150
+ duration_ms: 310,
151
+ assertions: [
152
+ { field: "status", rule: "equals 200", passed: false, actual: 500, expected: 200 },
153
+ ],
154
+ });
155
+ junitReporter.report([makeResult({ total: 1, passed: 0, failed: 1, steps: [step] })]);
156
+ const xml = cap.get();
157
+
158
+ expect(xml).toContain('<testcase name="Update user"');
159
+ expect(xml).toContain("<failure");
160
+ expect(xml).toContain("</failure>");
161
+ expect(xml).toContain("equals 200");
162
+ });
163
+
164
+ test("error step renders <error> element", () => {
165
+ const cap = captureLog();
166
+ restoreFn = cap.restore;
167
+
168
+ const step = makeStep({
169
+ name: "Create user",
170
+ status: "error",
171
+ duration_ms: 50,
172
+ error: "Connection refused",
173
+ });
174
+ junitReporter.report([makeResult({ total: 1, passed: 0, failed: 0, steps: [step] })]);
175
+ const xml = cap.get();
176
+
177
+ expect(xml).toContain("<error");
178
+ expect(xml).toContain("Connection refused");
179
+ expect(xml).toContain("</error>");
180
+ });
181
+
182
+ test("empty results produce empty testsuites", () => {
183
+ const cap = captureLog();
184
+ restoreFn = cap.restore;
185
+
186
+ junitReporter.report([]);
187
+ const xml = cap.get();
188
+
189
+ expect(xml).toContain('tests="0"');
190
+ expect(xml).toContain("<testsuites");
191
+ expect(xml).toContain("</testsuites>");
192
+ });
193
+ });
194
+
195
+ // ──────────────────────────────────────────────
196
+ // Time formatting
197
+ // ──────────────────────────────────────────────
198
+
199
+ describe("JUnit Reporter — time formatting", () => {
200
+ let restoreFn: (() => void) | undefined;
201
+ afterEach(() => restoreFn?.());
202
+
203
+ test("time is in seconds with 3 decimal places", () => {
204
+ const cap = captureLog();
205
+ restoreFn = cap.restore;
206
+
207
+ junitReporter.report([makeResult({ steps: [makeStep({ duration_ms: 450 })] })]);
208
+ const xml = cap.get();
209
+
210
+ expect(xml).toContain('time="0.450"');
211
+ });
212
+
213
+ test("aggregates step times for testsuite time", () => {
214
+ const cap = captureLog();
215
+ restoreFn = cap.restore;
216
+
217
+ const steps = [makeStep({ duration_ms: 100 }), makeStep({ duration_ms: 200 })];
218
+ junitReporter.report([makeResult({ total: 2, passed: 2, steps })]);
219
+ const xml = cap.get();
220
+
221
+ // testsuite time = 0.300
222
+ expect(xml).toContain('time="0.300"');
223
+ });
224
+
225
+ test("root testsuites time is total across all suites", () => {
226
+ const cap = captureLog();
227
+ restoreFn = cap.restore;
228
+
229
+ junitReporter.report([
230
+ makeResult({ steps: [makeStep({ duration_ms: 100 })] }),
231
+ makeResult({ suite_name: "B", steps: [makeStep({ duration_ms: 200 })] }),
232
+ ]);
233
+ const xml = cap.get();
234
+
235
+ // root time = 0.300
236
+ const match = xml.match(/<testsuites[^>]+time="([^"]+)"/);
237
+ expect(match?.[1]).toBe("0.300");
238
+ });
239
+ });
240
+
241
+ // ──────────────────────────────────────────────
242
+ // XML escaping
243
+ // ──────────────────────────────────────────────
244
+
245
+ describe("JUnit Reporter — XML escaping", () => {
246
+ let restoreFn: (() => void) | undefined;
247
+ afterEach(() => restoreFn?.());
248
+
249
+ test("escapes & < > in suite name", () => {
250
+ const cap = captureLog();
251
+ restoreFn = cap.restore;
252
+
253
+ junitReporter.report([makeResult({ suite_name: "A & B <Suite>" })]);
254
+ const xml = cap.get();
255
+
256
+ expect(xml).toContain("A &amp; B &lt;Suite&gt;");
257
+ expect(xml).not.toContain("A & B <Suite>");
258
+ });
259
+
260
+ test("escapes & < > in step name", () => {
261
+ const cap = captureLog();
262
+ restoreFn = cap.restore;
263
+
264
+ junitReporter.report([makeResult({ steps: [makeStep({ name: 'Create "item" & verify' })] })]);
265
+ const xml = cap.get();
266
+
267
+ expect(xml).toContain("Create &quot;item&quot; &amp; verify");
268
+ });
269
+
270
+ test("escapes failure message", () => {
271
+ const cap = captureLog();
272
+ restoreFn = cap.restore;
273
+
274
+ const step = makeStep({
275
+ status: "fail",
276
+ assertions: [{ field: "body", rule: "contains <html>", passed: false, actual: "<html>", expected: "json" }],
277
+ });
278
+ junitReporter.report([makeResult({ total: 1, passed: 0, failed: 1, steps: [step] })]);
279
+ const xml = cap.get();
280
+
281
+ expect(xml).toContain("&lt;html&gt;");
282
+ expect(xml).not.toContain("<html>");
283
+ });
284
+ });