@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,94 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { unlinkSync } from "fs";
|
|
5
|
+
import { getDb, closeDb } from "../../src/db/schema.ts";
|
|
6
|
+
import { createRun, finalizeRun, saveResults } from "../../src/db/queries.ts";
|
|
7
|
+
import { runsCommand } from "../../src/cli/commands/runs.ts";
|
|
8
|
+
import type { TestRunResult } from "../../src/core/runner/types.ts";
|
|
9
|
+
|
|
10
|
+
function tmpDb(): string {
|
|
11
|
+
return join(tmpdir(), `apitool-runs-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function tryUnlink(path: string): void {
|
|
15
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
16
|
+
try { unlinkSync(path + suffix); } catch { /* ignore */ }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeSuiteResult(overrides?: Partial<TestRunResult>): TestRunResult {
|
|
21
|
+
return {
|
|
22
|
+
suite_name: "Users API",
|
|
23
|
+
started_at: "2024-01-01T00:00:00.000Z",
|
|
24
|
+
finished_at: "2024-01-01T00:00:01.000Z",
|
|
25
|
+
total: 2,
|
|
26
|
+
passed: 1,
|
|
27
|
+
failed: 1,
|
|
28
|
+
skipped: 0,
|
|
29
|
+
steps: [
|
|
30
|
+
{
|
|
31
|
+
name: "Get user",
|
|
32
|
+
status: "pass",
|
|
33
|
+
duration_ms: 100,
|
|
34
|
+
request: { method: "GET", url: "http://localhost/users/1", headers: {} },
|
|
35
|
+
response: { status: 200, headers: {}, body: '{"id":1}', duration_ms: 100 },
|
|
36
|
+
assertions: [{ field: "status", rule: "equals", passed: true, actual: 200, expected: 200 }],
|
|
37
|
+
captures: {},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "Create user",
|
|
41
|
+
status: "fail",
|
|
42
|
+
duration_ms: 200,
|
|
43
|
+
request: { method: "POST", url: "http://localhost/users", headers: {} },
|
|
44
|
+
response: { status: 500, headers: {}, body: "error", duration_ms: 200 },
|
|
45
|
+
assertions: [{ field: "status", rule: "equals", passed: false, actual: 500, expected: 201 }],
|
|
46
|
+
captures: {},
|
|
47
|
+
error: "Expected 201 but got 500",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("runsCommand", () => {
|
|
55
|
+
let dbPath: string;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
dbPath = tmpDb();
|
|
59
|
+
getDb(dbPath);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
closeDb();
|
|
64
|
+
tryUnlink(dbPath);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("list shows empty message when no runs", () => {
|
|
68
|
+
const code = runsCommand({ dbPath });
|
|
69
|
+
expect(code).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("list shows runs table", () => {
|
|
73
|
+
const runId = createRun({ started_at: new Date().toISOString(), environment: "dev" });
|
|
74
|
+
const result = makeSuiteResult();
|
|
75
|
+
saveResults(runId, [result]);
|
|
76
|
+
finalizeRun(runId, [result]);
|
|
77
|
+
const code = runsCommand({ dbPath });
|
|
78
|
+
expect(code).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("detail shows run info", () => {
|
|
82
|
+
const runId = createRun({ started_at: new Date().toISOString() });
|
|
83
|
+
const result = makeSuiteResult();
|
|
84
|
+
saveResults(runId, [result]);
|
|
85
|
+
finalizeRun(runId, [result]);
|
|
86
|
+
const code = runsCommand({ runId, dbPath });
|
|
87
|
+
expect(code).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("detail returns 1 for missing run", () => {
|
|
91
|
+
const code = runsCommand({ runId: 9999, dbPath });
|
|
92
|
+
expect(code).toBe(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, test, expect, mock, afterEach, beforeEach } from "bun:test";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { unlinkSync } from "fs";
|
|
5
|
+
import { runCommand } from "../../src/cli/commands/run.ts";
|
|
6
|
+
import { closeDb } from "../../src/db/schema.ts";
|
|
7
|
+
|
|
8
|
+
const FIXTURES = `${import.meta.dir}/../fixtures`;
|
|
9
|
+
const originalFetch = globalThis.fetch;
|
|
10
|
+
|
|
11
|
+
function tryUnlink(path: string): void {
|
|
12
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
13
|
+
try { unlinkSync(path + suffix); } catch { /* ignore */ }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function mockFetchResponses(responses: Array<{ status: number; body: unknown }>) {
|
|
18
|
+
let callIndex = 0;
|
|
19
|
+
globalThis.fetch = mock(async () => {
|
|
20
|
+
const resp = responses[callIndex++] ?? { status: 500, body: { error: "unexpected call" } };
|
|
21
|
+
return new Response(JSON.stringify(resp.body), {
|
|
22
|
+
status: resp.status,
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
});
|
|
25
|
+
}) as unknown as typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function suppressOutput() {
|
|
29
|
+
const origOut = process.stdout.write;
|
|
30
|
+
const origErr = process.stderr.write;
|
|
31
|
+
process.stdout.write = mock(() => true) as typeof process.stdout.write;
|
|
32
|
+
process.stderr.write = mock(() => true) as typeof process.stderr.write;
|
|
33
|
+
return () => {
|
|
34
|
+
process.stdout.write = origOut;
|
|
35
|
+
process.stderr.write = origErr;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("--safe mode", () => {
|
|
40
|
+
let restore: () => void;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
restore = suppressOutput();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
restore();
|
|
48
|
+
globalThis.fetch = originalFetch;
|
|
49
|
+
closeDb();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("safe mode runs only GET tests from crud suite", async () => {
|
|
53
|
+
// crud.yaml has POST, GET, DELETE — safe mode should only run GET
|
|
54
|
+
mockFetchResponses([
|
|
55
|
+
{ status: 200, body: { id: 1, name: "John", email: "john@test.com" } },
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const code = await runCommand({
|
|
59
|
+
path: `${FIXTURES}/crud.yaml`,
|
|
60
|
+
env: undefined,
|
|
61
|
+
report: "json",
|
|
62
|
+
bail: false,
|
|
63
|
+
noDb: true,
|
|
64
|
+
safe: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof mock>;
|
|
68
|
+
// Only the GET test should have run (1 call)
|
|
69
|
+
expect(fetchMock.mock.calls.length).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("safe mode with GET-only suite runs normally", async () => {
|
|
73
|
+
// simple.yaml has only GET /health
|
|
74
|
+
mockFetchResponses([
|
|
75
|
+
{ status: 200, body: { status: "ok" } },
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const code = await runCommand({
|
|
79
|
+
path: `${FIXTURES}/simple.yaml`,
|
|
80
|
+
report: "json",
|
|
81
|
+
bail: false,
|
|
82
|
+
noDb: true,
|
|
83
|
+
safe: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(code).toBe(0);
|
|
87
|
+
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof mock>;
|
|
88
|
+
expect(fetchMock.mock.calls.length).toBe(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("safe mode returns 0 when no GET tests found", async () => {
|
|
92
|
+
const code = await runCommand({
|
|
93
|
+
path: `${FIXTURES}/post-only.yaml`,
|
|
94
|
+
report: "console",
|
|
95
|
+
bail: false,
|
|
96
|
+
noDb: true,
|
|
97
|
+
safe: true,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Should return 0 (warning printed but no error)
|
|
101
|
+
expect(code).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { detectTarget, parseVersion, compareVersions } from "../../src/cli/commands/update.ts";
|
|
3
|
+
|
|
4
|
+
describe("update command", () => {
|
|
5
|
+
test("detectTarget returns valid target for current platform", () => {
|
|
6
|
+
const { target, archive } = detectTarget();
|
|
7
|
+
expect(target).toMatch(/^(linux|darwin|win)-(x64|arm64)$/);
|
|
8
|
+
expect(["tar.gz", "zip"]).toContain(archive);
|
|
9
|
+
|
|
10
|
+
if (process.platform === "win32") {
|
|
11
|
+
expect(archive).toBe("zip");
|
|
12
|
+
expect(target).toStartWith("win-");
|
|
13
|
+
} else {
|
|
14
|
+
expect(archive).toBe("tar.gz");
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("parseVersion strips v prefix", () => {
|
|
19
|
+
expect(parseVersion("v0.3.0")).toBe("0.3.0");
|
|
20
|
+
expect(parseVersion("0.3.0")).toBe("0.3.0");
|
|
21
|
+
expect(parseVersion("v1.2.3")).toBe("1.2.3");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("compareVersions compares correctly", () => {
|
|
25
|
+
expect(compareVersions("0.2.0", "0.3.0")).toBeLessThan(0);
|
|
26
|
+
expect(compareVersions("0.3.0", "0.2.0")).toBeGreaterThan(0);
|
|
27
|
+
expect(compareVersions("0.2.0", "0.2.0")).toBe(0);
|
|
28
|
+
expect(compareVersions("1.0.0", "0.9.9")).toBeGreaterThan(0);
|
|
29
|
+
expect(compareVersions("0.2.0", "0.2.1")).toBeLessThan(0);
|
|
30
|
+
expect(compareVersions("0.10.0", "0.9.0")).toBeGreaterThan(0);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { compressSchema, formatParam } from "../../../src/core/generator/schema-utils.ts";
|
|
3
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
4
|
+
|
|
5
|
+
describe("compressSchema", () => {
|
|
6
|
+
test("compresses simple object with required fields", () => {
|
|
7
|
+
const schema: OpenAPIV3.SchemaObject = {
|
|
8
|
+
type: "object",
|
|
9
|
+
required: ["name", "email"],
|
|
10
|
+
properties: {
|
|
11
|
+
name: { type: "string" },
|
|
12
|
+
email: { type: "string", format: "email" },
|
|
13
|
+
age: { type: "integer" },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const result = compressSchema(schema);
|
|
17
|
+
expect(result).toBe("{ name: string (req), email: string (req, email), age: integer }");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("compresses array of objects", () => {
|
|
21
|
+
const schema: OpenAPIV3.SchemaObject = {
|
|
22
|
+
type: "array",
|
|
23
|
+
items: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
id: { type: "integer" },
|
|
27
|
+
name: { type: "string" },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const result = compressSchema(schema);
|
|
32
|
+
expect(result).toBe("[{ id: integer, name: string }]");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("compresses enum fields", () => {
|
|
36
|
+
const schema: OpenAPIV3.SchemaObject = {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
status: { type: "string", enum: ["active", "inactive", "pending"] },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const result = compressSchema(schema);
|
|
43
|
+
expect(result).toBe("{ status: string (enum: active|inactive|pending) }");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns {…} at max depth", () => {
|
|
47
|
+
// compressSchema at depth 0 renders the outer object
|
|
48
|
+
// depth 1 renders inner properties, depth 2 renders deeper, depth > 2 returns {...}
|
|
49
|
+
const schema: OpenAPIV3.SchemaObject = {
|
|
50
|
+
type: "array",
|
|
51
|
+
items: {
|
|
52
|
+
type: "array",
|
|
53
|
+
items: {
|
|
54
|
+
type: "array",
|
|
55
|
+
items: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: { id: { type: "integer" } },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
// depth 0: [...], depth 1: [...], depth 2: [...], depth 3: {...}
|
|
63
|
+
const result = compressSchema(schema);
|
|
64
|
+
expect(result).toContain("{...}");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("handles simple type without properties", () => {
|
|
68
|
+
expect(compressSchema({ type: "string" })).toBe("string");
|
|
69
|
+
expect(compressSchema({ type: "integer" })).toBe("integer");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("handles array without items", () => {
|
|
73
|
+
expect(compressSchema({ type: "array" })).toBe("[]");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("handles schema without type", () => {
|
|
77
|
+
expect(compressSchema({})).toBe("any");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("formatParam", () => {
|
|
82
|
+
test("formats required parameter", () => {
|
|
83
|
+
const param: OpenAPIV3.ParameterObject = {
|
|
84
|
+
name: "id",
|
|
85
|
+
in: "path",
|
|
86
|
+
required: true,
|
|
87
|
+
schema: { type: "integer" },
|
|
88
|
+
};
|
|
89
|
+
expect(formatParam(param)).toBe("id: integer (req)");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("formats optional parameter", () => {
|
|
93
|
+
const param: OpenAPIV3.ParameterObject = {
|
|
94
|
+
name: "limit",
|
|
95
|
+
in: "query",
|
|
96
|
+
schema: { type: "integer" },
|
|
97
|
+
};
|
|
98
|
+
expect(formatParam(param)).toBe("limit: integer");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("defaults to string type when no schema", () => {
|
|
102
|
+
const param: OpenAPIV3.ParameterObject = {
|
|
103
|
+
name: "q",
|
|
104
|
+
in: "query",
|
|
105
|
+
};
|
|
106
|
+
expect(formatParam(param)).toBe("q: string");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { flattenBodyAssertions } from "../../../src/core/parser/schema.ts";
|
|
3
|
+
import { validateSuite } from "../../../src/core/parser/schema.ts";
|
|
4
|
+
|
|
5
|
+
describe("flattenBodyAssertions", () => {
|
|
6
|
+
test("direct dot-notation passes through unchanged", () => {
|
|
7
|
+
const input = { "a.b": { equals: 1 } };
|
|
8
|
+
expect(flattenBodyAssertions(input)).toEqual({ "a.b": { equals: 1 } });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("nested YAML flattens to dot-notation", () => {
|
|
12
|
+
const input = { a: { b: { equals: 1 } } };
|
|
13
|
+
expect(flattenBodyAssertions(input)).toEqual({ "a.b": { equals: 1 } });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("deep nesting flattens correctly", () => {
|
|
17
|
+
const input = { a: { b: { c: { type: "string" } } } };
|
|
18
|
+
expect(flattenBodyAssertions(input)).toEqual({ "a.b.c": { type: "string" } });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("mixed: assertion-level stays, nested flattens", () => {
|
|
22
|
+
const input = {
|
|
23
|
+
id: { capture: "x" },
|
|
24
|
+
meta: { status: { equals: "ok" } },
|
|
25
|
+
};
|
|
26
|
+
expect(flattenBodyAssertions(input)).toEqual({
|
|
27
|
+
id: { capture: "x" },
|
|
28
|
+
"meta.status": { equals: "ok" },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("array index via dot-notation passes through", () => {
|
|
33
|
+
const input = { "items.0.name": { equals: "x" } };
|
|
34
|
+
expect(flattenBodyAssertions(input)).toEqual({ "items.0.name": { equals: "x" } });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("_body key is not flattened", () => {
|
|
38
|
+
const input = { _body: { type: "array" } };
|
|
39
|
+
expect(flattenBodyAssertions(input)).toEqual({ _body: { type: "array" } });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("_body.length key is not flattened", () => {
|
|
43
|
+
const input = { "_body.length": { gt: 0 } };
|
|
44
|
+
expect(flattenBodyAssertions(input)).toEqual({ "_body.length": { gt: 0 } });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("multiple nested paths at same level", () => {
|
|
48
|
+
const input = {
|
|
49
|
+
category: { name: { equals: "Dogs" }, id: { type: "integer" } },
|
|
50
|
+
};
|
|
51
|
+
expect(flattenBodyAssertions(input)).toEqual({
|
|
52
|
+
"category.name": { equals: "Dogs" },
|
|
53
|
+
"category.id": { type: "integer" },
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("nested assertions through full parser", () => {
|
|
59
|
+
test("validates suite with nested body assertions", () => {
|
|
60
|
+
const raw = {
|
|
61
|
+
name: "Nested test",
|
|
62
|
+
tests: [{
|
|
63
|
+
name: "Check nested",
|
|
64
|
+
GET: "/pets/1",
|
|
65
|
+
expect: {
|
|
66
|
+
status: 200,
|
|
67
|
+
body: {
|
|
68
|
+
category: { name: { equals: "Dogs" } },
|
|
69
|
+
id: { type: "integer" },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}],
|
|
73
|
+
};
|
|
74
|
+
const suite = validateSuite(raw);
|
|
75
|
+
expect(suite.tests[0]!.expect.body).toEqual({
|
|
76
|
+
"category.name": { equals: "Dogs" },
|
|
77
|
+
id: { type: "integer" },
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { checkAssertions, extractCaptures } from "../../../src/core/runner/assertions.ts";
|
|
3
|
+
import type { HttpResponse } from "../../../src/core/runner/types.ts";
|
|
4
|
+
|
|
5
|
+
function makeResponse(body: unknown, status = 200): HttpResponse {
|
|
6
|
+
return {
|
|
7
|
+
status,
|
|
8
|
+
headers: { "content-type": "application/json" },
|
|
9
|
+
body_raw: JSON.stringify(body),
|
|
10
|
+
body_parsed: body,
|
|
11
|
+
duration_ms: 50,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("_body root body assertions", () => {
|
|
16
|
+
test("_body type: array for array response → pass", () => {
|
|
17
|
+
const results = checkAssertions(
|
|
18
|
+
{ status: 200, body: { _body: { type: "array" } } },
|
|
19
|
+
makeResponse([1, 2, 3]),
|
|
20
|
+
);
|
|
21
|
+
const bodyResult = results.find(r => r.field === "body._body");
|
|
22
|
+
expect(bodyResult).toBeDefined();
|
|
23
|
+
expect(bodyResult!.passed).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("_body type: object for object response → pass", () => {
|
|
27
|
+
const results = checkAssertions(
|
|
28
|
+
{ status: 200, body: { _body: { type: "object" } } },
|
|
29
|
+
makeResponse({ id: 1 }),
|
|
30
|
+
);
|
|
31
|
+
const bodyResult = results.find(r => r.field === "body._body");
|
|
32
|
+
expect(bodyResult!.passed).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("_body type: array for object response → fail", () => {
|
|
36
|
+
const results = checkAssertions(
|
|
37
|
+
{ status: 200, body: { _body: { type: "array" } } },
|
|
38
|
+
makeResponse({ id: 1 }),
|
|
39
|
+
);
|
|
40
|
+
const bodyResult = results.find(r => r.field === "body._body");
|
|
41
|
+
expect(bodyResult!.passed).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("_body exists: true for non-empty body → pass", () => {
|
|
45
|
+
const results = checkAssertions(
|
|
46
|
+
{ status: 200, body: { _body: { exists: true } } },
|
|
47
|
+
makeResponse([]),
|
|
48
|
+
);
|
|
49
|
+
const bodyResult = results.find(r => r.field === "body._body");
|
|
50
|
+
expect(bodyResult!.passed).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("_body.length for array → checks length property", () => {
|
|
54
|
+
const results = checkAssertions(
|
|
55
|
+
{ status: 200, body: { "_body.length": { gt: 0 } } },
|
|
56
|
+
makeResponse([1, 2, 3]),
|
|
57
|
+
);
|
|
58
|
+
const bodyResult = results.find(r => r.field === "body._body.length");
|
|
59
|
+
expect(bodyResult).toBeDefined();
|
|
60
|
+
expect(bodyResult!.passed).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("_body capture captures entire body", () => {
|
|
64
|
+
const captures = extractCaptures(
|
|
65
|
+
{ _body: { capture: "all" } },
|
|
66
|
+
[1, 2, 3],
|
|
67
|
+
);
|
|
68
|
+
expect(captures.all).toEqual([1, 2, 3]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { getDb } from "../../src/db/schema.ts";
|
|
3
|
+
import {
|
|
4
|
+
createChatSession,
|
|
5
|
+
saveChatMessage,
|
|
6
|
+
getChatMessages,
|
|
7
|
+
listChatSessions,
|
|
8
|
+
loadSessionHistory,
|
|
9
|
+
} from "../../src/db/queries.ts";
|
|
10
|
+
|
|
11
|
+
describe("chat queries", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
// Use default DB (same as query functions) and clean chat tables
|
|
14
|
+
const db = getDb();
|
|
15
|
+
db.exec("DELETE FROM chat_messages");
|
|
16
|
+
db.exec("DELETE FROM chat_sessions");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("createChatSession returns id", () => {
|
|
20
|
+
const id = createChatSession("ollama", "llama3.2:3b", "Test Session");
|
|
21
|
+
expect(typeof id).toBe("number");
|
|
22
|
+
expect(id).toBeGreaterThan(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("saveChatMessage + getChatMessages roundtrip", () => {
|
|
26
|
+
const sessionId = createChatSession("ollama", "llama3.2:3b");
|
|
27
|
+
|
|
28
|
+
saveChatMessage({ session_id: sessionId, role: "user", content: "Hello" });
|
|
29
|
+
saveChatMessage({ session_id: sessionId, role: "assistant", content: "Hi there!", input_tokens: 10, output_tokens: 20 });
|
|
30
|
+
|
|
31
|
+
const messages = getChatMessages(sessionId);
|
|
32
|
+
expect(messages).toHaveLength(2);
|
|
33
|
+
expect(messages[0]!.role).toBe("user");
|
|
34
|
+
expect(messages[0]!.content).toBe("Hello");
|
|
35
|
+
expect(messages[1]!.role).toBe("assistant");
|
|
36
|
+
expect(messages[1]!.content).toBe("Hi there!");
|
|
37
|
+
expect(messages[1]!.input_tokens).toBe(10);
|
|
38
|
+
expect(messages[1]!.output_tokens).toBe(20);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("listChatSessions ordered by last_active DESC", () => {
|
|
42
|
+
const id1 = createChatSession("ollama", "llama3.2:3b", "Session 1");
|
|
43
|
+
const id2 = createChatSession("openai", "gpt-4o", "Session 2");
|
|
44
|
+
|
|
45
|
+
// Manually set last_active to force ordering
|
|
46
|
+
const db = getDb();
|
|
47
|
+
db.prepare("UPDATE chat_sessions SET last_active = '2025-01-01T00:00:00' WHERE id = ?").run(id1);
|
|
48
|
+
db.prepare("UPDATE chat_sessions SET last_active = '2025-01-02T00:00:00' WHERE id = ?").run(id2);
|
|
49
|
+
|
|
50
|
+
const sessions = listChatSessions();
|
|
51
|
+
expect(sessions.length).toBe(2);
|
|
52
|
+
// Session 2 has more recent last_active so should be first
|
|
53
|
+
expect(sessions[0]!.id).toBe(id2);
|
|
54
|
+
expect(sessions[1]!.id).toBe(id1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("loadSessionHistory returns CoreMessage[] format", () => {
|
|
58
|
+
const sessionId = createChatSession("ollama", "llama3.2:3b");
|
|
59
|
+
|
|
60
|
+
saveChatMessage({ session_id: sessionId, role: "user", content: "Run my tests" });
|
|
61
|
+
saveChatMessage({ session_id: sessionId, role: "assistant", content: "I'll run them now" });
|
|
62
|
+
saveChatMessage({ session_id: sessionId, role: "user", content: "Thanks" });
|
|
63
|
+
|
|
64
|
+
const history = loadSessionHistory(sessionId);
|
|
65
|
+
expect(history).toHaveLength(3);
|
|
66
|
+
expect(history[0]).toEqual({ role: "user", content: "Run my tests" });
|
|
67
|
+
expect(history[1]).toEqual({ role: "assistant", content: "I'll run them now" });
|
|
68
|
+
expect(history[2]).toEqual({ role: "user", content: "Thanks" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("saveChatMessage with tool data", () => {
|
|
72
|
+
const sessionId = createChatSession("ollama", "llama3.2:3b");
|
|
73
|
+
|
|
74
|
+
saveChatMessage({
|
|
75
|
+
session_id: sessionId,
|
|
76
|
+
role: "assistant",
|
|
77
|
+
content: "Running tests...",
|
|
78
|
+
tool_name: "run_tests",
|
|
79
|
+
tool_args: JSON.stringify({ testPath: "tests/" }),
|
|
80
|
+
tool_result: JSON.stringify({ runId: 1, status: "all_passed" }),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const messages = getChatMessages(sessionId);
|
|
84
|
+
expect(messages).toHaveLength(1);
|
|
85
|
+
expect(messages[0]!.tool_name).toBe("run_tests");
|
|
86
|
+
expect(messages[0]!.tool_args).toBe(JSON.stringify({ testPath: "tests/" }));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { getDb } from "../../src/db/schema.ts";
|
|
3
|
+
|
|
4
|
+
describe("DB schema v3 — chat tables", () => {
|
|
5
|
+
test("migration sets user_version to 3", () => {
|
|
6
|
+
const db = getDb();
|
|
7
|
+
const row = db.query("PRAGMA user_version").get() as { user_version: number };
|
|
8
|
+
expect(row.user_version).toBe(5);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("chat_sessions table exists", () => {
|
|
12
|
+
const db = getDb();
|
|
13
|
+
const tables = db.query(
|
|
14
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='chat_sessions'"
|
|
15
|
+
).all() as { name: string }[];
|
|
16
|
+
expect(tables).toHaveLength(1);
|
|
17
|
+
expect(tables[0]!.name).toBe("chat_sessions");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("chat_messages table exists", () => {
|
|
21
|
+
const db = getDb();
|
|
22
|
+
const tables = db.query(
|
|
23
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='chat_messages'"
|
|
24
|
+
).all() as { name: string }[];
|
|
25
|
+
expect(tables).toHaveLength(1);
|
|
26
|
+
expect(tables[0]!.name).toBe("chat_messages");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("FK constraint: message with invalid session_id fails", () => {
|
|
30
|
+
const db = getDb();
|
|
31
|
+
expect(() => {
|
|
32
|
+
db.prepare(
|
|
33
|
+
"INSERT INTO chat_messages (session_id, role, content) VALUES (99999, 'user', 'hello')"
|
|
34
|
+
).run();
|
|
35
|
+
}).toThrow();
|
|
36
|
+
});
|
|
37
|
+
});
|