@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,262 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { checkAssertions, extractCaptures } from "../../src/core/runner/assertions.ts";
3
+ import type { HttpResponse } from "../../src/core/runner/types.ts";
4
+
5
+ function makeResponse(overrides: Partial<HttpResponse> = {}): HttpResponse {
6
+ return {
7
+ status: 200,
8
+ headers: { "content-type": "application/json" },
9
+ body: "{}",
10
+ body_parsed: {},
11
+ duration_ms: 100,
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ describe("checkAssertions", () => {
17
+ describe("status", () => {
18
+ test("passes when status matches", () => {
19
+ const results = checkAssertions({ status: 200 }, makeResponse({ status: 200 }));
20
+ expect(results).toHaveLength(1);
21
+ expect(results[0]!.passed).toBe(true);
22
+ expect(results[0]!.field).toBe("status");
23
+ });
24
+
25
+ test("fails when status does not match", () => {
26
+ const results = checkAssertions({ status: 201 }, makeResponse({ status: 200 }));
27
+ expect(results[0]!.passed).toBe(false);
28
+ expect(results[0]!.actual).toBe(200);
29
+ expect(results[0]!.expected).toBe(201);
30
+ });
31
+ });
32
+
33
+ describe("duration", () => {
34
+ test("passes when duration is within limit", () => {
35
+ const results = checkAssertions({ duration: 200 }, makeResponse({ duration_ms: 100 }));
36
+ expect(results[0]!.passed).toBe(true);
37
+ });
38
+
39
+ test("fails when duration exceeds limit", () => {
40
+ const results = checkAssertions({ duration: 50 }, makeResponse({ duration_ms: 100 }));
41
+ expect(results[0]!.passed).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe("headers", () => {
46
+ test("passes when header matches", () => {
47
+ const results = checkAssertions(
48
+ { headers: { "content-type": "application/json" } },
49
+ makeResponse({ headers: { "content-type": "application/json" } }),
50
+ );
51
+ expect(results[0]!.passed).toBe(true);
52
+ });
53
+
54
+ test("fails when header does not match", () => {
55
+ const results = checkAssertions(
56
+ { headers: { "content-type": "text/plain" } },
57
+ makeResponse({ headers: { "content-type": "application/json" } }),
58
+ );
59
+ expect(results[0]!.passed).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe("body type assertions", () => {
64
+ test("string type", () => {
65
+ const res = makeResponse({ body_parsed: { name: "John" } });
66
+ const results = checkAssertions({ body: { name: { type: "string" } } }, res);
67
+ expect(results[0]!.passed).toBe(true);
68
+ });
69
+
70
+ test("integer type", () => {
71
+ const res = makeResponse({ body_parsed: { id: 42 } });
72
+ const results = checkAssertions({ body: { id: { type: "integer" } } }, res);
73
+ expect(results[0]!.passed).toBe(true);
74
+ });
75
+
76
+ test("integer type fails for float", () => {
77
+ const res = makeResponse({ body_parsed: { val: 3.14 } });
78
+ const results = checkAssertions({ body: { val: { type: "integer" } } }, res);
79
+ expect(results[0]!.passed).toBe(false);
80
+ });
81
+
82
+ test("number type", () => {
83
+ const res = makeResponse({ body_parsed: { val: 3.14 } });
84
+ const results = checkAssertions({ body: { val: { type: "number" } } }, res);
85
+ expect(results[0]!.passed).toBe(true);
86
+ });
87
+
88
+ test("boolean type", () => {
89
+ const res = makeResponse({ body_parsed: { active: true } });
90
+ const results = checkAssertions({ body: { active: { type: "boolean" } } }, res);
91
+ expect(results[0]!.passed).toBe(true);
92
+ });
93
+
94
+ test("array type", () => {
95
+ const res = makeResponse({ body_parsed: { items: [1, 2, 3] } });
96
+ const results = checkAssertions({ body: { items: { type: "array" } } }, res);
97
+ expect(results[0]!.passed).toBe(true);
98
+ });
99
+
100
+ test("object type", () => {
101
+ const res = makeResponse({ body_parsed: { data: { nested: true } } });
102
+ const results = checkAssertions({ body: { data: { type: "object" } } }, res);
103
+ expect(results[0]!.passed).toBe(true);
104
+ });
105
+
106
+ test("object type fails for array", () => {
107
+ const res = makeResponse({ body_parsed: { data: [1] } });
108
+ const results = checkAssertions({ body: { data: { type: "object" } } }, res);
109
+ expect(results[0]!.passed).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe("body equals", () => {
114
+ test("exact primitive match", () => {
115
+ const res = makeResponse({ body_parsed: { id: 1 } });
116
+ const results = checkAssertions({ body: { id: { equals: 1 } } }, res);
117
+ expect(results[0]!.passed).toBe(true);
118
+ });
119
+
120
+ test("string match", () => {
121
+ const res = makeResponse({ body_parsed: { name: "John" } });
122
+ const results = checkAssertions({ body: { name: { equals: "John" } } }, res);
123
+ expect(results[0]!.passed).toBe(true);
124
+ });
125
+
126
+ test("loose numeric comparison", () => {
127
+ const res = makeResponse({ body_parsed: { id: 123 } });
128
+ const results = checkAssertions({ body: { id: { equals: "123" } } }, res);
129
+ expect(results[0]!.passed).toBe(true);
130
+ });
131
+
132
+ test("fails on mismatch", () => {
133
+ const res = makeResponse({ body_parsed: { id: 1 } });
134
+ const results = checkAssertions({ body: { id: { equals: 2 } } }, res);
135
+ expect(results[0]!.passed).toBe(false);
136
+ });
137
+ });
138
+
139
+ describe("body contains", () => {
140
+ test("passes when string contains substring", () => {
141
+ const res = makeResponse({ body_parsed: { msg: "hello world" } });
142
+ const results = checkAssertions({ body: { msg: { contains: "world" } } }, res);
143
+ expect(results[0]!.passed).toBe(true);
144
+ });
145
+
146
+ test("fails when not a string", () => {
147
+ const res = makeResponse({ body_parsed: { val: 42 } });
148
+ const results = checkAssertions({ body: { val: { contains: "42" } } }, res);
149
+ expect(results[0]!.passed).toBe(false);
150
+ });
151
+ });
152
+
153
+ describe("body matches", () => {
154
+ test("passes when regex matches", () => {
155
+ const res = makeResponse({ body_parsed: { email: "test@example.com" } });
156
+ const results = checkAssertions({ body: { email: { matches: ".+@.+" } } }, res);
157
+ expect(results[0]!.passed).toBe(true);
158
+ });
159
+
160
+ test("fails when regex does not match", () => {
161
+ const res = makeResponse({ body_parsed: { email: "invalid" } });
162
+ const results = checkAssertions({ body: { email: { matches: ".+@.+" } } }, res);
163
+ expect(results[0]!.passed).toBe(false);
164
+ });
165
+ });
166
+
167
+ describe("body gt/lt", () => {
168
+ test("gt passes when value is greater", () => {
169
+ const res = makeResponse({ body_parsed: { count: 10 } });
170
+ const results = checkAssertions({ body: { count: { gt: 5 } } }, res);
171
+ expect(results[0]!.passed).toBe(true);
172
+ });
173
+
174
+ test("gt fails when value is not greater", () => {
175
+ const res = makeResponse({ body_parsed: { count: 3 } });
176
+ const results = checkAssertions({ body: { count: { gt: 5 } } }, res);
177
+ expect(results[0]!.passed).toBe(false);
178
+ });
179
+
180
+ test("lt passes when value is less", () => {
181
+ const res = makeResponse({ body_parsed: { count: 3 } });
182
+ const results = checkAssertions({ body: { count: { lt: 5 } } }, res);
183
+ expect(results[0]!.passed).toBe(true);
184
+ });
185
+ });
186
+
187
+ describe("body exists", () => {
188
+ test("exists: true passes when field exists", () => {
189
+ const res = makeResponse({ body_parsed: { name: "John" } });
190
+ const results = checkAssertions({ body: { name: { exists: true } } }, res);
191
+ expect(results[0]!.passed).toBe(true);
192
+ });
193
+
194
+ test("exists: true fails when field is missing", () => {
195
+ const res = makeResponse({ body_parsed: {} });
196
+ const results = checkAssertions({ body: { name: { exists: true } } }, res);
197
+ expect(results[0]!.passed).toBe(false);
198
+ });
199
+
200
+ test("exists: false passes when field is missing", () => {
201
+ const res = makeResponse({ body_parsed: {} });
202
+ const results = checkAssertions({ body: { name: { exists: false } } }, res);
203
+ expect(results[0]!.passed).toBe(true);
204
+ });
205
+ });
206
+
207
+ describe("nested paths", () => {
208
+ test("accesses deeply nested field", () => {
209
+ const res = makeResponse({ body_parsed: { data: { user: { name: "John" } } } });
210
+ const results = checkAssertions({ body: { "data.user.name": { equals: "John" } } }, res);
211
+ expect(results[0]!.passed).toBe(true);
212
+ });
213
+ });
214
+
215
+ describe("multiple assertions on same field", () => {
216
+ test("checks both type and capture", () => {
217
+ const res = makeResponse({ body_parsed: { id: 42 } });
218
+ const results = checkAssertions({ body: { id: { type: "integer", gt: 0 } } }, res);
219
+ expect(results).toHaveLength(2);
220
+ expect(results.every((r) => r.passed)).toBe(true);
221
+ });
222
+ });
223
+
224
+ test("returns empty array when no assertions", () => {
225
+ const results = checkAssertions({}, makeResponse());
226
+ expect(results).toEqual([]);
227
+ });
228
+ });
229
+
230
+ describe("extractCaptures", () => {
231
+ test("extracts captures from response body", () => {
232
+ const captures = extractCaptures(
233
+ { id: { capture: "user_id" }, name: { type: "string" } },
234
+ { id: 42, name: "John" },
235
+ );
236
+ expect(captures).toEqual({ user_id: 42 });
237
+ });
238
+
239
+ test("extracts nested captures", () => {
240
+ const captures = extractCaptures(
241
+ { "data.id": { capture: "item_id" } },
242
+ { data: { id: 99 } },
243
+ );
244
+ expect(captures).toEqual({ item_id: 99 });
245
+ });
246
+
247
+ test("skips capture when field is undefined", () => {
248
+ const captures = extractCaptures(
249
+ { missing: { capture: "val" } },
250
+ { other: 1 },
251
+ );
252
+ expect(captures).toEqual({});
253
+ });
254
+
255
+ test("returns empty when no body rules", () => {
256
+ expect(extractCaptures(undefined, { id: 1 })).toEqual({});
257
+ });
258
+
259
+ test("returns empty when body is undefined", () => {
260
+ expect(extractCaptures({ id: { capture: "x" } }, undefined)).toEqual({});
261
+ });
262
+ });
@@ -0,0 +1,310 @@
1
+ import { describe, test, expect, mock, afterEach } from "bun:test";
2
+ import { runSuite, runSuites } from "../../src/core/runner/executor.ts";
3
+ import type { TestSuite } from "../../src/core/parser/types.ts";
4
+ import { DEFAULT_CONFIG } from "../../src/core/parser/schema.ts";
5
+
6
+ const originalFetch = globalThis.fetch;
7
+
8
+ afterEach(() => {
9
+ globalThis.fetch = originalFetch;
10
+ });
11
+
12
+ function mockFetchResponses(responses: Array<{ status: number; body: unknown; headers?: Record<string, string> }>) {
13
+ let callIndex = 0;
14
+ globalThis.fetch = mock(async () => {
15
+ const resp = responses[callIndex++] ?? { status: 500, body: { error: "unexpected call" } };
16
+ return new Response(JSON.stringify(resp.body), {
17
+ status: resp.status,
18
+ headers: { "Content-Type": "application/json", ...resp.headers },
19
+ });
20
+ }) as unknown as typeof fetch;
21
+ }
22
+
23
+ describe("runSuite", () => {
24
+ test("runs single passing step", async () => {
25
+ mockFetchResponses([{ status: 200, body: { ok: true } }]);
26
+
27
+ const suite: TestSuite = {
28
+ name: "Simple",
29
+ config: DEFAULT_CONFIG,
30
+ tests: [{
31
+ name: "Health",
32
+ method: "GET",
33
+ path: "http://example.com/health",
34
+ expect: { status: 200 },
35
+ }],
36
+ };
37
+
38
+ const result = await runSuite(suite);
39
+ expect(result.suite_name).toBe("Simple");
40
+ expect(result.total).toBe(1);
41
+ expect(result.passed).toBe(1);
42
+ expect(result.failed).toBe(0);
43
+ expect(result.steps[0]!.status).toBe("pass");
44
+ });
45
+
46
+ test("runs single failing step", async () => {
47
+ mockFetchResponses([{ status: 500, body: { error: "internal" } }]);
48
+
49
+ const suite: TestSuite = {
50
+ name: "Fail",
51
+ config: DEFAULT_CONFIG,
52
+ tests: [{
53
+ name: "Bad",
54
+ method: "GET",
55
+ path: "http://example.com/fail",
56
+ expect: { status: 200 },
57
+ }],
58
+ };
59
+
60
+ const result = await runSuite(suite);
61
+ expect(result.total).toBe(1);
62
+ expect(result.failed).toBe(1);
63
+ expect(result.steps[0]!.status).toBe("fail");
64
+ });
65
+
66
+ test("captures values and passes to subsequent steps", async () => {
67
+ mockFetchResponses([
68
+ { status: 201, body: { id: 42, name: "John" } },
69
+ { status: 200, body: { id: 42, name: "John" } },
70
+ ]);
71
+
72
+ const suite: TestSuite = {
73
+ name: "Capture chain",
74
+ base_url: "http://example.com",
75
+ config: DEFAULT_CONFIG,
76
+ tests: [
77
+ {
78
+ name: "Create",
79
+ method: "POST",
80
+ path: "/users",
81
+ json: { name: "John" },
82
+ expect: {
83
+ status: 201,
84
+ body: { id: { capture: "user_id", type: "integer" } },
85
+ },
86
+ },
87
+ {
88
+ name: "Get",
89
+ method: "GET",
90
+ path: "/users/{{user_id}}",
91
+ expect: {
92
+ status: 200,
93
+ body: { id: { equals: "{{user_id}}" } },
94
+ },
95
+ },
96
+ ],
97
+ };
98
+
99
+ const result = await runSuite(suite);
100
+ expect(result.passed).toBe(2);
101
+ expect(result.steps[0]!.captures).toEqual({ user_id: 42 });
102
+
103
+ // Verify the second request used the captured value
104
+ expect(result.steps[1]!.request.url).toBe("http://example.com/users/42");
105
+ });
106
+
107
+ test("skips steps that depend on failed captures", async () => {
108
+ mockFetchResponses([
109
+ { status: 500, body: {} }, // Create fails, capture not obtained
110
+ ]);
111
+
112
+ const suite: TestSuite = {
113
+ name: "Skip test",
114
+ base_url: "http://example.com",
115
+ config: DEFAULT_CONFIG,
116
+ tests: [
117
+ {
118
+ name: "Create",
119
+ method: "POST",
120
+ path: "/users",
121
+ expect: {
122
+ status: 201,
123
+ body: { id: { capture: "user_id", type: "integer" } },
124
+ },
125
+ },
126
+ {
127
+ name: "Get (depends on user_id)",
128
+ method: "GET",
129
+ path: "/users/{{user_id}}",
130
+ expect: { status: 200 },
131
+ },
132
+ {
133
+ name: "List (no dependency)",
134
+ method: "GET",
135
+ path: "/users",
136
+ expect: { status: 200 },
137
+ },
138
+ ],
139
+ };
140
+
141
+ // Need a third response for the "List" step
142
+ let callCount = 0;
143
+ globalThis.fetch = mock(async () => {
144
+ callCount++;
145
+ if (callCount === 1) return new Response(JSON.stringify({}), { status: 500, headers: { "Content-Type": "application/json" } });
146
+ return new Response(JSON.stringify([]), { status: 200, headers: { "Content-Type": "application/json" } });
147
+ }) as unknown as typeof fetch;
148
+
149
+ const result = await runSuite(suite);
150
+ expect(result.total).toBe(3);
151
+ expect(result.failed).toBe(1); // Create fails
152
+ expect(result.skipped).toBe(1); // Get skipped
153
+ expect(result.passed).toBe(1); // List passes
154
+ expect(result.steps[1]!.status).toBe("skip");
155
+ expect(result.steps[1]!.error).toContain("user_id");
156
+ });
157
+
158
+ test("merges suite-level headers with step headers", async () => {
159
+ let capturedHeaders: Record<string, string> = {};
160
+ globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
161
+ capturedHeaders = Object.fromEntries(new Headers(init?.headers as Record<string, string>).entries());
162
+ return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
163
+ }) as unknown as typeof fetch;
164
+
165
+ const suite: TestSuite = {
166
+ name: "Headers",
167
+ headers: { Authorization: "Bearer suite-token", "X-Suite": "yes" },
168
+ config: DEFAULT_CONFIG,
169
+ tests: [{
170
+ name: "Step",
171
+ method: "GET",
172
+ path: "http://example.com/test",
173
+ headers: { "X-Step": "yes", Authorization: "Bearer step-token" },
174
+ expect: {},
175
+ }],
176
+ };
177
+
178
+ await runSuite(suite);
179
+ expect(capturedHeaders["authorization"]).toBe("Bearer step-token"); // step overrides suite
180
+ expect(capturedHeaders["x-suite"]).toBe("yes"); // suite header preserved
181
+ expect(capturedHeaders["x-step"]).toBe("yes"); // step header added
182
+ });
183
+
184
+ test("substitutes environment variables", async () => {
185
+ let capturedUrl = "";
186
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
187
+ capturedUrl = typeof url === "string" ? url : (url as Request).url;
188
+ return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
189
+ }) as unknown as typeof fetch;
190
+
191
+ const suite: TestSuite = {
192
+ name: "Env test",
193
+ base_url: "{{base}}",
194
+ config: DEFAULT_CONFIG,
195
+ tests: [{
196
+ name: "Health",
197
+ method: "GET",
198
+ path: "/health",
199
+ expect: { status: 200 },
200
+ }],
201
+ };
202
+
203
+ await runSuite(suite, { base: "http://api.example.com" });
204
+ expect(capturedUrl).toBe("http://api.example.com/health");
205
+ });
206
+
207
+ test("auto-sets Content-Type for JSON body", async () => {
208
+ let capturedHeaders: Record<string, string> = {};
209
+ globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
210
+ capturedHeaders = Object.fromEntries(new Headers(init?.headers as Record<string, string>).entries());
211
+ return new Response("{}", { status: 201, headers: { "Content-Type": "application/json" } });
212
+ }) as unknown as typeof fetch;
213
+
214
+ const suite: TestSuite = {
215
+ name: "JSON",
216
+ config: DEFAULT_CONFIG,
217
+ tests: [{
218
+ name: "Create",
219
+ method: "POST",
220
+ path: "http://example.com/users",
221
+ json: { name: "John" },
222
+ expect: { status: 201 },
223
+ }],
224
+ };
225
+
226
+ await runSuite(suite);
227
+ expect(capturedHeaders["content-type"]).toBe("application/json");
228
+ });
229
+
230
+ test("handles fetch error with error status", async () => {
231
+ globalThis.fetch = mock(async () => {
232
+ throw new Error("Connection refused");
233
+ }) as unknown as typeof fetch;
234
+
235
+ const suite: TestSuite = {
236
+ name: "Error",
237
+ config: { ...DEFAULT_CONFIG, retries: 0 },
238
+ tests: [{
239
+ name: "Fail",
240
+ method: "GET",
241
+ path: "http://example.com/fail",
242
+ expect: { status: 200 },
243
+ }],
244
+ };
245
+
246
+ const result = await runSuite(suite);
247
+ expect(result.steps[0]!.status).toBe("error");
248
+ expect(result.steps[0]!.error).toContain("Connection refused");
249
+ });
250
+
251
+ test("builds URL with query params", async () => {
252
+ let capturedUrl = "";
253
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
254
+ capturedUrl = typeof url === "string" ? url : (url as Request).url;
255
+ return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
256
+ }) as unknown as typeof fetch;
257
+
258
+ const suite: TestSuite = {
259
+ name: "Query",
260
+ base_url: "http://example.com",
261
+ config: DEFAULT_CONFIG,
262
+ tests: [{
263
+ name: "List",
264
+ method: "GET",
265
+ path: "/users",
266
+ query: { page: "1", limit: "10" },
267
+ expect: {},
268
+ }],
269
+ };
270
+
271
+ await runSuite(suite);
272
+ expect(capturedUrl).toContain("page=1");
273
+ expect(capturedUrl).toContain("limit=10");
274
+ });
275
+
276
+ test("provides timestamps in result", async () => {
277
+ mockFetchResponses([{ status: 200, body: {} }]);
278
+
279
+ const suite: TestSuite = {
280
+ name: "Timestamps",
281
+ config: DEFAULT_CONFIG,
282
+ tests: [{ name: "T", method: "GET", path: "http://x.com/t", expect: {} }],
283
+ };
284
+
285
+ const result = await runSuite(suite);
286
+ expect(result.started_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
287
+ expect(result.finished_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
288
+ });
289
+ });
290
+
291
+ describe("runSuites", () => {
292
+ test("runs multiple suites in parallel", async () => {
293
+ let callCount = 0;
294
+ globalThis.fetch = mock(async () => {
295
+ callCount++;
296
+ return new Response(JSON.stringify({ n: callCount }), { status: 200, headers: { "Content-Type": "application/json" } });
297
+ }) as unknown as typeof fetch;
298
+
299
+ const suites: TestSuite[] = [
300
+ { name: "A", config: DEFAULT_CONFIG, tests: [{ name: "A1", method: "GET", path: "http://x.com/a", expect: { status: 200 } }] },
301
+ { name: "B", config: DEFAULT_CONFIG, tests: [{ name: "B1", method: "GET", path: "http://x.com/b", expect: { status: 200 } }] },
302
+ ];
303
+
304
+ const results = await runSuites(suites);
305
+ expect(results).toHaveLength(2);
306
+ expect(results[0]!.suite_name).toBe("A");
307
+ expect(results[1]!.suite_name).toBe("B");
308
+ expect(callCount).toBe(2);
309
+ });
310
+ });