@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.
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/release.yml +97 -0
- package/.mcp.json +9 -0
- package/APITOOL.md +195 -0
- package/BACKLOG.md +62 -0
- package/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/bun.lock +291 -0
- package/docs/GLOSSARY.md +182 -0
- package/docs/INDEX.md +21 -0
- package/docs/agent.md +135 -0
- package/docs/archive/APITOOL-pre-M22.md +831 -0
- package/docs/archive/BACKLOG-AI-NATIVE.md +56 -0
- package/docs/archive/M1-M2-parser-runner.md +216 -0
- package/docs/archive/M4-M7-reporter-cli.md +179 -0
- package/docs/archive/M5-M7-storage-junit.md +300 -0
- package/docs/archive/M6-webui.md +339 -0
- package/docs/ci.md +274 -0
- package/docs/generation-issues.md +67 -0
- package/generated/.env.yaml +3 -0
- package/install.ps1 +80 -0
- package/install.sh +113 -0
- package/package.json +46 -0
- package/scripts/run-mocked-tests.ts +45 -0
- package/seed-demo.ts +53 -0
- package/self-tests/auth.yaml +18 -0
- package/self-tests/collections-crud.yaml +46 -0
- package/self-tests/environments-crud.yaml +48 -0
- package/self-tests/export.yaml +32 -0
- package/self-tests/runs.yaml +16 -0
- package/src/bun-types.d.ts +5 -0
- package/src/cli/commands/add-api.ts +51 -0
- package/src/cli/commands/ai-generate.ts +106 -0
- package/src/cli/commands/chat.ts +43 -0
- package/src/cli/commands/ci-init.ts +126 -0
- package/src/cli/commands/collections.ts +41 -0
- package/src/cli/commands/coverage.ts +65 -0
- package/src/cli/commands/doctor.ts +127 -0
- package/src/cli/commands/envs.ts +218 -0
- package/src/cli/commands/init.ts +84 -0
- package/src/cli/commands/mcp.ts +16 -0
- package/src/cli/commands/run.ts +137 -0
- package/src/cli/commands/runs.ts +108 -0
- package/src/cli/commands/serve.ts +22 -0
- package/src/cli/commands/update.ts +142 -0
- package/src/cli/commands/validate.ts +18 -0
- package/src/cli/index.ts +500 -0
- package/src/cli/output.ts +24 -0
- package/src/cli/runtime.ts +7 -0
- package/src/core/agent/agent-loop.ts +116 -0
- package/src/core/agent/context-manager.ts +41 -0
- package/src/core/agent/system-prompt.ts +33 -0
- package/src/core/agent/tools/diagnose-failure.ts +51 -0
- package/src/core/agent/tools/explore-api.ts +40 -0
- package/src/core/agent/tools/index.ts +48 -0
- package/src/core/agent/tools/manage-environment.ts +40 -0
- package/src/core/agent/tools/query-results.ts +40 -0
- package/src/core/agent/tools/run-tests.ts +38 -0
- package/src/core/agent/tools/send-request.ts +44 -0
- package/src/core/agent/tools/validate-tests.ts +23 -0
- package/src/core/agent/types.ts +22 -0
- package/src/core/generator/ai/ai-generator.ts +61 -0
- package/src/core/generator/ai/llm-client.ts +159 -0
- package/src/core/generator/ai/output-parser.ts +307 -0
- package/src/core/generator/ai/prompt-builder.ts +153 -0
- package/src/core/generator/ai/types.ts +56 -0
- package/src/core/generator/coverage-scanner.ts +87 -0
- package/src/core/generator/data-factory.ts +115 -0
- package/src/core/generator/index.ts +10 -0
- package/src/core/generator/openapi-reader.ts +142 -0
- package/src/core/generator/schema-utils.ts +52 -0
- package/src/core/generator/serializer.ts +189 -0
- package/src/core/generator/types.ts +47 -0
- package/src/core/parser/filter.ts +14 -0
- package/src/core/parser/index.ts +21 -0
- package/src/core/parser/schema.ts +175 -0
- package/src/core/parser/types.ts +50 -0
- package/src/core/parser/variables.ts +146 -0
- package/src/core/parser/yaml-parser.ts +85 -0
- package/src/core/reporter/console.ts +175 -0
- package/src/core/reporter/index.ts +23 -0
- package/src/core/reporter/json.ts +9 -0
- package/src/core/reporter/junit.ts +78 -0
- package/src/core/reporter/types.ts +12 -0
- package/src/core/runner/assertions.ts +172 -0
- package/src/core/runner/execute-run.ts +75 -0
- package/src/core/runner/executor.ts +150 -0
- package/src/core/runner/http-client.ts +69 -0
- package/src/core/runner/index.ts +12 -0
- package/src/core/runner/types.ts +48 -0
- package/src/core/setup-api.ts +97 -0
- package/src/core/utils.ts +9 -0
- package/src/db/queries.ts +868 -0
- package/src/db/schema.ts +215 -0
- package/src/mcp/server.ts +47 -0
- package/src/mcp/tools/ci-init.ts +57 -0
- package/src/mcp/tools/coverage-analysis.ts +58 -0
- package/src/mcp/tools/explore-api.ts +84 -0
- package/src/mcp/tools/generate-missing-tests.ts +80 -0
- package/src/mcp/tools/generate-tests-guide.ts +353 -0
- package/src/mcp/tools/manage-environment.ts +123 -0
- package/src/mcp/tools/manage-server.ts +87 -0
- package/src/mcp/tools/query-db.ts +141 -0
- package/src/mcp/tools/run-tests.ts +66 -0
- package/src/mcp/tools/save-test-suite.ts +164 -0
- package/src/mcp/tools/send-request.ts +53 -0
- package/src/mcp/tools/setup-api.ts +49 -0
- package/src/mcp/tools/validate-tests.ts +42 -0
- package/src/tui/chat-ui.ts +150 -0
- package/src/web/routes/api.ts +234 -0
- package/src/web/routes/dashboard.ts +348 -0
- package/src/web/routes/runs.ts +64 -0
- package/src/web/schemas.ts +121 -0
- package/src/web/server.ts +134 -0
- package/src/web/static/htmx.min.js +1 -0
- package/src/web/static/style.css +265 -0
- package/src/web/views/layout.ts +46 -0
- package/src/web/views/results.ts +209 -0
- package/tests/agent/agent-loop.test.ts +61 -0
- package/tests/agent/context-manager.test.ts +59 -0
- package/tests/agent/system-prompt.test.ts +42 -0
- package/tests/agent/tools/diagnose-failure.test.ts +85 -0
- package/tests/agent/tools/explore-api.test.ts +59 -0
- package/tests/agent/tools/manage-environment.test.ts +78 -0
- package/tests/agent/tools/query-results.test.ts +77 -0
- package/tests/agent/tools/run-tests.test.ts +89 -0
- package/tests/agent/tools/send-request.test.ts +78 -0
- package/tests/agent/tools/validate-tests.test.ts +59 -0
- package/tests/ai/ai-generator.integration.test.ts +131 -0
- package/tests/ai/llm-client.test.ts +145 -0
- package/tests/ai/output-parser.test.ts +132 -0
- package/tests/ai/prompt-builder.test.ts +67 -0
- package/tests/ai/types.test.ts +55 -0
- package/tests/cli/args.test.ts +63 -0
- package/tests/cli/chat.test.ts +38 -0
- package/tests/cli/ci-init.test.ts +112 -0
- package/tests/cli/commands.test.ts +316 -0
- package/tests/cli/coverage.test.ts +58 -0
- package/tests/cli/doctor.test.ts +39 -0
- package/tests/cli/envs.test.ts +181 -0
- package/tests/cli/init.test.ts +80 -0
- package/tests/cli/runs.test.ts +94 -0
- package/tests/cli/safe-run.test.ts +103 -0
- package/tests/cli/update.test.ts +32 -0
- package/tests/core/generator/schema-utils.test.ts +108 -0
- package/tests/core/parser/nested-assertions.test.ts +80 -0
- package/tests/core/runner/root-body-assertions.test.ts +70 -0
- package/tests/db/chat-queries.test.ts +88 -0
- package/tests/db/chat-schema.test.ts +37 -0
- package/tests/db/environments.test.ts +131 -0
- package/tests/db/queries.test.ts +409 -0
- package/tests/db/schema.test.ts +141 -0
- package/tests/fixtures/.env.yaml +3 -0
- package/tests/fixtures/auth-token-test.yaml +8 -0
- package/tests/fixtures/bail/suite-a.yaml +6 -0
- package/tests/fixtures/bail/suite-b.yaml +6 -0
- package/tests/fixtures/crud.yaml +35 -0
- package/tests/fixtures/invalid-missing-name.yaml +5 -0
- package/tests/fixtures/invalid-no-method.yaml +6 -0
- package/tests/fixtures/petstore-auth.json +295 -0
- package/tests/fixtures/petstore-simple.json +151 -0
- package/tests/fixtures/post-only.yaml +12 -0
- package/tests/fixtures/simple.yaml +6 -0
- package/tests/fixtures/valid/.env.yaml +1 -0
- package/tests/fixtures/valid/a.yaml +5 -0
- package/tests/fixtures/valid/b.yml +5 -0
- package/tests/generator/coverage-scanner.test.ts +129 -0
- package/tests/generator/data-factory.test.ts +133 -0
- package/tests/generator/openapi-reader.test.ts +131 -0
- package/tests/integration/auth-flow.test.ts +217 -0
- package/tests/mcp/coverage-analysis.test.ts +64 -0
- package/tests/mcp/explore-api-schemas.test.ts +105 -0
- package/tests/mcp/explore-api.test.ts +49 -0
- package/tests/mcp/generate-missing-tests.test.ts +69 -0
- package/tests/mcp/manage-environment.test.ts +89 -0
- package/tests/mcp/save-test-suite.test.ts +116 -0
- package/tests/mcp/send-request.test.ts +79 -0
- package/tests/mcp/setup-api.test.ts +106 -0
- package/tests/mcp/tools.test.ts +248 -0
- package/tests/parser/schema.test.ts +134 -0
- package/tests/parser/variables.test.ts +227 -0
- package/tests/parser/yaml-parser.test.ts +69 -0
- package/tests/reporter/console.test.ts +256 -0
- package/tests/reporter/json.test.ts +98 -0
- package/tests/reporter/junit.test.ts +284 -0
- package/tests/runner/assertions.test.ts +262 -0
- package/tests/runner/executor.test.ts +310 -0
- package/tests/runner/http-client.test.ts +138 -0
- package/tests/web/routes.test.ts +160 -0
- 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, "<")
|
|
8
|
+
.replace(/>/g, ">")
|
|
9
|
+
.replace(/"/g, """)
|
|
10
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|