import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'; import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js'; describe('LocalOllamaProvider', () => { let mock: MockAgent; let original: ReturnType; beforeEach(() => { original = getGlobalDispatcher(); mock = new MockAgent(); mock.disableNetConnect(); setGlobalDispatcher(mock); }); afterEach(async () => { setGlobalDispatcher(original); await mock.close(); }); it('generate parses Ollama JSON', async () => { mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { response: JSON.stringify({ title: '회의', summary: '첫\n둘\n셋', tags: ['api'] }) }); const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }); expect(r.title).toBe('회의'); }); it('generate passes vocab into prompt body', async () => { let capturedBody: string = ''; mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => { capturedBody = opts.body as string; return { statusCode: 200, data: JSON.stringify({ response: JSON.stringify({ title: '회의', summary: 'a\nb\nc', tags: ['design'] }) }) }; }); await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-05-02', dueDateCandidates: [], vocab: ['design', 'meeting'] }); const parsed = JSON.parse(capturedBody) as { prompt: string }; expect(parsed.prompt).toContain('design, meeting'); expect(parsed.prompt).toContain('Prefer reusing'); }); it('v0.3.14 — generate falls back to placeholder when JSON unparseable', async () => { // 이전엔 throw 했지만 schema graceful coerce 추가 후 placeholder 채워서 통과. // truncated / repetition-loop 응답에서 사용자 데이터 (raw_text) 무손실 보존. mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { response: 'not json' }); const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }); expect(r.title).toBe('(첨부 메모)'); expect(r.summary.length).toBeGreaterThan(0); }); it('v0.3.14 — body 에 repeat_penalty 포함 (repetition loop 방지)', async () => { let capturedBody: string = ''; mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => { capturedBody = opts.body as string; return { statusCode: 200, data: JSON.stringify({ response: JSON.stringify({ title: '회의', summary: 'a\nb\nc', tags: [] }) }) }; }); await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }); const parsed = JSON.parse(capturedBody) as { options: { repeat_penalty: number } }; expect(parsed.options.repeat_penalty).toBe(1.15); }); it('v0.3.11 — generate extracts JSON from markdown fence', async () => { // vision model 이 ```json ... ``` 형태로 응답하는 경우 fallback 으로 추출. mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { response: '```json\n{"title":"회의","summary":"a\\nb\\nc","tags":["meet"]}\n```' }); const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }); expect(r.title).toBe('회의'); }); it('v0.3.11 — generate extracts JSON when prose 가 앞뒤로 섞임', async () => { mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, { response: 'Here is the response:\n{"title":"회의","summary":"a\\nb\\nc","tags":[]}\nDone.' }); const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }); expect(r.title).toBe('회의'); }); it('generate aborts on timeout', async () => { mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply((async () => { await new Promise((r) => setTimeout(r, 500)); return { statusCode: 200, data: '{}' }; }) as never); await expect( new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] }) ).rejects.toThrow(); }, 2000); it('healthCheck ok=true when model present', async () => { mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, { models: [{ name: 'gemma4:e4b' }] }); const h = await new LocalOllamaProvider().healthCheck(); expect(h.ok).toBe(true); expect(h.model).toBe('gemma4:e4b'); }); it('healthCheck ok=false when missing', async () => { mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, { models: [{ name: 'other:latest' }] }); const h = await new LocalOllamaProvider().healthCheck(); expect(h.ok).toBe(false); expect(h.reason).toMatch(/gemma4:e4b/); }); it('healthCheck ok=false on connection error', async () => { mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }) .replyWithError(new Error('ECONNREFUSED')); const h = await new LocalOllamaProvider().healthCheck(); expect(h.ok).toBe(false); expect(h.reason).toMatch(/connect|refused|unreachable/i); }); it('abort() cancels in-flight generate (rejects with AbortError)', async () => { mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply((async () => { await new Promise((r) => setTimeout(r, 5000)); // long-running return { statusCode: 200, data: '{}' }; }) as never); const provider = new LocalOllamaProvider({ timeoutMs: 30_000 }); const generatePromise = provider.generate({ text: 'x', todayKst: '2026-05-04', dueDateCandidates: [] }); setTimeout(() => provider.abort(), 50); await expect(generatePromise).rejects.toThrow(); }); it('constructor uses provided model param (not just default)', () => { const provider = new LocalOllamaProvider({ model: 'gemma4:26b' }); expect(provider.name).toBe('local-ollama/gemma4:26b'); }); describe('healthCheck PII reason masking', () => { it('classifies ECONNREFUSED as network', async () => { mock.get('http://192.168.1.5:11434').intercept({ path: '/api/tags', method: 'GET' }) .replyWithError(new Error('connect ECONNREFUSED 192.168.1.5:11434')); const provider = new LocalOllamaProvider({ endpoint: 'http://192.168.1.5:11434' }); const h = await provider.healthCheck(); expect(h.ok).toBe(false); expect(h.reason).toBe('unreachable:network'); expect(h.reason).not.toContain('192.168.1.5'); }); it('classifies AbortError/timeout as timeout', async () => { mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }) .replyWithError(new Error('The operation was aborted due to timeout')); const h = await new LocalOllamaProvider().healthCheck(); expect(h.ok).toBe(false); expect(h.reason).toBe('unreachable:timeout'); }); it('classifies ENOTFOUND as dns', async () => { mock.get('http://nonexistent.local:11434').intercept({ path: '/api/tags', method: 'GET' }) .replyWithError(new Error('getaddrinfo ENOTFOUND nonexistent.local')); const provider = new LocalOllamaProvider({ endpoint: 'http://nonexistent.local:11434' }); const h = await provider.healthCheck(); expect(h.ok).toBe(false); expect(h.reason).toBe('unreachable:dns'); expect(h.reason).not.toContain('nonexistent.local'); }); it('falls back to other for unknown errors', async () => { mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }) .replyWithError(new Error('something weird happened')); const h = await new LocalOllamaProvider().healthCheck(); expect(h.ok).toBe(false); expect(h.reason).toBe('unreachable:other'); }); }); describe('vision path (v0.3.1 Cut F)', () => { it('visionModel + images → body.images + model=visionModel + buildVisionPrompt', async () => { let capturedBody: string = ''; mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => { capturedBody = opts.body as string; return { statusCode: 200, data: JSON.stringify({ response: JSON.stringify({ title: '비전테스트', summary: 'a\nb\nc', tags: [], due_date: null }) }) }; }); const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' }); await provider.generate( { text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [], images: [{ base64: 'AAAA', mime: 'image/png' }] }, { visionModel: 'gemma3:12b-vision' } ); const parsed = JSON.parse(capturedBody) as { model: string; prompt: string; images?: string[] }; expect(parsed.model).toBe('gemma3:12b-vision'); expect(parsed.prompt).toContain('이미지'); expect(parsed.images).toEqual(['AAAA']); }); it('visionModel 있어도 images 없으면 text-only (model = this.model, no body.images)', async () => { let capturedBody: string = ''; mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => { capturedBody = opts.body as string; return { statusCode: 200, data: JSON.stringify({ response: JSON.stringify({ title: '텍스트전용', summary: 'a\nb\nc', tags: [], due_date: null }) }) }; }); const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' }); await provider.generate( { text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] }, { visionModel: 'gemma3:12b-vision' } ); const parsed = JSON.parse(capturedBody) as { model: string; images?: string[] }; expect(parsed.model).toBe('gemma4:e4b'); expect(parsed.images).toBeUndefined(); }); it('opts 미전달 → 기존 text-only (회귀)', async () => { let capturedBody: string = ''; mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => { capturedBody = opts.body as string; return { statusCode: 200, data: JSON.stringify({ response: JSON.stringify({ title: '기본텍스트', summary: 'a\nb\nc', tags: [], due_date: null }) }) }; }); const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' }); await provider.generate({ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] }); const parsed = JSON.parse(capturedBody) as { model: string; images?: string[] }; expect(parsed.model).toBe('gemma4:e4b'); expect(parsed.images).toBeUndefined(); }); }); });