import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { discoverLocalOllama, recommendDefaultModel, largestModel, isLocalDiscoveryDismissed, dismissLocalDiscovery, type DiscoveredLocalModel, } from '../local-discovery'; /** * Real model list from this machine's Ollama (`curl 127.0.0.1:11434/api/tags`, * 2026-08-06) — used as the test fixture rather than invented data, per the * explicit instruction to use the real local runtime as the test case for * "which queries are required and how to add most of the modules * automatically". Sizes/params/capabilities are copied verbatim. */ const REAL_MACHINE_MODELS: DiscoveredLocalModel[] = [ { name: 'nomic-embed-text:latest', capabilities: ['embedding'], parameterSize: '137M', sizeBytes: 274_302_450 }, { name: 'qwen2.5:32b', capabilities: ['completion', 'tools'], parameterSize: '32.8B', sizeBytes: 19_851_349_669 }, { name: 'gemma4:12b-mlx', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '', sizeBytes: 9_977_519_169 }, { name: 'gemma4:latest', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '8.0B', sizeBytes: 9_608_350_718 }, { name: 'deepseek-r1:32b', capabilities: ['completion', 'thinking'], parameterSize: '32.8B', sizeBytes: 19_851_337_809 }, { name: 'deepseek-r1:14b', capabilities: ['completion', 'thinking'], parameterSize: '14.8B', sizeBytes: 8_988_112_209 }, { name: 'llama3.2:latest', capabilities: ['completion', 'tools'], parameterSize: '3.2B', sizeBytes: 2_019_393_189 }, { name: 'hermes3:8b', capabilities: ['completion', 'tools'], parameterSize: '8B', sizeBytes: 4_661_227_000 }, { name: 'qwen3:latest', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '8.2B', sizeBytes: 5_200_000_000 }, { name: 'gemma4:e4b', capabilities: ['completion', 'tools', 'thinking'], parameterSize: '8.0B', sizeBytes: 9_600_000_000 }, { name: 'qwen3.5:latest', capabilities: ['vision', 'completion', 'tools', 'thinking'], parameterSize: '9.7B', sizeBytes: 6_600_000_000 }, ]; describe('recommendDefaultModel', () => { it('picks the smallest non-"thinking" chat model from a real mixed fleet', () => { // llama3.2 (2.0GB) is the smallest completion-capable, non-reasoning // model on this real machine — everything smaller is embedding-only. expect(recommendDefaultModel(REAL_MACHINE_MODELS)).toBe('llama3.2:latest'); }); it('never recommends an embedding-only model', () => { const onlyEmbedding = [REAL_MACHINE_MODELS[0]]; // nomic-embed-text expect(recommendDefaultModel(onlyEmbedding)).toBeNull(); }); it('falls back to the smallest "thinking" model when nothing else qualifies', () => { const onlyReasoning = REAL_MACHINE_MODELS.filter((m) => m.capabilities.includes('thinking') && !m.capabilities.includes('vision')); // Smallest of the thinking-only pool here is qwen3 (5.2GB) before gemma4 variants. expect(recommendDefaultModel(onlyReasoning)).toBe('qwen3:latest'); }); it('returns null when no models are chat-capable at all', () => { expect(recommendDefaultModel([])).toBeNull(); }); }); describe('largestModel', () => { it('picks the biggest chat-capable model — qwen2.5:32b, by 11,860 bytes over deepseek-r1:32b', () => { // Both are ~19.85GB on this real machine (same base size class), but // qwen2.5:32b's actual manifest is very slightly larger — not a tie. expect(largestModel(REAL_MACHINE_MODELS)).toBe('qwen2.5:32b'); }); it('excludes embedding-only models even though they can be tiny or huge', () => { expect(largestModel([REAL_MACHINE_MODELS[0]])).toBeNull(); }); }); describe('discoverLocalOllama', () => { const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; vi.restoreAllMocks(); }); it('parses a real-shaped /api/tags response into DiscoveredLocalModel[]', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ models: [ { name: 'llama3.2:latest', capabilities: ['completion', 'tools'], size: 2_019_393_189, details: { parameter_size: '3.2B' } }, { name: 'nomic-embed-text:latest', capabilities: ['embedding'], size: 274_302_450, details: { parameter_size: '137M' } }, ], }), }) as unknown as typeof fetch; const result = await discoverLocalOllama(['http://127.0.0.1:11434']); expect(result).not.toBeNull(); expect(result?.baseUrl).toBe('http://127.0.0.1:11434'); expect(result?.models).toHaveLength(2); expect(result?.models[0]).toEqual({ name: 'llama3.2:latest', capabilities: ['completion', 'tools'], parameterSize: '3.2B', sizeBytes: 2_019_393_189, }); }); it('requires exactly one query — a single /api/tags call, no follow-up /api/show requests', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ models: [{ name: 'llama3.2:latest', capabilities: ['completion'], size: 1, details: {} }] }), }); global.fetch = fetchMock as unknown as typeof fetch; await discoverLocalOllama(['http://127.0.0.1:11434']); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:11434/api/tags', expect.anything()); }); it('falls through to the next candidate base URL when the first is unreachable', async () => { const fetchMock = vi.fn() .mockRejectedValueOnce(new Error('connection refused')) .mockResolvedValueOnce({ ok: true, json: async () => ({ models: [{ name: 'llama3.2:latest', capabilities: ['completion'], size: 1, details: {} }] }), }); global.fetch = fetchMock as unknown as typeof fetch; const result = await discoverLocalOllama(['http://127.0.0.1:11434', 'http://localhost:11434']); expect(result?.baseUrl).toBe('http://localhost:11434'); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('returns null when nothing answers on any candidate', async () => { global.fetch = vi.fn().mockRejectedValue(new Error('connection refused')) as unknown as typeof fetch; const result = await discoverLocalOllama(['http://127.0.0.1:11434', 'http://localhost:11434']); expect(result).toBeNull(); }); it('returns null (not an empty-models result) when Ollama answers with zero models installed', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ models: [] }) }) as unknown as typeof fetch; const result = await discoverLocalOllama(['http://127.0.0.1:11434']); expect(result).toBeNull(); }); }); describe('dismissal persistence', () => { beforeEach(() => { window.localStorage.clear(); }); it('is not dismissed by default', () => { expect(isLocalDiscoveryDismissed()).toBe(false); }); it('persists a dismissal across calls', () => { dismissLocalDiscovery(); expect(isLocalDiscoveryDismissed()).toBe(true); }); });