Files
socraticode/tests/integration/tools.test.ts
T
2026-02-28 17:06:21 +00:00

554 lines
18 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import fs from "node:fs";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { invalidateGraphCache, rebuildGraph } from "../../src/services/code-graph.js";
import { stopAllWatchers } from "../../src/services/watcher.js";
import { handleContextTool } from "../../src/tools/context-tools.js";
import { handleGraphTool } from "../../src/tools/graph-tools.js";
import { handleIndexTool } from "../../src/tools/index-tools.js";
import { handleManageTool } from "../../src/tools/manage-tools.js";
import { handleQueryTool } from "../../src/tools/query-tools.js";
import {
addFileToFixture,
createFixtureProject,
type FixtureProject,
isDockerAvailable,
} from "../helpers/fixtures.js";
import {
cleanupTestCollections,
waitForOllama,
waitForQdrant,
} from "../helpers/setup.js";
const dockerAvailable = isDockerAvailable();
// ─────────────────────────────────────────────────────────────
// Manage tools (codebase_health, codebase_about, codebase_list_projects)
// These have minimal infra requirements so test them first
// ─────────────────────────────────────────────────────────────
describe.skipIf(!dockerAvailable)("manage tool handlers", () => {
describe("codebase_about", () => {
it("returns descriptive text about SocratiCode", async () => {
const result = await handleManageTool("codebase_about", {});
expect(result).toContain("SocratiCode");
expect(result.length).toBeGreaterThan(100);
});
});
describe("codebase_health", () => {
it("returns health status with Docker/Qdrant/Ollama checks", async () => {
const result = await handleManageTool("codebase_health", {});
expect(result).toContain("Docker");
expect(result).toContain("Qdrant");
expect(result).toContain("Ollama");
// With Docker available, Docker should be OK
expect(result).toContain("[OK]");
});
it("shows model status", async () => {
const result = await handleManageTool("codebase_health", {});
expect(result).toContain("Embedding model");
});
});
describe("codebase_list_projects", () => {
it("returns project listing (may be empty)", async () => {
const result = await handleManageTool("codebase_list_projects", {});
// Either "No indexed projects found" or a listing
expect(result.length).toBeGreaterThan(0);
});
});
describe("unknown tool", () => {
it("returns unknown tool message", async () => {
const result = await handleManageTool("nonexistent_tool", {});
expect(result).toContain("Unknown tool");
});
});
});
// ─────────────────────────────────────────────────────────────
// Graph tools (codebase_graph_build, _query, _stats, _circular, _visualize)
// ─────────────────────────────────────────────────────────────
describe("graph tool handlers", () => {
let fixture: FixtureProject;
beforeAll(() => {
fixture = createFixtureProject("graph-tools-test");
});
afterAll(() => {
invalidateGraphCache(fixture.root);
fixture.cleanup();
});
describe("codebase_graph_build", () => {
it("starts a background graph build and returns immediately", async () => {
const result = await handleGraphTool("codebase_graph_build", {
projectPath: fixture.root,
});
expect(result).toContain("Graph build started in the background");
expect(result).toContain("codebase_graph_status");
});
});
describe("codebase_graph_stats", () => {
it("returns graph statistics", async () => {
const result = await handleGraphTool("codebase_graph_stats", {
projectPath: fixture.root,
});
expect(result).toContain("Code Graph Statistics");
expect(result).toContain("Total files:");
expect(result).toContain("Languages:");
});
it("includes orphan count", async () => {
const result = await handleGraphTool("codebase_graph_stats", {
projectPath: fixture.root,
});
expect(result).toContain("Orphan files");
});
});
describe("codebase_graph_query", () => {
it("queries file dependencies", async () => {
// Build graph directly (tool is fire-and-forget now)
await rebuildGraph(fixture.root);
const result = await handleGraphTool("codebase_graph_query", {
projectPath: fixture.root,
filePath: "src/index.ts",
});
// Should contain dependency info
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe("codebase_graph_circular", () => {
it("reports circular dependency results", async () => {
const result = await handleGraphTool("codebase_graph_circular", {
projectPath: fixture.root,
});
// The fixture has a self-cycle (index.ts imports itself)
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
describe("codebase_graph_visualize", () => {
it("returns a Mermaid diagram", async () => {
const result = await handleGraphTool("codebase_graph_visualize", {
projectPath: fixture.root,
});
expect(result).toContain("```mermaid");
expect(result).toContain("graph LR");
expect(result).toContain("```");
});
});
describe("codebase_graph_remove", () => {
it("removes the graph and confirms", async () => {
// Ensure graph exists before removing
await rebuildGraph(fixture.root);
const result = await handleGraphTool("codebase_graph_remove", {
projectPath: fixture.root,
});
expect(result).toContain("Removed");
});
});
describe("unknown tool", () => {
it("returns unknown tool message", async () => {
const result = await handleGraphTool("nonexistent_tool", {
projectPath: fixture.root,
});
expect(result).toContain("Unknown");
});
});
});
// ─────────────────────────────────────────────────────────────
// Index + Query tools (full lifecycle)
// codebase_index, codebase_update, codebase_search, codebase_status, codebase_remove
// These require Docker + embeddings
// ─────────────────────────────────────────────────────────────
describe.skipIf(!dockerAvailable)(
"index & query tool handlers",
{ timeout: 300_000 },
() => {
let fixture: FixtureProject;
beforeAll(async () => {
await waitForQdrant();
await waitForOllama();
fixture = createFixtureProject("tools-index-test");
await cleanupTestCollections(fixture.root);
}, 120_000);
afterAll(async () => {
await stopAllWatchers();
fixture.cleanup();
await cleanupTestCollections(fixture.root);
});
describe("codebase_index", () => {
it("starts indexing and returns immediately", async () => {
const result = await handleIndexTool("codebase_index", {
projectPath: fixture.root,
});
// codebase_index is fire-and-forget: returns immediately
expect(result).toContain("Indexing started");
}, 60_000);
it("reports in-progress on second call", async () => {
// Immediately after starting, the project should be indexing
const result = await handleIndexTool("codebase_index", {
projectPath: fixture.root,
});
// Should either still be indexing or have finished (depending on speed).
// We check it doesn't crash at minimum.
expect(result).toBeDefined();
}, 60_000);
});
describe("codebase_status", () => {
it("returns project status", async () => {
// Wait a moment for indexing to potentially complete
await new Promise((resolve) => setTimeout(resolve, 5_000));
const result = await handleQueryTool("codebase_status", {
projectPath: fixture.root,
});
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
}, 30_000);
});
describe("codebase_search (after indexing)", () => {
beforeAll(async () => {
// Wait for indexing to complete (poll status)
const maxWait = 120_000;
const start = Date.now();
while (Date.now() - start < maxWait) {
const status = await handleQueryTool("codebase_status", {
projectPath: fixture.root,
});
if (
status.includes("Last completed") ||
status.includes("chunks") ||
status.includes("files indexed")
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 3_000));
}
}, 180_000);
it("finds relevant code via semantic search", async () => {
const result = await handleQueryTool("codebase_search", {
query: "authentication user login",
projectPath: fixture.root,
});
// Should find something in the fixture project
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
}, 30_000);
it("respects limit parameter", async () => {
const result = await handleQueryTool("codebase_search", {
query: "function",
projectPath: fixture.root,
limit: 2,
});
expect(result).toBeDefined();
}, 30_000);
});
describe("codebase_update", () => {
beforeAll(async () => {
// Wait for background indexing (from codebase_index) to complete
const maxWait = 120_000;
const start = Date.now();
while (Date.now() - start < maxWait) {
const status = await handleQueryTool("codebase_status", {
projectPath: fixture.root,
});
if (
status.includes("Last completed") ||
(status.includes("chunks") && !status.includes("in progress"))
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 3_000));
}
}, 180_000);
it("detects no changes when nothing changed", async () => {
const result = await handleIndexTool("codebase_update", {
projectPath: fixture.root,
});
// No changes → should report zero or minimal changes
expect(result).toBeDefined();
}, 60_000);
it("detects changes after adding a file", async () => {
addFileToFixture(
fixture.root,
"src/api/routes.ts",
`
export interface Route {
path: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
handler: (req: Request) => Response;
}
export function createRouter(routes: Route[]): void {
for (const route of routes) {
console.log(\`Registered: \${route.method} \${route.path}\`);
}
}
`.trim(),
);
const result = await handleIndexTool("codebase_update", {
projectPath: fixture.root,
});
expect(result).toBeDefined();
// Should detect at least the new file
expect(result).toContain("added");
}, 60_000);
});
describe("codebase_stop", () => {
it("reports nothing to stop when no indexing is running", async () => {
const result = await handleIndexTool("codebase_stop", {
projectPath: fixture.root,
});
expect(result).toContain("No indexing operation is currently running");
});
});
describe("codebase_watch", () => {
it("starts watching", async () => {
const result = await handleIndexTool("codebase_watch", {
projectPath: fixture.root,
action: "start",
});
expect(result.toLowerCase()).toContain("watch");
}, 30_000);
it("reports watching status", async () => {
const result = await handleIndexTool("codebase_watch", {
projectPath: fixture.root,
action: "status",
});
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
it("stops watching", async () => {
const result = await handleIndexTool("codebase_watch", {
projectPath: fixture.root,
action: "stop",
});
expect(result.toLowerCase()).toContain("stop");
});
});
describe("codebase_remove", () => {
it("removes the project index", async () => {
const result = await handleIndexTool("codebase_remove", {
projectPath: fixture.root,
});
expect(result).toContain("Removed");
}, 30_000);
it("status shows not indexed after removal", async () => {
const result = await handleQueryTool("codebase_status", {
projectPath: fixture.root,
});
// Should indicate project is not indexed
expect(
result.toLowerCase().includes("not") ||
result.toLowerCase().includes("no ") ||
result.toLowerCase().includes("does not"),
).toBe(true);
}, 30_000);
});
},
);
// ─────────────────────────────────────────────────────────────
// Context tools (codebase_context, codebase_context_index,
// codebase_context_search, codebase_context_remove)
// Requires Docker + embeddings
// ─────────────────────────────────────────────────────────────
describe.skipIf(!dockerAvailable)(
"context tool handlers",
{ timeout: 300_000 },
() => {
let fixture: FixtureProject;
beforeAll(async () => {
await waitForQdrant();
await waitForOllama();
fixture = createFixtureProject("context-tools-test");
// Create artifact files
fs.mkdirSync(path.join(fixture.root, "docs"), { recursive: true });
fs.writeFileSync(
path.join(fixture.root, "docs", "schema.sql"),
[
"CREATE TABLE users (",
" id SERIAL PRIMARY KEY,",
" email VARCHAR(255) UNIQUE NOT NULL,",
" created_at TIMESTAMP DEFAULT NOW()",
");",
"",
"CREATE TABLE orders (",
" id SERIAL PRIMARY KEY,",
" user_id INTEGER REFERENCES users(id),",
" total NUMERIC(10,2) NOT NULL",
");",
].join("\n"),
);
// Create .socraticodecontextartifacts.json
fs.writeFileSync(
path.join(fixture.root, ".socraticodecontextartifacts.json"),
JSON.stringify({
artifacts: [
{
name: "database-schema",
path: "./docs/schema.sql",
description: "PostgreSQL schema with users and orders tables",
},
],
}),
);
}, 120_000);
afterAll(async () => {
// Clean up indexed artifacts
try {
await handleContextTool("codebase_context_remove", {
projectPath: fixture.root,
});
} catch {
// ignore
}
await stopAllWatchers();
fixture.cleanup();
await cleanupTestCollections(fixture.root);
});
describe("codebase_context", () => {
it("lists configured artifacts and their status", async () => {
const result = await handleContextTool("codebase_context", {
projectPath: fixture.root,
});
expect(result).toContain("Context Artifacts");
expect(result).toContain("database-schema");
expect(result).toContain("schema.sql");
});
it("shows guidance when no config exists", async () => {
// Use the base fixture root's parent (no config there)
const emptyFixture = createFixtureProject("context-empty");
try {
const result = await handleContextTool("codebase_context", {
projectPath: emptyFixture.root,
});
expect(result).toContain("No context artifacts configured");
expect(result).toContain(".socraticodecontextartifacts.json");
} finally {
emptyFixture.cleanup();
}
});
});
describe("codebase_context_index", () => {
it("indexes all configured artifacts", async () => {
const result = await handleContextTool("codebase_context_index", {
projectPath: fixture.root,
});
expect(result).toContain("Indexing Complete");
expect(result).toContain("database-schema");
expect(result).toContain("chunks");
}, 60_000);
});
describe("codebase_context_search", () => {
it("finds relevant content via semantic search", async () => {
const result = await handleContextTool("codebase_context_search", {
projectPath: fixture.root,
query: "users table email",
});
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
// Should find something from the schema
expect(result).toContain("search results");
}, 60_000);
it("requires a query parameter", async () => {
const result = await handleContextTool("codebase_context_search", {
projectPath: fixture.root,
query: "",
});
expect(result).toContain("query parameter is required");
});
});
describe("codebase_context_remove", () => {
it("removes all indexed context artifacts", async () => {
const result = await handleContextTool("codebase_context_remove", {
projectPath: fixture.root,
});
expect(result).toContain("Removed");
}, 30_000);
});
describe("unknown tool", () => {
it("returns unknown tool message", async () => {
const result = await handleContextTool("nonexistent_tool", {
projectPath: fixture.root,
});
expect(result).toContain("Unknown");
});
});
},
);