mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
// 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<Record<string, unknown>>();
|
|
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");
|
|
});
|
|
});
|