import { Platform } from 'react-native'; import { buildBaseUrl, chatCompletion, chatCompletionStream, getModels, parseSSELines, testConnection, } from '../services/lmStudioApi'; import { ServerConfig } from '../services/types'; const config: ServerConfig = { host: '192.168.0.10', port: 1234 }; const fetchMock = jest.fn(); const originalFetch = global.fetch; beforeEach(() => { fetchMock.mockReset(); global.fetch = fetchMock as unknown as typeof fetch; }); afterAll(() => { global.fetch = originalFetch; }); const sseLine = (content: string) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}`; describe('buildBaseUrl', () => { it('prepends http:// and appends port and /v1', () => { expect(buildBaseUrl(config)).toBe('http://192.168.0.10:1234/v1'); }); it('keeps an explicit http or https scheme', () => { expect(buildBaseUrl({ host: 'http://lan.local', port: 8080 })).toBe('http://lan.local:8080/v1'); expect(buildBaseUrl({ host: 'https://lan.local', port: 443 })).toBe('https://lan.local:443/v1'); }); it('strips trailing slashes from the host', () => { expect(buildBaseUrl({ host: 'http://lan.local//', port: 1234 })).toBe('http://lan.local:1234/v1'); }); it('does not mistake a host that merely starts with "http" for a scheme', () => { expect(buildBaseUrl({ host: 'httpbin.local', port: 1234 })).toBe('http://httpbin.local:1234/v1'); }); }); describe('parseSSELines', () => { it('emits content of every complete data line and keeps the partial tail', () => { const onChunk = jest.fn(); const onDone = jest.fn(); const result = parseSSELines(`${sseLine('Hel')}\n${sseLine('lo')}\ndata: {"cho`, onChunk, onDone); expect(onChunk.mock.calls).toEqual([['Hel'], ['lo']]); expect(result).toEqual({ remaining: 'data: {"cho', isDone: false }); expect(onDone).not.toHaveBeenCalled(); }); it('stops at [DONE] and calls onDone', () => { const onChunk = jest.fn(); const onDone = jest.fn(); const result = parseSSELines(`${sseLine('a')}\ndata: [DONE]\n${sseLine('ignored')}\n`, onChunk, onDone); expect(onChunk.mock.calls).toEqual([['a']]); expect(onDone).toHaveBeenCalledTimes(1); expect(result).toEqual({ remaining: '', isDone: true }); }); it('skips malformed JSON, empty deltas, comments and non-data lines', () => { const onChunk = jest.fn(); parseSSELines( [ 'data: {not json}', 'data: {"choices":[{"delta":{}}]}', 'data: {"choices":[]}', ': keep-alive', 'event: message', '', sseLine('ok'), '', ].join('\n'), onChunk, jest.fn(), ); expect(onChunk.mock.calls).toEqual([['ok']]); }); it('handles CRLF line endings and "data:" without a space', () => { const onChunk = jest.fn(); const noSpace = `data:${JSON.stringify({ choices: [{ delta: { content: 'nospace' } }] })}`; parseSSELines(`${sseLine('crlf')}\r\n${noSpace}\n`, onChunk, jest.fn()); expect(onChunk.mock.calls).toEqual([['crlf'], ['nospace']]); }); }); describe('testConnection', () => { it('sends the Authorization header only when an API key is set', async () => { fetchMock.mockResolvedValue({ ok: true }); await testConnection({ ...config, apiKey: 'secret' }); await testConnection({ ...config, apiKey: '' }); expect(fetchMock.mock.calls[0][0]).toBe('http://192.168.0.10:1234/v1/models'); expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Bearer secret' }); expect(fetchMock.mock.calls[1][1].headers).not.toHaveProperty('Authorization'); }); it('returns true on a 2xx response and false otherwise', async () => { fetchMock.mockResolvedValueOnce({ ok: true }); await expect(testConnection(config)).resolves.toBe(true); fetchMock.mockResolvedValueOnce({ ok: false }); await expect(testConnection(config)).resolves.toBe(false); }); it('returns false when the network call throws', async () => { fetchMock.mockRejectedValue(new TypeError('Network request failed')); await expect(testConnection(config)).resolves.toBe(false); }); it('aborts after 5 seconds and returns false', async () => { jest.useFakeTimers(); try { fetchMock.mockImplementation( (_url: string, init: { signal: AbortSignal }) => new Promise((_resolve, reject) => { init.signal.addEventListener('abort', () => reject(new Error('aborted'))); }), ); const pending = testConnection(config); await jest.advanceTimersByTimeAsync(5000); await expect(pending).resolves.toBe(false); } finally { jest.useRealTimers(); } }); it('does not leave the timeout timer running after a failure', async () => { jest.useFakeTimers(); try { fetchMock.mockRejectedValue(new TypeError('Network request failed')); await testConnection(config); expect(jest.getTimerCount()).toBe(0); } finally { jest.useRealTimers(); } }); }); describe('getModels', () => { it('returns the data array', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: [{ id: 'm1' }] }) }); await expect(getModels(config)).resolves.toEqual([{ id: 'm1' }]); }); it('falls back to an empty list when data is missing', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) }); await expect(getModels(config)).resolves.toEqual([]); }); it('throws with the HTTP status when the response is not ok', async () => { fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' }); await expect(getModels(config)).rejects.toThrow('Failed to fetch models: 401 Unauthorized'); }); }); describe('chatCompletion', () => { it('posts a non-streaming request with defaults that options can override', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'x' }) }); await chatCompletion(config, 'model-a', [{ role: 'user', content: 'hi' }], { temperature: 0.1 }); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('http://192.168.0.10:1234/v1/chat/completions'); expect(init.method).toBe('POST'); expect(JSON.parse(init.body)).toEqual({ model: 'model-a', messages: [{ role: 'user', content: 'hi' }], stream: false, temperature: 0.1, max_tokens: 2048, }); }); it('throws with the HTTP status when the response is not ok', async () => { fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' }); await expect(chatCompletion(config, 'm', [])).rejects.toThrow('Chat completion failed: 500 Server Error'); }); }); describe('chatCompletionStream (web, fetch + ReadableStream)', () => { const originalOS = Platform.OS; const encoder = new TextEncoder(); beforeEach(() => { Platform.OS = 'web'; }); afterEach(() => { Platform.OS = originalOS; }); const streamOf = (parts: string[]) => new ReadableStream({ start(controller) { parts.forEach((p) => controller.enqueue(encoder.encode(p))); controller.close(); }, }); // Starts a stream and resolves once either onDone or onError fired. const run = () => { const onChunk = jest.fn(); const onDone = jest.fn(); const onError = jest.fn(); const finished = new Promise((resolve) => { chatCompletionStream( config, 'm', [], onChunk, () => { onDone(); resolve(); }, (e) => { onError(e); resolve(); }, ); }); return { onChunk, onDone, onError, finished }; }; it('streams chunks split across reads and calls onDone exactly once', async () => { const whole = `${sseLine('Hi')}\n${sseLine(' there')}\ndata: [DONE]\n`; fetchMock.mockResolvedValue({ ok: true, body: streamOf([whole.slice(0, 20), whole.slice(20)]) }); const { onChunk, onDone, onError, finished } = run(); await finished; expect(onChunk.mock.calls).toEqual([['Hi'], [' there']]); expect(onDone).toHaveBeenCalledTimes(1); expect(onError).not.toHaveBeenCalled(); expect(JSON.parse(fetchMock.mock.calls[0][1].body).stream).toBe(true); }); it('calls onDone when the stream ends without [DONE]', async () => { fetchMock.mockResolvedValue({ ok: true, body: streamOf([`${sseLine('x')}\n`]) }); const { onChunk, onDone, finished } = run(); await finished; expect(onChunk).toHaveBeenCalledWith('x'); expect(onDone).toHaveBeenCalledTimes(1); }); it('reports an HTTP error through onError', async () => { fetchMock.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }); const { onError, finished } = run(); await finished; expect(onError.mock.calls[0][0].message).toBe('Stream failed: 404 Not Found'); }); it('turns a network failure into an actionable connection error', async () => { fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); const { onError, finished } = run(); await finished; expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1'); }); it('does not report an error when the request is aborted', async () => { fetchMock.mockImplementation( (_url: string, init: { signal: AbortSignal }) => new Promise((_resolve, reject) => { init.signal.addEventListener('abort', () => { const err = new Error('aborted'); err.name = 'AbortError'; reject(err); }); }), ); const onError = jest.fn(); const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError); handle.abort(); await new Promise((r) => setTimeout(r, 0)); expect(onError).not.toHaveBeenCalled(); }); }); describe('chatCompletionStream (native, XMLHttpRequest)', () => { class FakeXHR { static last: FakeXHR; status = 200; statusText = 'OK'; responseText = ''; headers: Record = {}; aborted = false; onprogress: (() => void) | null = null; onload: (() => void) | null = null; onerror: (() => void) | null = null; ontimeout: (() => void) | null = null; open = jest.fn(); send = jest.fn(); constructor() { FakeXHR.last = this; } setRequestHeader(k: string, v: string) { this.headers[k] = v; } abort() { this.aborted = true; } } const originalXHR = global.XMLHttpRequest; beforeEach(() => { global.XMLHttpRequest = FakeXHR as unknown as typeof XMLHttpRequest; }); afterEach(() => { global.XMLHttpRequest = originalXHR; }); it('parses progress events incrementally and finishes once', () => { const onChunk = jest.fn(); const onDone = jest.fn(); chatCompletionStream({ ...config, apiKey: 'k' }, 'm', [], onChunk, onDone, jest.fn()); const xhr = FakeXHR.last; expect(xhr.open).toHaveBeenCalledWith('POST', 'http://192.168.0.10:1234/v1/chat/completions'); expect(xhr.headers.Authorization).toBe('Bearer k'); const full = `${sseLine('A')}\n${sseLine('B')}\n`; xhr.responseText = full.slice(0, 30); xhr.onprogress?.(); xhr.responseText = full; xhr.onprogress?.(); xhr.responseText = `${full}data: [DONE]\n`; xhr.onprogress?.(); xhr.onload?.(); expect(onChunk.mock.calls).toEqual([['A'], ['B']]); expect(onDone).toHaveBeenCalledTimes(1); }); it('flushes a final line that has no trailing newline on load', () => { const onChunk = jest.fn(); chatCompletionStream(config, 'm', [], onChunk, jest.fn(), jest.fn()); const xhr = FakeXHR.last; xhr.responseText = sseLine('tail'); xhr.onprogress?.(); xhr.onload?.(); expect(onChunk.mock.calls).toEqual([['tail']]); }); it('reports an HTTP error status instead of finishing silently', () => { const onDone = jest.fn(); const onError = jest.fn(); chatCompletionStream(config, 'm', [], jest.fn(), onDone, onError); const xhr = FakeXHR.last; xhr.status = 401; xhr.statusText = 'Unauthorized'; xhr.responseText = '{"error":"bad key"}'; xhr.onprogress?.(); xhr.onload?.(); expect(onError.mock.calls[0][0].message).toBe('Stream failed: 401 Unauthorized'); expect(onDone).not.toHaveBeenCalled(); }); it('turns a network error into an actionable connection error', () => { const onError = jest.fn(); chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError); FakeXHR.last.status = 0; FakeXHR.last.onerror?.(); expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1'); }); it('reports a timeout', () => { const onError = jest.fn(); chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError); FakeXHR.last.ontimeout?.(); expect(onError.mock.calls[0][0].message).toBe('Request timed out'); }); it('abort() aborts the request and swallows errors thrown by abort', () => { const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), jest.fn()); const xhr = FakeXHR.last; handle.abort(); expect(xhr.aborted).toBe(true); xhr.abort = () => { throw new Error('boom'); }; expect(() => handle.abort()).not.toThrow(); }); });