Files
LocalMind/__tests__/lmStudioApi.test.ts
evandro 808cb1610b fix(api): handle hosts starting with http, HTTP errors on XHR and clearer connection errors
- buildBaseUrl treated any host starting with 'http' (e.g. httpbin.local) as having a scheme
- native streaming finished silently on 4xx/5xx; it now reports the status
- network failures now explain how to fix the connection instead of 'XHR streaming failed: 0'
- testConnection left its 5 s timer running on failure
- SSE parser accepts 'data:' without a space, as the spec allows
2026-10-10 04:12:13 -03:00

391 lines
14 KiB
TypeScript

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<Uint8Array>({
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<void>((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<string, string> = {};
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();
});
});