// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import { beforeEach, describe, expect, it, vi } from "vitest"; // ── Mocks ──────────────────────────────────────────────────────────────── // All external service dependencies are mocked so these tests run without Docker. vi.mock("../../src/services/docker.js", () => ({ isDockerAvailable: vi.fn(), isQdrantRunning: vi.fn(), })); vi.mock("../../src/services/qdrant.js", () => ({ listCodebaseCollections: vi.fn(), })); vi.mock("../../src/services/indexer.js", () => ({ getPersistedIndexingStatus: vi.fn(), indexProject: vi.fn(), updateProjectIndex: vi.fn(), getIndexingInProgressProjects: vi.fn().mockReturnValue([]), requestCancellation: vi.fn(), })); vi.mock("../../src/services/lock.js", () => ({ getLockHolderPid: vi.fn(), releaseAllLocks: vi.fn().mockResolvedValue(undefined), })); vi.mock("../../src/services/watcher.js", () => ({ isWatching: vi.fn().mockReturnValue(false), startWatching: vi.fn().mockResolvedValue(true), stopAllWatchers: vi.fn().mockResolvedValue(undefined), })); vi.mock("../../src/services/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); // Mock constants — default to "managed" mode vi.mock("../../src/constants.js", async (importOriginal) => { const original = await importOriginal>(); return { ...original, QDRANT_MODE: "managed" }; }); // Config uses real SHA-256 logic — don't mock // vi.mock("../../src/config.js") is NOT mocked import { collectionName, projectIdFromPath } from "../../src/config.js"; import { isDockerAvailable, isQdrantRunning } from "../../src/services/docker.js"; import { getIndexingInProgressProjects, getPersistedIndexingStatus, indexProject, requestCancellation, updateProjectIndex } from "../../src/services/indexer.js"; import { getLockHolderPid, releaseAllLocks } from "../../src/services/lock.js"; import { logger } from "../../src/services/logger.js"; import { listCodebaseCollections } from "../../src/services/qdrant.js"; import { autoResumeIndexedProjects, awaitActiveIndexing, gracefulShutdown } from "../../src/services/startup.js"; import { isWatching, startWatching, stopAllWatchers } from "../../src/services/watcher.js"; // Typed mock helpers const mockIsDockerAvailable = vi.mocked(isDockerAvailable); const mockIsQdrantRunning = vi.mocked(isQdrantRunning); const mockListCollections = vi.mocked(listCodebaseCollections); const mockGetPersistedStatus = vi.mocked(getPersistedIndexingStatus); const mockIndexProject = vi.mocked(indexProject); const mockUpdateProjectIndex = vi.mocked(updateProjectIndex); const mockGetIndexingInProgress = vi.mocked(getIndexingInProgressProjects); const mockRequestCancellation = vi.mocked(requestCancellation); const mockGetLockHolderPid = vi.mocked(getLockHolderPid); const mockIsWatching = vi.mocked(isWatching); const mockStartWatching = vi.mocked(startWatching); const mockStopAllWatchers = vi.mocked(stopAllWatchers); const mockReleaseAllLocks = vi.mocked(releaseAllLocks); // A plausible project path for tests const TEST_PROJECT = "/tmp/test-project"; beforeEach(() => { vi.clearAllMocks(); // Default: Docker and Qdrant are running mockIsDockerAvailable.mockResolvedValue(true); mockIsQdrantRunning.mockResolvedValue(true); mockGetIndexingInProgress.mockReturnValue([]); }); // ── autoResumeIndexedProjects ──────────────────────────────────────────── describe("autoResumeIndexedProjects", () => { it("skips when Docker is not available (managed mode)", async () => { mockIsDockerAvailable.mockResolvedValue(false); await autoResumeIndexedProjects(TEST_PROJECT); expect(mockIsDockerAvailable).toHaveBeenCalled(); expect(mockListCollections).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith("Auto-resume: Docker not available, skipping"); }); it("skips when Qdrant is not running (managed mode)", async () => { mockIsQdrantRunning.mockResolvedValue(false); await autoResumeIndexedProjects(TEST_PROJECT); expect(mockIsQdrantRunning).toHaveBeenCalled(); expect(mockListCollections).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith("Auto-resume: Qdrant not running, skipping"); }); it("skips when projectPath is root (/)", async () => { await autoResumeIndexedProjects("/"); expect(mockListCollections).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith( "Auto-resume: CWD is root/home, no specific project — skipping", expect.objectContaining({ projectPath: "/" }), ); }); it("skips when projectPath is HOME", async () => { const home = process.env.HOME ?? "/tmp"; await autoResumeIndexedProjects(home); expect(mockListCollections).not.toHaveBeenCalled(); }); it("skips when project has no collection (not yet indexed)", async () => { mockListCollections.mockResolvedValue([]); await autoResumeIndexedProjects(TEST_PROJECT); expect(mockListCollections).toHaveBeenCalled(); expect(mockGetPersistedStatus).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith( "Auto-resume: current project not yet indexed, skipping", expect.objectContaining({ projectPath: TEST_PROJECT }), ); }); it("skips incomplete index recovery when another process holds the lock", async () => { const pid = projectIdFromPath(TEST_PROJECT); const coll = collectionName(pid); mockListCollections.mockResolvedValue([coll]); mockGetPersistedStatus.mockResolvedValue("in-progress"); mockGetLockHolderPid.mockResolvedValue(12345); await autoResumeIndexedProjects(TEST_PROJECT); expect(mockGetLockHolderPid).toHaveBeenCalledWith(TEST_PROJECT, "index"); expect(mockIndexProject).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith( "Auto-resume: skipping — another process is still indexing this project", expect.objectContaining({ orphanPid: 12345 }), ); }); it("resumes incomplete index when no lock holder exists", async () => { const pid = projectIdFromPath(TEST_PROJECT); const coll = collectionName(pid); mockListCollections.mockResolvedValue([coll]); mockGetPersistedStatus.mockResolvedValue("in-progress"); mockGetLockHolderPid.mockResolvedValue(null); const indexResult = { filesIndexed: 8, chunksCreated: 100, cancelled: false }; mockIndexProject.mockResolvedValue(indexResult); await autoResumeIndexedProjects(TEST_PROJECT); expect(mockIndexProject).toHaveBeenCalledWith(TEST_PROJECT, expect.any(Function)); expect(logger.info).toHaveBeenCalledWith( "Auto-resume: detected incomplete index, resuming indexing", expect.objectContaining({ projectPath: TEST_PROJECT }), ); // Wait a tick for the .then() chain to resolve await vi.waitFor(() => { expect(logger.info).toHaveBeenCalledWith( "Auto-resume: incomplete index recovery completed", expect.objectContaining({ filesIndexed: 8, chunksCreated: 100, cancelled: false, }), ); }); }); it("starts watcher after successful incomplete index recovery", async () => { const pid = projectIdFromPath(TEST_PROJECT); const coll = collectionName(pid); mockListCollections.mockResolvedValue([coll]); mockGetPersistedStatus.mockResolvedValue("in-progress"); mockGetLockHolderPid.mockResolvedValue(null); mockIsWatching.mockReturnValue(false); mockIndexProject.mockResolvedValue({ filesIndexed: 1, chunksCreated: 10, cancelled: false }); await autoResumeIndexedProjects(TEST_PROJECT); await vi.waitFor(() => { expect(mockStartWatching).toHaveBeenCalledWith(TEST_PROJECT); }); }); it("does not start watcher after cancelled recovery", async () => { const pid = projectIdFromPath(TEST_PROJECT); const coll = collectionName(pid); mockListCollections.mockResolvedValue([coll]); mockGetPersistedStatus.mockResolvedValue("in-progress"); mockGetLockHolderPid.mockResolvedValue(null); mockIndexProject.mockResolvedValue({ filesIndexed: 1, chunksCreated: 10, cancelled: true }); await autoResumeIndexedProjects(TEST_PROJECT); // Give the .then() time to fire await new Promise((r) => setTimeout(r, 50)); expect(mockStartWatching).not.toHaveBeenCalled(); }); it("starts watcher and runs incremental update for completed index", async () => { const pid = projectIdFromPath(TEST_PROJECT); const coll = collectionName(pid); mockListCollections.mockResolvedValue([coll]); mockGetPersistedStatus.mockResolvedValue("completed"); mockIsWatching.mockReturnValue(false); mockUpdateProjectIndex.mockResolvedValue({ added: 3, updated: 1, removed: 0, chunksCreated: 40, cancelled: false }); await autoResumeIndexedProjects(TEST_PROJECT); expect(mockStartWatching).toHaveBeenCalledWith(TEST_PROJECT); expect(mockUpdateProjectIndex).toHaveBeenCalledWith(TEST_PROJECT); await vi.waitFor(() => { expect(logger.info).toHaveBeenCalledWith( "Auto-resume: incremental update completed", expect.objectContaining({ filesChanged: 4 }), ); }); }); it("logs 'up to date' when incremental update finds no changes", async () => { const pid = projectIdFromPath(TEST_PROJECT); const coll = collectionName(pid); mockListCollections.mockResolvedValue([coll]); mockGetPersistedStatus.mockResolvedValue("completed"); mockIsWatching.mockReturnValue(false); mockUpdateProjectIndex.mockResolvedValue({ added: 0, updated: 0, removed: 0, chunksCreated: 0, cancelled: false }); await autoResumeIndexedProjects(TEST_PROJECT); await vi.waitFor(() => { expect(logger.info).toHaveBeenCalledWith( "Auto-resume: index is up to date", expect.objectContaining({ projectPath: TEST_PROJECT }), ); }); }); it("does not re-start watcher if already watching", async () => { const pid = projectIdFromPath(TEST_PROJECT); const coll = collectionName(pid); mockListCollections.mockResolvedValue([coll]); mockGetPersistedStatus.mockResolvedValue("completed"); mockIsWatching.mockReturnValue(true); // already watching mockUpdateProjectIndex.mockResolvedValue({ added: 0, updated: 0, removed: 0, chunksCreated: 0, cancelled: false }); await autoResumeIndexedProjects(TEST_PROJECT); expect(mockStartWatching).not.toHaveBeenCalled(); }); it("handles updateProjectIndex failure gracefully for completed index", async () => { const pid = projectIdFromPath(TEST_PROJECT); const coll = collectionName(pid); mockListCollections.mockResolvedValue([coll]); mockGetPersistedStatus.mockResolvedValue("completed"); mockIsWatching.mockReturnValue(false); mockUpdateProjectIndex.mockRejectedValue(new Error("Qdrant unavailable")); await autoResumeIndexedProjects(TEST_PROJECT); await vi.waitFor(() => { expect(logger.warn).toHaveBeenCalledWith( "Auto-resume: incremental update failed (non-fatal)", expect.objectContaining({ error: "Qdrant unavailable" }), ); }); }); it("handles indexProject failure gracefully for in-progress index", async () => { const pid = projectIdFromPath(TEST_PROJECT); const coll = collectionName(pid); mockListCollections.mockResolvedValue([coll]); mockGetPersistedStatus.mockResolvedValue("in-progress"); mockGetLockHolderPid.mockResolvedValue(null); mockIndexProject.mockRejectedValue(new Error("Crash during resume")); await autoResumeIndexedProjects(TEST_PROJECT); await vi.waitFor(() => { expect(logger.warn).toHaveBeenCalledWith( "Auto-resume: incomplete index recovery failed (non-fatal)", expect.objectContaining({ error: "Crash during resume" }), ); }); }); it("catches top-level errors and logs a warning", async () => { mockIsDockerAvailable.mockRejectedValue(new Error("Docker daemon gone")); await autoResumeIndexedProjects(TEST_PROJECT); expect(logger.warn).toHaveBeenCalledWith( "Auto-resume failed (non-fatal)", expect.objectContaining({ error: "Docker daemon gone" }), ); }); }); // ── awaitActiveIndexing ────────────────────────────────────────────────── describe("awaitActiveIndexing", () => { it("returns immediately when no indexing is in progress", async () => { mockGetIndexingInProgress.mockReturnValue([]); const start = Date.now(); await awaitActiveIndexing(5000); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(200); }); it("waits until indexing completes", async () => { // First call returns active, subsequent calls return empty let calls = 0; mockGetIndexingInProgress.mockImplementation(() => { calls++; return calls <= 2 ? ["/tmp/proj"] : []; }); await awaitActiveIndexing(10_000); expect(calls).toBeGreaterThan(2); }); it("times out and logs a warning when indexing persists", async () => { mockGetIndexingInProgress.mockReturnValue(["/tmp/stuck-project"]); await awaitActiveIndexing(1000); // 1 second timeout expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("Forcing shutdown"), expect.objectContaining({ projects: ["/tmp/stuck-project"] }), ); }); it("logs active projects at the start", async () => { mockGetIndexingInProgress .mockReturnValueOnce(["/proj-a", "/proj-b"]) // first call — reported .mockReturnValue([]); // subsequent — done await awaitActiveIndexing(5000); expect(logger.info).toHaveBeenCalledWith( "Waiting for active indexing to complete before shutdown", expect.objectContaining({ projects: ["/proj-a", "/proj-b"] }), ); }); }); // ── gracefulShutdown ───────────────────────────────────────────────────── describe("gracefulShutdown", () => { it("requests cancellation for all in-progress projects", async () => { mockGetIndexingInProgress .mockReturnValueOnce(["/proj-a", "/proj-b"]) // for cancellation loop .mockReturnValue([]); // for awaitActiveIndexing await gracefulShutdown("SIGTERM"); expect(mockRequestCancellation).toHaveBeenCalledWith("/proj-a"); expect(mockRequestCancellation).toHaveBeenCalledWith("/proj-b"); }); it("stops all watchers and releases all locks", async () => { await gracefulShutdown("SIGINT"); expect(mockStopAllWatchers).toHaveBeenCalled(); expect(mockReleaseAllLocks).toHaveBeenCalled(); }); it("calls the closeServer callback if provided", async () => { const closeFn = vi.fn().mockResolvedValue(undefined); await gracefulShutdown("SIGTERM", closeFn); expect(closeFn).toHaveBeenCalledOnce(); }); it("works without a closeServer callback", async () => { // Should not throw await gracefulShutdown("stdin EOF"); expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("stdin EOF")); }); it("logs the signal name", async () => { await gracefulShutdown("SIGTERM"); expect(logger.info).toHaveBeenCalledWith("Received SIGTERM, shutting down gracefully..."); }); it("logs completion at the end", async () => { await gracefulShutdown("SIGTERM"); expect(logger.info).toHaveBeenCalledWith("Graceful shutdown complete"); }); });