// 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"); }); }); }, );