aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-ollama-autoconnect.js
blob: 12b0786609aa305a764e1fdaad2ce4a9878461b7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// 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);
    }
})();