@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,175 @@
1
+ import { z } from "zod";
2
+ import type { TestSuite, TestStep, AssertionRule, TestStepExpect, SuiteConfig } from "./types.ts";
3
+
4
+ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
5
+
6
+ function extractMethodAndPath(raw: unknown): unknown {
7
+ if (typeof raw !== "object" || raw === null) return raw;
8
+ const obj = raw as Record<string, unknown>;
9
+
10
+ let foundMethod: string | undefined;
11
+ for (const method of HTTP_METHODS) {
12
+ if (method in obj) {
13
+ if (foundMethod) {
14
+ throw new Error(`Ambiguous step: found both ${foundMethod} and ${method} keys`);
15
+ }
16
+ foundMethod = method;
17
+ }
18
+ }
19
+
20
+ if (foundMethod) {
21
+ const path = obj[foundMethod];
22
+ if (typeof path !== "string") {
23
+ throw new Error(`${foundMethod} value must be a string path, got ${typeof path}`);
24
+ }
25
+ const { [foundMethod]: _, ...rest } = obj;
26
+ return { ...rest, method: foundMethod, path };
27
+ }
28
+
29
+ return raw;
30
+ }
31
+
32
+ const ASSERTION_KEYS = new Set([
33
+ "capture", "type", "equals", "contains", "matches", "gt", "lt", "exists",
34
+ ]);
35
+
36
+ /**
37
+ * Recursively flattens nested body assertion objects into dot-notation keys.
38
+ * e.g. { category: { name: { equals: "Dogs" } } } → { "category.name": { equals: "Dogs" } }
39
+ * Leaves assertion-level objects untouched (objects where all keys are ASSERTION_KEYS).
40
+ * Also skips the special `_body` key prefix.
41
+ */
42
+ export function flattenBodyAssertions(body: Record<string, unknown>): Record<string, unknown> {
43
+ const result: Record<string, unknown> = {};
44
+
45
+ function walk(obj: Record<string, unknown>, prefix: string) {
46
+ for (const [key, value] of Object.entries(obj)) {
47
+ const fullKey = prefix ? `${prefix}.${key}` : key;
48
+
49
+ if (
50
+ typeof value === "object" && value !== null && !Array.isArray(value) &&
51
+ !fullKey.startsWith("_body")
52
+ ) {
53
+ const objKeys = Object.keys(value as Record<string, unknown>);
54
+ const isAssertionRule = objKeys.length > 0 && objKeys.every(k => ASSERTION_KEYS.has(k));
55
+
56
+ if (isAssertionRule) {
57
+ result[fullKey] = value;
58
+ } else {
59
+ walk(value as Record<string, unknown>, fullKey);
60
+ }
61
+ } else {
62
+ result[fullKey] = value;
63
+ }
64
+ }
65
+ }
66
+
67
+ walk(body, "");
68
+ return result;
69
+ }
70
+
71
+ const AssertionRuleSchema: z.ZodType<AssertionRule> = z.preprocess(
72
+ (val) => {
73
+ if (typeof val === "string") return { type: val };
74
+ if (val === null || val === undefined) return { exists: true };
75
+ if (typeof val === "object" && val !== null) {
76
+ const obj = val as Record<string, unknown>;
77
+ // Coerce exists: "true"/"false" → boolean
78
+ if (typeof obj.exists === "string") {
79
+ obj.exists = obj.exists === "true";
80
+ }
81
+ return obj;
82
+ }
83
+ return val;
84
+ },
85
+ z.object({
86
+ capture: z.string().optional(),
87
+ type: z.enum(["string", "integer", "number", "boolean", "array", "object"]).optional(),
88
+ equals: z.unknown().optional(),
89
+ contains: z.string().optional(),
90
+ matches: z.string().optional(),
91
+ gt: z.number().optional(),
92
+ lt: z.number().optional(),
93
+ exists: z.boolean().optional(),
94
+ }),
95
+ ) as z.ZodType<AssertionRule>;
96
+
97
+ const TestStepExpectSchema: z.ZodType<TestStepExpect> = z.preprocess(
98
+ (val) => {
99
+ if (typeof val !== "object" || val === null) return val;
100
+ const obj = val as Record<string, unknown>;
101
+ // body: null → remove it
102
+ if (obj.body === null) {
103
+ const { body: _, ...rest } = obj;
104
+ return rest;
105
+ }
106
+ // Flatten nested body assertions into dot-notation
107
+ if (obj.body && typeof obj.body === "object" && !Array.isArray(obj.body)) {
108
+ obj.body = flattenBodyAssertions(obj.body as Record<string, unknown>);
109
+ }
110
+ return obj;
111
+ },
112
+ z.object({
113
+ status: z.number().int().optional(),
114
+ body: z.record(z.string(), AssertionRuleSchema).optional(),
115
+ headers: z.record(z.string(), z.string()).optional(),
116
+ duration: z.number().optional(),
117
+ }),
118
+ ) as z.ZodType<TestStepExpect>;
119
+
120
+ const TestStepSchema: z.ZodType<TestStep> = z.preprocess(
121
+ extractMethodAndPath,
122
+ z.object({
123
+ name: z.string(),
124
+ method: z.enum(HTTP_METHODS),
125
+ path: z.string(),
126
+ headers: z.record(z.string(), z.string()).optional(),
127
+ json: z.unknown().optional(),
128
+ form: z.record(z.string(), z.string()).optional(),
129
+ query: z.record(z.string(), z.string()).optional(),
130
+ expect: TestStepExpectSchema,
131
+ }),
132
+ ) as z.ZodType<TestStep>;
133
+
134
+ export const DEFAULT_CONFIG: SuiteConfig = {
135
+ timeout: 30000,
136
+ retries: 0,
137
+ retry_delay: 1000,
138
+ follow_redirects: true,
139
+ verify_ssl: true,
140
+ };
141
+
142
+ const SuiteConfigSchema = z.preprocess(
143
+ (val) => ({ ...DEFAULT_CONFIG, ...(typeof val === "object" && val !== null ? val : {}) }),
144
+ z.object({
145
+ timeout: z.number(),
146
+ retries: z.number(),
147
+ retry_delay: z.number(),
148
+ follow_redirects: z.boolean(),
149
+ verify_ssl: z.boolean(),
150
+ }),
151
+ ) as z.ZodType<SuiteConfig>;
152
+
153
+ const TestSuiteSchema = z.preprocess(
154
+ (val) => {
155
+ if (typeof val === "object" && val !== null && !("config" in val)) {
156
+ return { ...val, config: DEFAULT_CONFIG };
157
+ }
158
+ return val;
159
+ },
160
+ z.object({
161
+ name: z.string(),
162
+ description: z.string().optional(),
163
+ tags: z.array(z.string()).optional(),
164
+ base_url: z.string().optional(),
165
+ headers: z.record(z.string(), z.string()).optional(),
166
+ config: SuiteConfigSchema,
167
+ tests: z.array(TestStepSchema).min(1),
168
+ }),
169
+ );
170
+
171
+ export function validateSuite(raw: unknown): TestSuite {
172
+ return TestSuiteSchema.parse(raw) as TestSuite;
173
+ }
174
+
175
+ export { TestSuiteSchema, TestStepSchema, AssertionRuleSchema, ASSERTION_KEYS };
@@ -0,0 +1,50 @@
1
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
2
+
3
+ export interface AssertionRule {
4
+ capture?: string;
5
+ type?: "string" | "integer" | "number" | "boolean" | "array" | "object";
6
+ equals?: unknown;
7
+ contains?: string;
8
+ matches?: string;
9
+ gt?: number;
10
+ lt?: number;
11
+ exists?: boolean;
12
+ }
13
+
14
+ export interface TestStepExpect {
15
+ status?: number;
16
+ body?: Record<string, AssertionRule>;
17
+ headers?: Record<string, string>;
18
+ duration?: number;
19
+ }
20
+
21
+ export interface TestStep {
22
+ name: string;
23
+ method: HttpMethod;
24
+ path: string;
25
+ headers?: Record<string, string>;
26
+ json?: unknown;
27
+ form?: Record<string, string>;
28
+ query?: Record<string, string>;
29
+ expect: TestStepExpect;
30
+ }
31
+
32
+ export interface SuiteConfig {
33
+ timeout: number;
34
+ retries: number;
35
+ retry_delay: number;
36
+ follow_redirects: boolean;
37
+ verify_ssl: boolean;
38
+ }
39
+
40
+ export interface TestSuite {
41
+ name: string;
42
+ description?: string;
43
+ tags?: string[];
44
+ base_url?: string;
45
+ headers?: Record<string, string>;
46
+ config: SuiteConfig;
47
+ tests: TestStep[];
48
+ }
49
+
50
+ export type Environment = Record<string, string>;
@@ -0,0 +1,146 @@
1
+ import { dirname } from "path";
2
+ import type { TestStep } from "./types.ts";
3
+
4
+ const NAMES = ["John Smith", "Jane Doe", "Alice Brown", "Bob Wilson", "Emma Davis", "James Miller"];
5
+ const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
6
+
7
+ function randomFrom<T>(arr: T[]): T {
8
+ return arr[Math.floor(Math.random() * arr.length)]!;
9
+ }
10
+
11
+ function randomChars(len: number): string {
12
+ let result = "";
13
+ for (let i = 0; i < len; i++) {
14
+ result += CHARS[Math.floor(Math.random() * CHARS.length)];
15
+ }
16
+ return result;
17
+ }
18
+
19
+ export const GENERATORS: Record<string, () => string | number> = {
20
+ "$uuid": () => crypto.randomUUID(),
21
+ "$timestamp": () => Math.floor(Date.now() / 1000),
22
+ "$randomName": () => randomFrom(NAMES),
23
+ "$randomEmail": () => `${randomChars(8).toLowerCase()}@test.com`,
24
+ "$randomInt": () => Math.floor(Math.random() * 10000),
25
+ "$randomString": () => randomChars(8),
26
+ };
27
+
28
+ const VAR_PATTERN = /\{\{(.+?)\}\}/g;
29
+
30
+ export function substituteString(template: string, vars: Record<string, unknown>): unknown {
31
+ // If entire string is a single {{var}}, return raw value (number stays number)
32
+ const singleMatch = template.match(/^\{\{([^{}]+)\}\}$/);
33
+ if (singleMatch) {
34
+ const key = singleMatch[1]!;
35
+ if (key in vars) return vars[key];
36
+ if (key in GENERATORS) return GENERATORS[key]!();
37
+ return template;
38
+ }
39
+
40
+ // Create new regex each time to avoid lastIndex issues with /g flag
41
+ return template.replace(new RegExp(VAR_PATTERN.source, "g"), (_, key: string) => {
42
+ if (key in vars) return String(vars[key]);
43
+ if (key in GENERATORS) return String(GENERATORS[key]!());
44
+ return `{{${key}}}`;
45
+ });
46
+ }
47
+
48
+ export function substituteDeep<T>(value: T, vars: Record<string, unknown>): T {
49
+ if (typeof value === "string") {
50
+ return substituteString(value, vars) as T;
51
+ }
52
+ if (Array.isArray(value)) {
53
+ return value.map((item) => substituteDeep(item, vars)) as T;
54
+ }
55
+ if (typeof value === "object" && value !== null) {
56
+ const result: Record<string, unknown> = {};
57
+ for (const [k, v] of Object.entries(value)) {
58
+ result[k] = substituteDeep(v, vars);
59
+ }
60
+ return result as T;
61
+ }
62
+ return value;
63
+ }
64
+
65
+ export function substituteStep(step: TestStep, vars: Record<string, unknown>): TestStep {
66
+ const result: TestStep = {
67
+ ...step,
68
+ path: substituteString(step.path, vars) as string,
69
+ expect: { ...step.expect },
70
+ };
71
+
72
+ if (step.headers) {
73
+ result.headers = substituteDeep(step.headers, vars);
74
+ }
75
+ if (step.json !== undefined) {
76
+ result.json = substituteDeep(step.json, vars);
77
+ }
78
+ if (step.form) {
79
+ result.form = substituteDeep(step.form, vars);
80
+ }
81
+ if (step.query) {
82
+ result.query = substituteDeep(step.query, vars);
83
+ }
84
+ if (step.expect.body) {
85
+ result.expect.body = substituteDeep(step.expect.body, vars);
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ export function extractVariableReferences(step: TestStep): string[] {
92
+ const refs = new Set<string>();
93
+ const scan = (value: unknown): void => {
94
+ if (typeof value === "string") {
95
+ for (const match of value.matchAll(VAR_PATTERN)) {
96
+ const key = match[1]!;
97
+ if (!key.startsWith("$")) refs.add(key);
98
+ }
99
+ } else if (Array.isArray(value)) {
100
+ value.forEach(scan);
101
+ } else if (typeof value === "object" && value !== null) {
102
+ Object.values(value).forEach(scan);
103
+ }
104
+ };
105
+ scan(step);
106
+ return [...refs];
107
+ }
108
+
109
+ async function loadEnvFile(filePath: string): Promise<Record<string, string> | null> {
110
+ try {
111
+ const text = await Bun.file(filePath).text();
112
+ const parsed = Bun.YAML.parse(text);
113
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
114
+ throw new Error(`Environment file ${filePath} must contain a YAML object`);
115
+ }
116
+ const result: Record<string, string> = {};
117
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
118
+ result[k] = String(v);
119
+ }
120
+ return result;
121
+ } catch (err) {
122
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
123
+ return null;
124
+ }
125
+ }
126
+
127
+ export async function loadEnvironment(envName?: string, searchDir: string = ".", collectionId?: number): Promise<Record<string, string>> {
128
+ const fileName = envName ? `.env.${envName}.yaml` : ".env.yaml";
129
+
130
+ // Try both searchDir and parent dir — env file may be in collection root while tests are in tests/ subdir
131
+ const fileVars = await loadEnvFile(`${searchDir}/${fileName}`);
132
+ const parentFileVars = await loadEnvFile(`${dirname(searchDir)}/${fileName}`);
133
+
134
+ // DB fallback/merge: resolve scoped + global env from DB
135
+ let dbVars: Record<string, string> | null = null;
136
+ if (envName) {
137
+ try {
138
+ const { resolveEnvironment } = await import("../../db/queries.ts");
139
+ dbVars = resolveEnvironment(envName, collectionId);
140
+ } catch { /* DB not initialized — OK */ }
141
+ }
142
+
143
+ // Merge priority: dbGlobal < dbScoped < parentFile < file (local file beats everything)
144
+ const merged: Record<string, string> = { ...dbVars, ...parentFileVars, ...fileVars };
145
+ return merged;
146
+ }
@@ -0,0 +1,85 @@
1
+ import { Glob } from "bun";
2
+ import { validateSuite } from "./schema.ts";
3
+ import type { TestSuite } from "./types.ts";
4
+
5
+ export async function parseFile(filePath: string): Promise<TestSuite> {
6
+ let text: string;
7
+ try {
8
+ text = await Bun.file(filePath).text();
9
+ } catch (err) {
10
+ throw new Error(`Failed to read file ${filePath}: ${(err as Error).message}`);
11
+ }
12
+
13
+ let raw: unknown;
14
+ try {
15
+ raw = Bun.YAML.parse(text);
16
+ } catch (err) {
17
+ throw new Error(`Invalid YAML in ${filePath}: ${(err as Error).message}`);
18
+ }
19
+
20
+ try {
21
+ const suite = validateSuite(raw);
22
+ (suite as any)._source = filePath;
23
+ return suite;
24
+ } catch (err) {
25
+ throw new Error(`Validation error in ${filePath}: ${(err as Error).message}`);
26
+ }
27
+ }
28
+
29
+ export async function parseDirectory(dirPath: string): Promise<TestSuite[]> {
30
+ const glob = new Glob("**/*.{yaml,yml}");
31
+ const suites: TestSuite[] = [];
32
+
33
+ for await (const file of glob.scan({ cwd: dirPath, absolute: false })) {
34
+ // Skip environment files
35
+ if (file.match(/\.env(\..+)?\.yaml$/) || file.match(/\.env(\..+)?\.yml$/)) {
36
+ continue;
37
+ }
38
+ const fullPath = `${dirPath}/${file}`;
39
+ try {
40
+ suites.push(await parseFile(fullPath));
41
+ } catch {
42
+ // Skip files that fail to parse (e.g. invalid AI-generated YAML)
43
+ // so one bad file doesn't block the entire directory
44
+ }
45
+ }
46
+
47
+ return suites;
48
+ }
49
+
50
+ export interface ParseDirectoryResult {
51
+ suites: TestSuite[];
52
+ errors: { file: string; error: string }[];
53
+ }
54
+
55
+ export async function parseDirectorySafe(dirPath: string): Promise<ParseDirectoryResult> {
56
+ const glob = new Glob("**/*.{yaml,yml}");
57
+ const suites: TestSuite[] = [];
58
+ const errors: { file: string; error: string }[] = [];
59
+
60
+ for await (const file of glob.scan({ cwd: dirPath, absolute: false })) {
61
+ if (file.match(/\.env(\..+)?\.yaml$/) || file.match(/\.env(\..+)?\.yml$/)) {
62
+ continue;
63
+ }
64
+ const fullPath = `${dirPath}/${file}`;
65
+ try {
66
+ suites.push(await parseFile(fullPath));
67
+ } catch (err) {
68
+ errors.push({ file, error: (err as Error).message });
69
+ }
70
+ }
71
+
72
+ return { suites, errors };
73
+ }
74
+
75
+ export async function parse(path: string): Promise<TestSuite[]> {
76
+ const file = Bun.file(path);
77
+ const exists = await file.exists();
78
+
79
+ if (exists) {
80
+ return [await parseFile(path)];
81
+ }
82
+
83
+ // Not a file, try as directory
84
+ return parseDirectory(path);
85
+ }
@@ -0,0 +1,175 @@
1
+ import type { TestRunResult, StepResult, AssertionResult } from "../runner/types.ts";
2
+ import type { Reporter, ReporterOptions } from "./types.ts";
3
+
4
+ // ANSI escape codes
5
+ const RESET = "\x1b[0m";
6
+ const BOLD = "\x1b[1m";
7
+ const DIM = "\x1b[2m";
8
+ const GREEN = "\x1b[32m";
9
+ const RED = "\x1b[31m";
10
+ const GRAY = "\x1b[90m";
11
+
12
+ const PASS_ICON = "\u2713"; // ✓
13
+ const FAIL_ICON = "\u2717"; // ✗
14
+ const SKIP_ICON = "\u25CB"; // ○
15
+
16
+ export function formatDuration(ms: number): string {
17
+ if (ms < 1000) return `${Math.round(ms)}ms`;
18
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
19
+ const mins = Math.floor(ms / 60000);
20
+ const secs = Math.round((ms % 60000) / 1000);
21
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
22
+ }
23
+
24
+ export function formatStep(step: StepResult, color: boolean): string {
25
+ const duration = formatDuration(step.duration_ms);
26
+
27
+ switch (step.status) {
28
+ case "pass": {
29
+ const icon = color ? `${GREEN}${PASS_ICON}${RESET}` : PASS_ICON;
30
+ const dim = color ? `${DIM}(${duration})${RESET}` : `(${duration})`;
31
+ return ` ${icon} ${step.name} ${dim}`;
32
+ }
33
+ case "fail": {
34
+ const icon = color ? `${RED}${FAIL_ICON}${RESET}` : FAIL_ICON;
35
+ const dim = color ? `${DIM}(${duration})${RESET}` : `(${duration})`;
36
+ return ` ${icon} ${step.name} ${dim}`;
37
+ }
38
+ case "skip": {
39
+ const icon = color ? `${GRAY}${SKIP_ICON}${RESET}` : SKIP_ICON;
40
+ const label = color ? `${GRAY}(skipped)${RESET}` : "(skipped)";
41
+ return ` ${icon} ${step.name} ${label}`;
42
+ }
43
+ case "error": {
44
+ const icon = color ? `${RED}${FAIL_ICON}${RESET}` : FAIL_ICON;
45
+ const label = color ? `${DIM}(error)${RESET}` : "(error)";
46
+ return ` ${icon} ${step.name} ${label}`;
47
+ }
48
+ }
49
+ }
50
+
51
+ export function formatFailures(step: StepResult, color: boolean): string {
52
+ const lines: string[] = [];
53
+
54
+ if (step.status === "error" && step.error) {
55
+ const msg = color ? `${RED}Error: ${step.error}${RESET}` : `Error: ${step.error}`;
56
+ lines.push(` ${msg}`);
57
+ return lines.join("\n");
58
+ }
59
+
60
+ const failed = step.assertions.filter((a) => !a.passed);
61
+ for (const a of failed) {
62
+ const msg = `${a.field}: expected ${a.rule} but got ${formatValue(a.actual)}`;
63
+ lines.push(color ? ` ${RED}${msg}${RESET}` : ` ${msg}`);
64
+ }
65
+ return lines.join("\n");
66
+ }
67
+
68
+ function formatValue(value: unknown): string {
69
+ if (value === undefined) return "undefined";
70
+ if (value === null) return "null";
71
+ if (typeof value === "string") return `"${value}"`;
72
+ return String(value);
73
+ }
74
+
75
+ export function formatSuiteResult(result: TestRunResult, color: boolean): string {
76
+ const lines: string[] = [];
77
+
78
+ // Suite header
79
+ const header = color ? ` ${BOLD}${result.suite_name}${RESET}` : ` ${result.suite_name}`;
80
+ lines.push(header);
81
+
82
+ // Tags
83
+ if (result.suite_tags?.length) {
84
+ const tagsStr = result.suite_tags.map(t => `[${t}]`).join(" ");
85
+ lines.push(color ? ` ${DIM}${tagsStr}${RESET}` : ` ${tagsStr}`);
86
+ }
87
+
88
+ // Steps
89
+ for (const step of result.steps) {
90
+ lines.push(formatStep(step, color));
91
+ if (step.status === "fail" || step.status === "error") {
92
+ const details = formatFailures(step, color);
93
+ if (details) lines.push(details);
94
+ }
95
+ }
96
+
97
+ // Summary
98
+ const totalMs = Date.parse(result.finished_at) - Date.parse(result.started_at);
99
+ const duration = formatDuration(totalMs > 0 ? totalMs : 0);
100
+ const parts: string[] = [];
101
+
102
+ if (result.passed > 0) {
103
+ parts.push(color ? `${GREEN}${result.passed} passed${RESET}` : `${result.passed} passed`);
104
+ }
105
+ if (result.failed > 0) {
106
+ parts.push(color ? `${RED}${result.failed} failed${RESET}` : `${result.failed} failed`);
107
+ }
108
+ if (result.skipped > 0) {
109
+ parts.push(color ? `${GRAY}${result.skipped} skipped${RESET}` : `${result.skipped} skipped`);
110
+ }
111
+ if (parts.length === 0) {
112
+ parts.push("0 tests");
113
+ }
114
+
115
+ lines.push("");
116
+ lines.push(`Results: ${parts.join(", ")} (${duration})`);
117
+
118
+ return lines.join("\n");
119
+ }
120
+
121
+ export function formatGrandTotal(results: TestRunResult[], color: boolean): string {
122
+ const totals = { passed: 0, failed: 0, skipped: 0, total: 0 };
123
+ let minStart = Infinity;
124
+ let maxEnd = -Infinity;
125
+
126
+ for (const r of results) {
127
+ totals.passed += r.passed;
128
+ totals.failed += r.failed;
129
+ totals.skipped += r.skipped;
130
+ totals.total += r.total;
131
+ const start = Date.parse(r.started_at);
132
+ const end = Date.parse(r.finished_at);
133
+ if (start < minStart) minStart = start;
134
+ if (end > maxEnd) maxEnd = end;
135
+ }
136
+
137
+ const totalMs = maxEnd - minStart;
138
+ const duration = formatDuration(totalMs > 0 ? totalMs : 0);
139
+ const parts: string[] = [];
140
+
141
+ if (totals.passed > 0) {
142
+ parts.push(color ? `${GREEN}${totals.passed} passed${RESET}` : `${totals.passed} passed`);
143
+ }
144
+ if (totals.failed > 0) {
145
+ parts.push(color ? `${RED}${totals.failed} failed${RESET}` : `${totals.failed} failed`);
146
+ }
147
+ if (totals.skipped > 0) {
148
+ parts.push(color ? `${GRAY}${totals.skipped} skipped${RESET}` : `${totals.skipped} skipped`);
149
+ }
150
+
151
+ const header = color ? `${BOLD}Total:${RESET}` : "Total:";
152
+ return `${header} ${parts.join(", ")} (${duration})`;
153
+ }
154
+
155
+ export const consoleReporter: Reporter = {
156
+ report(results: TestRunResult[], options?: ReporterOptions): void {
157
+ const color = options?.color ?? (process.stdout.isTTY ?? false);
158
+
159
+ if (results.length === 0) {
160
+ console.log("No test suites found.");
161
+ return;
162
+ }
163
+
164
+ const blocks: string[] = [];
165
+ for (const result of results) {
166
+ blocks.push(formatSuiteResult(result, color));
167
+ }
168
+
169
+ console.log(blocks.join("\n\n"));
170
+
171
+ if (results.length > 1) {
172
+ console.log("\n" + formatGrandTotal(results, color));
173
+ }
174
+ },
175
+ };
@@ -0,0 +1,23 @@
1
+ export type { Reporter, ReporterOptions, ReporterName } from "./types.ts";
2
+ export { consoleReporter, formatDuration, formatStep, formatFailures, formatSuiteResult, formatGrandTotal } from "./console.ts";
3
+ export { jsonReporter } from "./json.ts";
4
+ export { junitReporter } from "./junit.ts";
5
+
6
+ import type { Reporter, ReporterName } from "./types.ts";
7
+ import { consoleReporter } from "./console.ts";
8
+ import { jsonReporter } from "./json.ts";
9
+ import { junitReporter } from "./junit.ts";
10
+
11
+ const reporters: Record<ReporterName, Reporter> = {
12
+ console: consoleReporter,
13
+ json: jsonReporter,
14
+ junit: junitReporter,
15
+ };
16
+
17
+ export function getReporter(name: ReporterName): Reporter {
18
+ const reporter = reporters[name];
19
+ if (!reporter) {
20
+ throw new Error(`Unknown reporter: ${name}. Available: ${Object.keys(reporters).join(", ")}`);
21
+ }
22
+ return reporter;
23
+ }
@@ -0,0 +1,9 @@
1
+ import type { TestRunResult } from "../runner/types.ts";
2
+ import type { Reporter, ReporterOptions } from "./types.ts";
3
+
4
+ export const jsonReporter: Reporter = {
5
+ report(results: TestRunResult[], _options?: ReporterOptions): void {
6
+ const json = JSON.stringify(results, null, 2);
7
+ console.log(json);
8
+ },
9
+ };