@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,78 @@
1
+ import type { TestRunResult, StepResult } from "../runner/types.ts";
2
+ import type { Reporter, ReporterOptions } from "./types.ts";
3
+
4
+ function escapeXml(str: string): string {
5
+ return str
6
+ .replace(/&/g, "&")
7
+ .replace(/</g, "&lt;")
8
+ .replace(/>/g, "&gt;")
9
+ .replace(/"/g, "&quot;")
10
+ .replace(/'/g, "&apos;");
11
+ }
12
+
13
+ function formatTime(ms: number): string {
14
+ return (ms / 1000).toFixed(3);
15
+ }
16
+
17
+ function renderTestcase(step: StepResult): string {
18
+ const time = formatTime(step.duration_ms);
19
+ const name = escapeXml(step.name);
20
+
21
+ if (step.status === "pass") {
22
+ return ` <testcase name="${name}" time="${time}"/>`;
23
+ }
24
+
25
+ if (step.status === "skip") {
26
+ return ` <testcase name="${name}" time="${time}">\n <skipped/>\n </testcase>`;
27
+ }
28
+
29
+ if (step.status === "fail") {
30
+ const failedAssertions = step.assertions.filter((a) => !a.passed);
31
+ const message = failedAssertions.length > 0
32
+ ? escapeXml(`${failedAssertions[0]!.rule}: expected ${JSON.stringify(failedAssertions[0]!.expected)}, got ${JSON.stringify(failedAssertions[0]!.actual)}`)
33
+ : escapeXml(step.error ?? "Assertion failed");
34
+ const body = failedAssertions
35
+ .map((a) => escapeXml(`${a.rule}: expected ${JSON.stringify(a.expected)}, got ${JSON.stringify(a.actual)}`))
36
+ .join("\n");
37
+ return ` <testcase name="${name}" time="${time}">\n <failure message="${message}">${body}</failure>\n </testcase>`;
38
+ }
39
+
40
+ // error
41
+ const message = escapeXml(step.error ?? "Unknown error");
42
+ return ` <testcase name="${name}" time="${time}">\n <error message="${message}">${message}</error>\n </testcase>`;
43
+ }
44
+
45
+ function renderTestsuite(result: TestRunResult): string {
46
+ const name = escapeXml(result.suite_name);
47
+ const failures = result.failed;
48
+ const tests = result.total;
49
+ const errors = result.steps.filter((s) => s.status === "error").length;
50
+ const skipped = result.skipped;
51
+ const time = formatTime(result.steps.reduce((sum, s) => sum + s.duration_ms, 0));
52
+
53
+ const testcases = result.steps.map(renderTestcase).join("\n");
54
+
55
+ return ` <testsuite name="${name}" tests="${tests}" failures="${failures}" errors="${errors}" skipped="${skipped}" time="${time}">\n${testcases}\n </testsuite>`;
56
+ }
57
+
58
+ export function generateJunitXml(results: TestRunResult[]): string {
59
+ const totalTests = results.reduce((s, r) => s + r.total, 0);
60
+ const totalFailures = results.reduce((s, r) => s + r.failed, 0);
61
+ const totalErrors = results.reduce((s, r) => s + r.steps.filter((s) => s.status === "error").length, 0);
62
+ const totalTime = formatTime(results.reduce((s, r) => s + r.steps.reduce((ss, step) => ss + step.duration_ms, 0), 0));
63
+
64
+ const suites = results.map(renderTestsuite).join("\n");
65
+
66
+ return [
67
+ `<?xml version="1.0" encoding="UTF-8"?>`,
68
+ `<testsuites tests="${totalTests}" failures="${totalFailures}" errors="${totalErrors}" time="${totalTime}">`,
69
+ suites,
70
+ `</testsuites>`,
71
+ ].join("\n");
72
+ }
73
+
74
+ export const junitReporter: Reporter = {
75
+ report(results: TestRunResult[], _options?: ReporterOptions): void {
76
+ console.log(generateJunitXml(results));
77
+ },
78
+ };
@@ -0,0 +1,12 @@
1
+ import type { TestRunResult } from "../runner/types.ts";
2
+
3
+ export interface ReporterOptions {
4
+ /** Whether to use ANSI colors. Default: auto-detect via isTTY. */
5
+ color?: boolean;
6
+ }
7
+
8
+ export type ReporterName = "console" | "json" | "junit";
9
+
10
+ export interface Reporter {
11
+ report(results: TestRunResult[], options?: ReporterOptions): void;
12
+ }
@@ -0,0 +1,172 @@
1
+ import type { TestStepExpect, AssertionRule } from "../parser/types.ts";
2
+ import type { HttpResponse, AssertionResult } from "./types.ts";
3
+ import { getByPath } from "../utils.ts";
4
+
5
+ function checkType(value: unknown, expectedType: string): boolean {
6
+ switch (expectedType) {
7
+ case "string": return typeof value === "string";
8
+ case "integer": return typeof value === "number" && Number.isInteger(value);
9
+ case "number": return typeof value === "number";
10
+ case "boolean": return typeof value === "boolean";
11
+ case "array": return Array.isArray(value);
12
+ case "object": return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ default: return false;
14
+ }
15
+ }
16
+
17
+ function describeType(value: unknown): string {
18
+ if (value === null) return "null";
19
+ if (value === undefined) return "undefined";
20
+ if (Array.isArray(value)) return "array";
21
+ if (typeof value === "number" && Number.isInteger(value)) return "integer";
22
+ return typeof value;
23
+ }
24
+
25
+ function deepEquals(a: unknown, b: unknown): boolean {
26
+ if (a === b) return true;
27
+ // Loose numeric comparison: "123" == 123
28
+ if (typeof a === "number" && typeof b === "string") return a === Number(b);
29
+ if (typeof a === "string" && typeof b === "number") return Number(a) === b;
30
+ if (typeof a !== typeof b) return false;
31
+ if (typeof a !== "object" || a === null || b === null) return false;
32
+ return JSON.stringify(a) === JSON.stringify(b);
33
+ }
34
+
35
+ function checkRule(path: string, rule: AssertionRule, actual: unknown): AssertionResult[] {
36
+ const results: AssertionResult[] = [];
37
+ const field = `body.${path}`;
38
+
39
+ if (rule.exists !== undefined) {
40
+ const doesExist = actual !== undefined && actual !== null;
41
+ results.push({
42
+ field, rule: `exists ${rule.exists}`,
43
+ passed: doesExist === rule.exists, actual: doesExist, expected: rule.exists,
44
+ });
45
+ }
46
+
47
+ if (rule.type !== undefined) {
48
+ results.push({
49
+ field, rule: `type ${rule.type}`,
50
+ passed: checkType(actual, rule.type), actual: describeType(actual), expected: rule.type,
51
+ });
52
+ }
53
+
54
+ if (rule.equals !== undefined) {
55
+ results.push({
56
+ field, rule: `equals ${JSON.stringify(rule.equals)}`,
57
+ passed: deepEquals(actual, rule.equals), actual, expected: rule.equals,
58
+ });
59
+ }
60
+
61
+ if (rule.contains !== undefined) {
62
+ const passed = typeof actual === "string" && actual.includes(rule.contains);
63
+ results.push({
64
+ field, rule: `contains "${rule.contains}"`,
65
+ passed, actual, expected: rule.contains,
66
+ });
67
+ }
68
+
69
+ if (rule.matches !== undefined) {
70
+ const passed = typeof actual === "string" && new RegExp(rule.matches).test(actual);
71
+ results.push({
72
+ field, rule: `matches ${rule.matches}`,
73
+ passed, actual, expected: rule.matches,
74
+ });
75
+ }
76
+
77
+ if (rule.gt !== undefined) {
78
+ const passed = typeof actual === "number" && actual > rule.gt;
79
+ results.push({
80
+ field, rule: `gt ${rule.gt}`,
81
+ passed, actual, expected: rule.gt,
82
+ });
83
+ }
84
+
85
+ if (rule.lt !== undefined) {
86
+ const passed = typeof actual === "number" && actual < rule.lt;
87
+ results.push({
88
+ field, rule: `lt ${rule.lt}`,
89
+ passed, actual, expected: rule.lt,
90
+ });
91
+ }
92
+
93
+ return results;
94
+ }
95
+
96
+ export function checkAssertions(expect: TestStepExpect, response: HttpResponse): AssertionResult[] {
97
+ const results: AssertionResult[] = [];
98
+
99
+ if (expect.status !== undefined) {
100
+ results.push({
101
+ field: "status",
102
+ rule: `equals ${expect.status}`,
103
+ passed: response.status === expect.status,
104
+ actual: response.status,
105
+ expected: expect.status,
106
+ });
107
+ }
108
+
109
+ if (expect.duration !== undefined) {
110
+ results.push({
111
+ field: "duration",
112
+ rule: `lte ${expect.duration}ms`,
113
+ passed: response.duration_ms <= expect.duration,
114
+ actual: response.duration_ms,
115
+ expected: expect.duration,
116
+ });
117
+ }
118
+
119
+ if (expect.headers) {
120
+ for (const [key, expectedValue] of Object.entries(expect.headers)) {
121
+ const actual = response.headers[key.toLowerCase()];
122
+ results.push({
123
+ field: `headers.${key}`,
124
+ rule: `equals "${expectedValue}"`,
125
+ passed: actual === expectedValue,
126
+ actual,
127
+ expected: expectedValue,
128
+ });
129
+ }
130
+ }
131
+
132
+ if (expect.body && response.body_parsed !== undefined) {
133
+ for (const [path, rule] of Object.entries(expect.body)) {
134
+ let actual: unknown;
135
+ if (path === "_body") {
136
+ actual = response.body_parsed;
137
+ } else if (path.startsWith("_body.")) {
138
+ actual = getByPath(response.body_parsed, path.slice(6));
139
+ } else {
140
+ actual = getByPath(response.body_parsed, path);
141
+ }
142
+ results.push(...checkRule(path, rule, actual));
143
+ }
144
+ }
145
+
146
+ return results;
147
+ }
148
+
149
+ export function extractCaptures(
150
+ bodyRules: Record<string, AssertionRule> | undefined,
151
+ responseBody: unknown,
152
+ ): Record<string, unknown> {
153
+ const captures: Record<string, unknown> = {};
154
+ if (!bodyRules || responseBody === undefined) return captures;
155
+
156
+ for (const [path, rule] of Object.entries(bodyRules)) {
157
+ if (rule.capture) {
158
+ let value: unknown;
159
+ if (path === "_body") {
160
+ value = responseBody;
161
+ } else if (path.startsWith("_body.")) {
162
+ value = getByPath(responseBody, path.slice(6));
163
+ } else {
164
+ value = getByPath(responseBody, path);
165
+ }
166
+ if (value !== undefined) {
167
+ captures[rule.capture] = value;
168
+ }
169
+ }
170
+ }
171
+ return captures;
172
+ }
@@ -0,0 +1,75 @@
1
+ import { parse } from "../parser/yaml-parser.ts";
2
+ import { loadEnvironment } from "../parser/variables.ts";
3
+ import { filterSuitesByTags } from "../parser/filter.ts";
4
+ import { runSuite } from "./executor.ts";
5
+ import { getDb } from "../../db/schema.ts";
6
+ import { createRun, finalizeRun, saveResults, findCollectionByTestPath } from "../../db/queries.ts";
7
+ import { dirname, resolve } from "path";
8
+ import { stat } from "node:fs/promises";
9
+ import type { TestRunResult } from "./types.ts";
10
+
11
+ export interface ExecuteRunOptions {
12
+ testPath: string;
13
+ envName?: string;
14
+ trigger?: string; // "cli" | "webui" | "mcp"
15
+ dbPath?: string;
16
+ safe?: boolean;
17
+ tag?: string[];
18
+ }
19
+
20
+ export interface ExecuteRunResult {
21
+ runId: number;
22
+ results: TestRunResult[];
23
+ }
24
+
25
+ export async function executeRun(options: ExecuteRunOptions): Promise<ExecuteRunResult> {
26
+ const { testPath, envName, trigger = "cli", dbPath, safe, tag } = options;
27
+
28
+ let suites = await parse(testPath);
29
+ if (suites.length === 0) {
30
+ throw new Error("No test files found");
31
+ }
32
+
33
+ // Tag filter
34
+ if (tag && tag.length > 0) {
35
+ suites = filterSuitesByTags(suites, tag);
36
+ if (suites.length === 0) {
37
+ throw new Error("No suites match the specified tags");
38
+ }
39
+ }
40
+
41
+ // Safe mode: filter to GET-only tests
42
+ if (safe) {
43
+ for (const suite of suites) {
44
+ suite.tests = suite.tests.filter(t => t.method === "GET");
45
+ }
46
+ suites = suites.filter(s => s.tests.length > 0);
47
+ if (suites.length === 0) {
48
+ throw new Error("No GET tests found. Nothing to run in safe mode.");
49
+ }
50
+ }
51
+
52
+ const fileStat = await stat(testPath).catch(() => null);
53
+ const envDir = fileStat?.isDirectory() ? testPath : dirname(testPath);
54
+
55
+ getDb(dbPath);
56
+ const resolvedPath = resolve(testPath);
57
+ const collection = findCollectionByTestPath(resolvedPath)
58
+ ?? (fileStat?.isFile() ? findCollectionByTestPath(resolve(dirname(testPath))) : null);
59
+
60
+ // If no envName given but a collection exists, fall back to "default" for DB lookup
61
+ const effectiveEnvName = envName ?? (collection ? "default" : undefined);
62
+ const env = await loadEnvironment(effectiveEnvName, envDir, collection?.id);
63
+ const results = await Promise.all(suites.map((s) => runSuite(s, env)));
64
+
65
+ const runId = createRun({
66
+ started_at: results[0]?.started_at ?? new Date().toISOString(),
67
+ environment: effectiveEnvName,
68
+ trigger,
69
+ collection_id: collection?.id,
70
+ });
71
+ finalizeRun(runId, results);
72
+ saveResults(runId, results);
73
+
74
+ return { runId, results };
75
+ }
@@ -0,0 +1,150 @@
1
+ import type { TestSuite, Environment } from "../parser/types.ts";
2
+ import { substituteString, substituteStep, substituteDeep, extractVariableReferences } from "../parser/variables.ts";
3
+ import type { TestRunResult, StepResult, HttpRequest } from "./types.ts";
4
+ import { executeRequest, type FetchOptions } from "./http-client.ts";
5
+ import { checkAssertions, extractCaptures } from "./assertions.ts";
6
+
7
+ function buildUrl(baseUrl: string | undefined, path: string, query?: Record<string, string>): string {
8
+ let url = baseUrl ? `${baseUrl.replace(/\/+$/, "")}${path}` : path;
9
+ if (query && Object.keys(query).length > 0) {
10
+ const params = new URLSearchParams(query);
11
+ url += `?${params.toString()}`;
12
+ }
13
+ return url;
14
+ }
15
+
16
+ function makeSkippedResult(stepName: string, reason: string): StepResult {
17
+ return {
18
+ name: stepName,
19
+ status: "skip",
20
+ duration_ms: 0,
21
+ request: { method: "", url: "", headers: {} },
22
+ assertions: [],
23
+ captures: {},
24
+ error: reason,
25
+ };
26
+ }
27
+
28
+ export async function runSuite(suite: TestSuite, env: Environment = {}): Promise<TestRunResult> {
29
+ const startedAt = new Date().toISOString();
30
+ const steps: StepResult[] = [];
31
+ const variables: Record<string, unknown> = { ...env };
32
+ const failedCaptures = new Set<string>();
33
+
34
+ const fetchOptions: Partial<FetchOptions> = {
35
+ timeout: suite.config.timeout,
36
+ retries: suite.config.retries,
37
+ retry_delay: suite.config.retry_delay,
38
+ follow_redirects: suite.config.follow_redirects,
39
+ };
40
+
41
+ for (const step of suite.tests) {
42
+ // Skip check: if step references a failed capture variable, skip it
43
+ const referencedVars = extractVariableReferences(step);
44
+ const missingCapture = referencedVars.find((v) => failedCaptures.has(v));
45
+ if (missingCapture) {
46
+ steps.push(makeSkippedResult(step.name, `Depends on missing capture: ${missingCapture}`));
47
+ continue;
48
+ }
49
+
50
+ // Substitute variables
51
+ const resolved = substituteStep(step, variables);
52
+
53
+ // Build request — substitute base_url and suite headers with current variables
54
+ const resolvedBaseUrl = suite.base_url ? substituteString(suite.base_url, variables) as string : undefined;
55
+ const resolvedSuiteHeaders = suite.headers ? substituteDeep(suite.headers, variables) : undefined;
56
+ const url = buildUrl(resolvedBaseUrl, resolved.path, resolved.query);
57
+ const headers: Record<string, string> = { ...resolvedSuiteHeaders, ...resolved.headers };
58
+ let body: string | undefined;
59
+
60
+ if (resolved.json !== undefined) {
61
+ body = JSON.stringify(resolved.json);
62
+ if (!headers["Content-Type"] && !headers["content-type"]) {
63
+ headers["Content-Type"] = "application/json";
64
+ }
65
+ } else if (resolved.form) {
66
+ body = new URLSearchParams(resolved.form).toString();
67
+ if (!headers["Content-Type"] && !headers["content-type"]) {
68
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
69
+ }
70
+ }
71
+
72
+ const request: HttpRequest = { method: resolved.method, url, headers, body };
73
+
74
+ try {
75
+ const response = await executeRequest(request, fetchOptions);
76
+
77
+ // Extract captures
78
+ const captures = extractCaptures(resolved.expect.body, response.body_parsed);
79
+ Object.assign(variables, captures);
80
+
81
+ // Track expected captures that weren't obtained
82
+ if (resolved.expect.body) {
83
+ for (const rule of Object.values(resolved.expect.body)) {
84
+ if (rule.capture && !(rule.capture in captures)) {
85
+ failedCaptures.add(rule.capture);
86
+ }
87
+ }
88
+ }
89
+
90
+ // Run assertions
91
+ const assertions = checkAssertions(resolved.expect, response);
92
+ const allPassed = assertions.every((a) => a.passed);
93
+
94
+ steps.push({
95
+ name: step.name,
96
+ status: allPassed ? "pass" : "fail",
97
+ duration_ms: response.duration_ms,
98
+ request,
99
+ response,
100
+ assertions,
101
+ captures,
102
+ });
103
+
104
+ // If step failed, mark its captures as unreliable
105
+ if (!allPassed && resolved.expect.body) {
106
+ for (const rule of Object.values(resolved.expect.body)) {
107
+ if (rule.capture) {
108
+ failedCaptures.add(rule.capture);
109
+ }
110
+ }
111
+ }
112
+ } catch (err) {
113
+ const errorMsg = err instanceof Error ? err.message : String(err);
114
+ steps.push({
115
+ name: step.name,
116
+ status: "error",
117
+ duration_ms: 0,
118
+ request,
119
+ assertions: [],
120
+ captures: {},
121
+ error: errorMsg,
122
+ });
123
+
124
+ // Mark any captures from this step as failed
125
+ if (step.expect.body) {
126
+ for (const rule of Object.values(step.expect.body)) {
127
+ if (rule.capture) failedCaptures.add(rule.capture);
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ const finishedAt = new Date().toISOString();
134
+ return {
135
+ suite_name: suite.name,
136
+ suite_tags: suite.tags,
137
+ suite_description: suite.description,
138
+ started_at: startedAt,
139
+ finished_at: finishedAt,
140
+ total: steps.length,
141
+ passed: steps.filter((s) => s.status === "pass").length,
142
+ failed: steps.filter((s) => s.status === "fail").length,
143
+ skipped: steps.filter((s) => s.status === "skip").length,
144
+ steps,
145
+ };
146
+ }
147
+
148
+ export async function runSuites(suites: TestSuite[], env: Environment = {}): Promise<TestRunResult[]> {
149
+ return Promise.all(suites.map((suite) => runSuite(suite, env)));
150
+ }
@@ -0,0 +1,69 @@
1
+ import type { HttpRequest, HttpResponse } from "./types.ts";
2
+
3
+ export interface FetchOptions {
4
+ timeout: number;
5
+ retries: number;
6
+ retry_delay: number;
7
+ follow_redirects: boolean;
8
+ }
9
+
10
+ export const DEFAULT_FETCH_OPTIONS: FetchOptions = {
11
+ timeout: 30000,
12
+ retries: 0,
13
+ retry_delay: 1000,
14
+ follow_redirects: true,
15
+ };
16
+
17
+ export async function executeRequest(
18
+ request: HttpRequest,
19
+ options?: Partial<FetchOptions>,
20
+ ): Promise<HttpResponse> {
21
+ const opts = { ...DEFAULT_FETCH_OPTIONS, ...options };
22
+ let lastError: Error | undefined;
23
+
24
+ for (let attempt = 0; attempt <= opts.retries; attempt++) {
25
+ if (attempt > 0) {
26
+ await Bun.sleep(opts.retry_delay);
27
+ }
28
+
29
+ try {
30
+ const controller = new AbortController();
31
+ const timeoutId = setTimeout(() => controller.abort(), opts.timeout);
32
+ const start = performance.now();
33
+
34
+ const response = await fetch(request.url, {
35
+ method: request.method,
36
+ headers: request.headers,
37
+ body: request.body ?? undefined,
38
+ signal: controller.signal,
39
+ redirect: opts.follow_redirects ? "follow" : "manual",
40
+ tls: { rejectUnauthorized: false },
41
+ });
42
+
43
+ clearTimeout(timeoutId);
44
+ const duration_ms = Math.round(performance.now() - start);
45
+
46
+ const bodyText = await response.text();
47
+ let body_parsed: unknown = undefined;
48
+ const contentType = response.headers.get("content-type") ?? "";
49
+ if (contentType.includes("application/json")) {
50
+ try {
51
+ body_parsed = JSON.parse(bodyText);
52
+ } catch {
53
+ // Body is not valid JSON despite content-type
54
+ }
55
+ }
56
+
57
+ const headers: Record<string, string> = {};
58
+ response.headers.forEach((v, k) => {
59
+ headers[k] = v;
60
+ });
61
+
62
+ return { status: response.status, headers, body: bodyText, body_parsed, duration_ms };
63
+ } catch (err) {
64
+ lastError = err instanceof Error ? err : new Error(String(err));
65
+ }
66
+ }
67
+
68
+ throw lastError!;
69
+ }
@@ -0,0 +1,12 @@
1
+ export type {
2
+ StepStatus,
3
+ HttpRequest,
4
+ HttpResponse,
5
+ AssertionResult,
6
+ StepResult,
7
+ TestRunResult,
8
+ } from "./types.ts";
9
+
10
+ export { executeRequest, type FetchOptions, DEFAULT_FETCH_OPTIONS } from "./http-client.ts";
11
+ export { checkAssertions, extractCaptures } from "./assertions.ts";
12
+ export { runSuite, runSuites } from "./executor.ts";
@@ -0,0 +1,48 @@
1
+ export type StepStatus = "pass" | "fail" | "skip" | "error";
2
+
3
+ export interface HttpRequest {
4
+ method: string;
5
+ url: string;
6
+ headers: Record<string, string>;
7
+ body?: string;
8
+ }
9
+
10
+ export interface HttpResponse {
11
+ status: number;
12
+ headers: Record<string, string>;
13
+ body: string;
14
+ body_parsed?: unknown;
15
+ duration_ms: number;
16
+ }
17
+
18
+ export interface AssertionResult {
19
+ field: string;
20
+ rule: string;
21
+ passed: boolean;
22
+ actual: unknown;
23
+ expected: unknown;
24
+ }
25
+
26
+ export interface StepResult {
27
+ name: string;
28
+ status: StepStatus;
29
+ duration_ms: number;
30
+ request: HttpRequest;
31
+ response?: HttpResponse;
32
+ assertions: AssertionResult[];
33
+ captures: Record<string, unknown>;
34
+ error?: string;
35
+ }
36
+
37
+ export interface TestRunResult {
38
+ suite_name: string;
39
+ suite_tags?: string[];
40
+ suite_description?: string;
41
+ started_at: string;
42
+ finished_at: string;
43
+ total: number;
44
+ passed: number;
45
+ failed: number;
46
+ skipped: number;
47
+ steps: StepResult[];
48
+ }