227 lines
6.9 KiB
TypeScript
227 lines
6.9 KiB
TypeScript
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<never>((_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);
|
|
});
|
|
});
|