import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { detectOllama, listOllamaModels, getRecommendedModel } from "../services/ollama.js"; import type { OllamaModel } from "../services/ollama.js"; describe("detectOllama", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it("returns installed:true + version when Ollama responds at /api/version", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ version: "0.5.1" }), }); vi.stubGlobal("fetch", mockFetch); const result = await detectOllama(); expect(result.installed).toBe(true); expect(result.version).toBe("0.5.1"); expect(result.installUrl).toBe("https://ollama.com/download"); }); it("returns installed:false + installUrl when Ollama is absent (ECONNREFUSED)", async () => { const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); vi.stubGlobal("fetch", mockFetch); const result = await detectOllama(); expect(result.installed).toBe(false); expect(result.version).toBeNull(); expect(result.installUrl).toBe("https://ollama.com/download"); }); it("returns installed:false when fetch times out (AbortController)", async () => { const mockFetch = vi.fn().mockImplementation((_url: string, opts: { signal?: AbortSignal }) => { return new Promise((_resolve, reject) => { if (opts?.signal) { opts.signal.addEventListener("abort", () => { reject(new DOMException("The operation was aborted.", "AbortError")); }); } // Never resolves — simulates timeout }); }); vi.stubGlobal("fetch", mockFetch); const result = await detectOllama(); expect(result.installed).toBe(false); expect(result.version).toBeNull(); expect(result.installUrl).toBe("https://ollama.com/download"); }, 10000); it("returns installed:false when response is not ok", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503, }); vi.stubGlobal("fetch", mockFetch); const result = await detectOllama(); expect(result.installed).toBe(false); expect(result.version).toBeNull(); }); }); describe("listOllamaModels", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it("returns OllamaModel[] mapped from /api/tags response", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ models: [ { name: "qwen2.5-coder:32b", model: "qwen2.5-coder:32b", modified_at: "2026-01-01T00:00:00Z", size: 23123456789, digest: "abc123", details: { parent_model: "", format: "gguf", family: "qwen2", families: ["qwen2"], parameter_size: "32.8B", quantization_level: "Q4_K_M", }, }, { name: "llama3.1:8b", model: "llama3.1:8b", modified_at: "2026-01-01T00:00:00Z", size: 4500000000, digest: "def456", details: { parent_model: "", format: "gguf", family: "llama", families: ["llama"], parameter_size: "8.0B", quantization_level: "Q4_K_M", }, }, ], }), }); vi.stubGlobal("fetch", mockFetch); const models = await listOllamaModels(); expect(models).toHaveLength(2); expect(models[0].name).toBe("qwen2.5-coder:32b"); expect(models[0].parameterSize).toBe("32.8B"); expect(models[0].quantization).toBe("Q4_K_M"); expect(models[0].sizeBytes).toBe(23123456789); expect(models[0].family).toBe("qwen2"); expect(models[0].recommended).toBe(false); expect(models[0].recommendationReason).toBeNull(); expect(models[1].name).toBe("llama3.1:8b"); expect(models[1].family).toBe("llama"); }); it("returns empty array when Ollama is absent", async () => { const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); vi.stubGlobal("fetch", mockFetch); const models = await listOllamaModels(); expect(models).toEqual([]); }); it("returns empty array when response is not ok", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503, }); vi.stubGlobal("fetch", mockFetch); const models = await listOllamaModels(); expect(models).toEqual([]); }); }); describe("getRecommendedModel", () => { const makeModel = (name: string, family: string): OllamaModel => ({ name, parameterSize: "7B", quantization: "Q4_K_M", sizeBytes: 4000000000, family, recommended: false, recommendationReason: null, }); it("recommends a 7b-class model when system RAM is 8GB", () => { const models: OllamaModel[] = [ makeModel("qwen2.5-coder:7b", "qwen2"), makeModel("qwen2.5-coder:32b", "qwen2"), ]; const ramBytes = 8 * 1024 * 1024 * 1024; // 8GB const result = getRecommendedModel(models, ramBytes); const recommended = result.filter((m) => m.recommended); expect(recommended).toHaveLength(1); expect(recommended[0].name).toBe("qwen2.5-coder:7b"); expect(recommended[0].recommendationReason).not.toBeNull(); }); it("recommends a 32b-class model when system RAM is 32GB", () => { const models: OllamaModel[] = [ makeModel("qwen2.5-coder:7b", "qwen2"), makeModel("qwen2.5-coder:32b", "qwen2"), ]; const ramBytes = 32 * 1024 * 1024 * 1024; // 32GB const result = getRecommendedModel(models, ramBytes); const recommended = result.filter((m) => m.recommended); expect(recommended).toHaveLength(1); expect(recommended[0].name).toBe("qwen2.5-coder:32b"); expect(recommended[0].recommendationReason).not.toBeNull(); }); it("returns recommended=false for all models not in catalog", () => { const models: OllamaModel[] = [ makeModel("unknown-model:7b", "unknown"), makeModel("another-unknown:13b", "mystery"), ]; const ramBytes = 64 * 1024 * 1024 * 1024; // 64GB — plenty of RAM const result = getRecommendedModel(models, ramBytes); expect(result.every((m) => !m.recommended)).toBe(true); }); it("returns empty array when no models provided", () => { const result = getRecommendedModel([], 16 * 1024 * 1024 * 1024); expect(result).toEqual([]); }); it("does not recommend models that exceed 75% of system RAM", () => { // 4GB RAM — 75% = 3GB. qwen2.5-coder:7b needs 5GB, should NOT be recommended const models: OllamaModel[] = [ makeModel("qwen2.5-coder:7b", "qwen2"), ]; const ramBytes = 4 * 1024 * 1024 * 1024; // 4GB const result = getRecommendedModel(models, ramBytes); expect(result.every((m) => !m.recommended)).toBe(true); }); });