// modules/test/test-ollama-autoconnect.js — run: node // Proves the first message lazily connects instead of dying with "Use ollamaconnect first", // and that a genuinely unreachable endpoint still produces the error (after one attempt). const fsMod = require('fs'), path = require('path'); const ROOT = path.join(__dirname, '..', 'core'); const store = {}; global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; }, }; global.window = { addEventListener: () => {}, location: { origin: 'https://webx11.duckdns.org' } }; global.document = { addEventListener: () => {} }; const OllamaManager = new Function( fsMod.readFileSync(path.join(ROOT, 'ollama-manager.js'), 'utf8') + '\nreturn OllamaManager;')(); let fails = 0, passes = 0; function ok(name, cond, detail) { if (cond) { passes++; return; } fails++; console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); } function mk(behaviour) { const out = []; const term = { print: (m, t) => out.push({ text: String(m), type: t || '' }), log: () => {}, // chat() prints the model answer through this, even in non-streaming mode printStreamingResponse: (t) => out.push({ text: String(t), type: 'stream' }), }; const om = Object.create(OllamaManager.prototype); Object.assign(om, { terminal: term, connected: false, models: [], currentModel: 'm1', endpoint: 'https://webx11.duckdns.org/ollama', fallbackEndpoints: [], providers: [{ name: 'Ollama', type: 'ollama', endpoint: 'https://webx11.duckdns.org/ollama', model: 'm1', priority: 1, enabled: true }], currentProvider: null, fallbackCount: 0, responseHooks: [], _usageLog: [], _usageBuffer: [], _abortController: null, _connecting: null, saveSettings: () => {}, _saveProviders: () => {}, _logUsage: () => {}, _flushUsageLog: () => {}, }); om.currentProvider = om.providers[0]; global.fetch = behaviour; return { om, out }; } const json = (body, okFlag = true) => Promise.resolve({ ok: okFlag, status: okFlag ? 200 : 500, json: async () => body }); (async () => { // A. healthy endpoint: the first chat() call connects, then answers { const seen = []; const { om, out } = mk((url) => { seen.push(url); if (url.includes('/api/tags')) return json({ models: [{ name: 'm1' }] }); // chat() goes through /api/generate for ollama providers return json({ response: 'pong', message: { content: 'pong' }, eval_count: 1, prompt_eval_count: 1 }); }); const res = await om.chat('ping'); const text = out.map(o => o.text).join(' | '); ok('A1 connect is attempted lazily', seen.some(u => u.includes('/api/tags')), seen.join(', ')); ok('A2 the user sees a first-use hint', /connecting on first use/.test(text), text.slice(0, 160)); ok('A3 manager is connected after the attempt', om.connected === true); ok('A4 the message is answered', String(res).includes('pong'), String(res).slice(0, 120)); ok('A5 no "ollamaconnect first" error', !/ollamaconnect first/.test(text), text.slice(0, 200)); } // B. dead endpoint: one attempt, then an actionable error { const seen = []; const { om, out } = mk((url) => { seen.push(url); return Promise.reject(new TypeError('Failed to fetch')); }); const res = await om.chat('ping'); const text = out.map(o => o.text).join(' | '); ok('B1 still attempts the connection', seen.some(u => u.includes('/api/tags')), seen.join(', ')); ok('B2 returns null', res === null, String(res)); ok('B3 error names the real cause', /endpoint is unreachable/.test(text), text.slice(0, 240)); ok('B4 auto-connect failure is reported once', /Auto-connect failed/.test(text), text.slice(0, 240)); ok('B5 manager stays disconnected', om.connected === false); } // C. concurrent callers share one attempt { let tagCalls = 0; const { om } = mk((url) => { if (url.includes('/api/tags')) { tagCalls++; return json({ models: [{ name: 'm1' }] }); } return json({ response: 'ok', message: { content: 'ok' }, eval_count: 1, prompt_eval_count: 1 }); }); await Promise.all([om.chat('a'), om.chat('b'), om.chat('c')]); ok('C1 three concurrent messages probe /api/tags once', tagCalls === 1, 'tagCalls=' + tagCalls); } // D. a blackholed endpoint must not hang every later message { const { om, out } = mk(() => new Promise(() => {})); // never settles om.connectTimeoutMs = 100; const t0 = Date.now(); const res = await om.chat('ping'); const ms = Date.now() - t0; const text = out.map(o => o.text).join(' | '); ok('D1 chat settles on a silent endpoint', ms < 3000, ms + 'ms'); ok('D2 returns null', res === null, String(res)); ok('D3 the timeout is reported', /timed out/.test(text), text.slice(0, 220)); ok('D4 _connecting is cleared', om._connecting === null, String(om._connecting)); const before = out.length; await om.chat('again'); ok('D5 the next message retries the connect', out.length > before, 'lines ' + before + ' -> ' + out.length); } // E. an automatic connect must not rewrite the model the user saved { store['webx11_ollama_model'] = 'my-saved-model'; const { om, out } = mk((url) => url.includes('/api/tags') ? json({ models: [{ name: 'other-model' }] }) : json({ response: 'x', eval_count: 1, prompt_eval_count: 1 })); om.currentModel = 'my-saved-model'; await om._ensureConnected(); const text = out.map(o => o.text).join(' | '); ok('E1 the saved model survives an auto-connect', om.currentModel === 'my-saved-model', om.currentModel); ok('E2 the stored preference is restored', store['webx11_ollama_model'] === 'my-saved-model', String(store['webx11_ollama_model'])); ok('E3 the user is told why', /Keeping your saved model/.test(text), text.slice(0, 200)); delete store['webx11_ollama_model']; } if (fails === 0) { console.log('test-ollama-autoconnect: ' + passes + ' passed, 0 failed'); } else { console.log('test-ollama-autoconnect: ' + passes + ' passed, ' + fails + ' FAILED'); process.exit(1); } })();