// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ── Module mocks (hoisted — evaluated before imports) ──────────────────── // Make promisify a passthrough so execFileAsync === execFile (the mock below). // This lets us control all child-process calls with a single vi.fn(). vi.mock("node:util", () => ({ promisify: vi.fn((fn: T): T => fn), })); vi.mock("node:child_process", () => ({ execFile: vi.fn(), })); vi.mock("../../src/services/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); // ── Imports (after mocks are registered) ───────────────────────────────── import { execFile } from "node:child_process"; import { ensureOllamaContainerReady, ensureQdrantReady, isDockerAvailable, isOllamaImagePresent, isOllamaRunning, isQdrantImagePresent, isQdrantRunning, pullOllamaImage, pullQdrantImage, resetOllamaContainerReadinessCache, resetQdrantReadinessCache, startQdrant, } from "../../src/services/docker.js"; const mockExecFile = vi.mocked(execFile as unknown as ( cmd: string, args: string[], opts: object ) => Promise<{ stdout: string; stderr: string }>); // Shorthand: resolve with empty stdout/stderr (simulates a successful command) const resolveOk = (stdout = "") => mockExecFile.mockResolvedValueOnce({ stdout, stderr: "" }); // Shorthand: reject simulating a timed-out command const rejectTimeout = () => mockExecFile.mockRejectedValueOnce( Object.assign(new Error("timeout"), { killed: true }), ); // Shorthand: reject simulating a failed command with stderr const rejectFailed = (stderr = "some docker error") => mockExecFile.mockRejectedValueOnce( Object.assign(new Error("exit 1"), { stderr }), ); // ── isDockerAvailable ───────────────────────────────────────────────────── describe("isDockerAvailable", () => { beforeEach(() => vi.clearAllMocks()); it("returns true when docker info succeeds", async () => { resolveOk(); expect(await isDockerAvailable()).toBe(true); }); it("returns false when docker info fails", async () => { rejectFailed("Cannot connect to the Docker daemon"); expect(await isDockerAvailable()).toBe(false); }); }); // ── isQdrantImagePresent ────────────────────────────────────────────────── describe("isQdrantImagePresent", () => { beforeEach(() => vi.clearAllMocks()); it("returns true when docker images output contains qdrant/qdrant", async () => { resolveOk("qdrant/qdrant:v1.17.0\n"); expect(await isQdrantImagePresent()).toBe(true); }); it("returns false when docker images output does not contain qdrant/qdrant", async () => { resolveOk("ollama/ollama:latest\n"); expect(await isQdrantImagePresent()).toBe(false); }); it("returns false when docker command fails", async () => { rejectFailed(); expect(await isQdrantImagePresent()).toBe(false); }); }); // ── isQdrantRunning ─────────────────────────────────────────────────────── describe("isQdrantRunning", () => { beforeEach(() => vi.clearAllMocks()); it("returns true when docker ps output contains the container name", async () => { resolveOk("socraticode-qdrant\n"); expect(await isQdrantRunning()).toBe(true); }); it("returns false when container is not in docker ps output", async () => { resolveOk("some-other-container\n"); expect(await isQdrantRunning()).toBe(false); }); it("returns false when docker command fails", async () => { rejectFailed(); expect(await isQdrantRunning()).toBe(false); }); }); // ── isOllamaImagePresent ────────────────────────────────────────────────── describe("isOllamaImagePresent", () => { beforeEach(() => vi.clearAllMocks()); it("returns true when docker images output contains ollama/ollama", async () => { resolveOk("ollama/ollama:latest\n"); expect(await isOllamaImagePresent()).toBe(true); }); it("returns false when docker images output does not contain ollama/ollama", async () => { resolveOk("qdrant/qdrant:v1.17.0\n"); expect(await isOllamaImagePresent()).toBe(false); }); it("returns false when docker command fails", async () => { rejectFailed(); expect(await isOllamaImagePresent()).toBe(false); }); }); // ── isOllamaRunning ─────────────────────────────────────────────────────── describe("isOllamaRunning", () => { beforeEach(() => vi.clearAllMocks()); it("returns true when docker ps output contains the container name", async () => { resolveOk("socraticode-ollama\n"); expect(await isOllamaRunning()).toBe(true); }); it("returns false when container is not in docker ps output", async () => { resolveOk("other-container\n"); expect(await isOllamaRunning()).toBe(false); }); it("returns false when docker command fails", async () => { rejectFailed(); expect(await isOllamaRunning()).toBe(false); }); }); // ── pullQdrantImage error paths ─────────────────────────────────────────── describe("pullQdrantImage", () => { beforeEach(() => vi.clearAllMocks()); it("throws a user-friendly timeout message when docker pull is killed", async () => { rejectTimeout(); await expect(pullQdrantImage()).rejects.toThrow( "Qdrant image download timed out after 10 minutes", ); }); it("throws a user-friendly network error message on generic failure", async () => { rejectFailed("Network unreachable"); await expect(pullQdrantImage()).rejects.toThrow( "Failed to download Qdrant image", ); }); it("calls onProgress callback before pulling", async () => { resolveOk(); const progress: string[] = []; await pullQdrantImage((msg) => progress.push(msg)); expect(progress[0]).toMatch(/Downloading Qdrant Docker image/); }); }); // ── pullOllamaImage error paths ─────────────────────────────────────────── describe("pullOllamaImage", () => { beforeEach(() => vi.clearAllMocks()); it("throws a user-friendly timeout message when docker pull is killed", async () => { rejectTimeout(); await expect(pullOllamaImage()).rejects.toThrow( "Ollama image download timed out after 10 minutes", ); }); it("throws a user-friendly network error message on generic failure", async () => { rejectFailed("Network unreachable"); await expect(pullOllamaImage()).rejects.toThrow( "Failed to download Ollama image", ); }); it("calls onProgress callback before pulling", async () => { resolveOk(); const progress: string[] = []; await pullOllamaImage((msg) => progress.push(msg)); expect(progress[0]).toMatch(/Downloading Ollama Docker image/); }); }); // ── startQdrant error paths ─────────────────────────────────────────────── describe("startQdrant", () => { beforeEach(() => { vi.clearAllMocks(); }); it("throws a port-conflict message when docker run fails", async () => { // isQdrantRunning → false (ps returns empty) resolveOk(""); // doesQdrantContainerExist → false (ps -a returns empty) resolveOk(""); // docker run → fails rejectFailed("port is already allocated"); await expect(startQdrant()).rejects.toThrow( "Failed to start Qdrant container", ); }); it("skips creation and returns when Qdrant is already running", async () => { // isQdrantRunning returns true resolveOk("socraticode-qdrant"); // No further docker calls should be needed await startQdrant(); // execFile called exactly once (for isQdrantRunning) expect(mockExecFile).toHaveBeenCalledTimes(1); }); }); // ── ensureQdrantReady (managed mode) — readiness cache ─────────────────── describe("ensureQdrantReady managed mode cache", () => { beforeEach(() => { vi.clearAllMocks(); resetQdrantReadinessCache(); }); it("fast-paths on the second call without invoking docker", async () => { // First call: docker info + images + ps all succeed; skip actual start by // reporting image present and container already running. resolveOk(); // docker info → available resolveOk("qdrant/qdrant:v1.17.0"); // images → present resolveOk("socraticode-qdrant"); // ps → running const first = await ensureQdrantReady(); expect(first).toMatchObject({ started: false, pulled: false }); const callsAfterFirst = mockExecFile.mock.calls.length; // Second call should be served from cache — no new docker calls const second = await ensureQdrantReady(); expect(second).toMatchObject({ started: false, pulled: false }); expect(mockExecFile).toHaveBeenCalledTimes(callsAfterFirst); }); it("throws when Docker is not available in managed mode", async () => { rejectFailed("Cannot connect to daemon"); // docker info fails await expect(ensureQdrantReady()).rejects.toThrow("Docker is not available"); }); }); // ── resetOllamaContainerReadinessCache ──────────────────────────────────── describe("resetOllamaContainerReadinessCache", () => { beforeEach(() => { vi.clearAllMocks(); resetOllamaContainerReadinessCache(); }); it("forces a re-check after reset", async () => { // Prime the cache: docker info + images + ps all succeed resolveOk(); // docker info resolveOk("ollama/ollama:latest"); // images resolveOk("socraticode-ollama"); // ps const first = await ensureOllamaContainerReady(); expect(first).toMatchObject({ started: false, pulled: false }); const callsAfterFirst = mockExecFile.mock.calls.length; // Reset: next call must re-check resetOllamaContainerReadinessCache(); resolveOk(); // docker info again resolveOk("ollama/ollama:latest"); // images again resolveOk("socraticode-ollama"); // ps again await ensureOllamaContainerReady(); expect(mockExecFile.mock.calls.length).toBeGreaterThan(callsAfterFirst); }); }); // ── ensureQdrantReady (external mode) ──────────────────────────────────── // These tests use vi.resetModules() + vi.doMock() to reload docker.ts with // QDRANT_MODE=external and controlled constants. They must run in isolation. describe("ensureQdrantReady external mode", () => { afterEach(() => { vi.resetModules(); vi.restoreAllMocks(); }); async function loadDockerWithExternalMode(overrides: Record = {}) { // Reset module cache so docker.ts re-imports the mocked constants below vi.resetModules(); vi.doMock("../../src/constants.js", async (importOriginal) => { const original = await importOriginal>(); return { ...original, QDRANT_MODE: "external", QDRANT_URL: "", QDRANT_HOST: "localhost", QDRANT_PORT: 16333, ...overrides, }; }); vi.doMock("../../src/services/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); vi.doMock("node:child_process", () => ({ execFile: vi.fn() })); vi.doMock("node:util", () => ({ promisify: (fn: T): T => fn })); const docker = await import("../../src/services/docker.js"); docker.resetQdrantReadinessCache(); return docker; } it("throws when QDRANT_URL is not set and QDRANT_HOST is localhost", async () => { const docker = await loadDockerWithExternalMode(); await expect(docker.ensureQdrantReady()).rejects.toThrow( "QDRANT_MODE=external requires QDRANT_URL", ); }); it("returns { started: false, pulled: false } when external Qdrant is reachable", async () => { const docker = await loadDockerWithExternalMode({ QDRANT_URL: "http://remote-qdrant:6333", QDRANT_HOST: "remote-qdrant", }); // Mock fetch so the health check immediately succeeds const fetchSpy = vi .spyOn(globalThis, "fetch") .mockResolvedValue({ ok: true } as Response); const result = await docker.ensureQdrantReady(); expect(result).toEqual({ started: false, pulled: false }); fetchSpy.mockRestore(); }); it("throws 'Cannot reach external Qdrant' when health check fails", async () => { const docker = await loadDockerWithExternalMode({ QDRANT_URL: "http://unreachable-qdrant:6333", QDRANT_HOST: "unreachable-qdrant", }); // Mock fetch to always fail (simulates unreachable server) const fetchSpy = vi .spyOn(globalThis, "fetch") .mockRejectedValue(new Error("ECONNREFUSED")); await expect(docker.ensureQdrantReady()).rejects.toThrow( "Cannot reach external Qdrant", ); fetchSpy.mockRestore(); }, 30_000); // allow up to 30s for the retry loop (5 retries × 1s) it("throws 'Cannot reach external Qdrant' when health endpoint returns non-ok", async () => { const docker = await loadDockerWithExternalMode({ QDRANT_URL: "http://bad-qdrant:6333", QDRANT_HOST: "bad-qdrant", }); const fetchSpy = vi .spyOn(globalThis, "fetch") .mockResolvedValue({ ok: false, status: 503 } as Response); await expect(docker.ensureQdrantReady()).rejects.toThrow( "Cannot reach external Qdrant", ); fetchSpy.mockRestore(); }, 30_000); });