194 lines
9.2 KiB
JavaScript
194 lines
9.2 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import { parseEndpoint, buildWorkerEntry, ALL_ROLES, renderConfigYaml, renderDotenv, parseAnswersFromEnv, probeModels } from './setup-lib.mjs';
|
|
import { parse as parseYaml } from 'yaml';
|
|
|
|
describe('parseEndpoint', () => {
|
|
it('keeps a full http URL and derives the /v1-stripped base', () => {
|
|
expect(parseEndpoint('http://localhost:11434/v1')).toEqual({
|
|
endpoint: 'http://localhost:11434/v1',
|
|
base: 'http://localhost:11434',
|
|
});
|
|
});
|
|
it('adds http:// when scheme is missing', () => {
|
|
expect(parseEndpoint('localhost:11434/v1').endpoint).toBe('http://localhost:11434/v1');
|
|
});
|
|
it('accepts https and IPv6 literals', () => {
|
|
const r = parseEndpoint('https://[::1]:9876/v1');
|
|
expect(r.endpoint).toBe('https://[::1]:9876/v1');
|
|
expect(r.base).toBe('https://[::1]:9876');
|
|
});
|
|
it('strips a trailing slash', () => {
|
|
expect(parseEndpoint('http://h:11434/v1/').endpoint).toBe('http://h:11434/v1');
|
|
});
|
|
it('base equals endpoint when there is no /v1 suffix', () => {
|
|
const r = parseEndpoint('http://h:11434');
|
|
expect(r.endpoint).toBe('http://h:11434');
|
|
expect(r.base).toBe('http://h:11434');
|
|
});
|
|
it('rejects empty input', () => {
|
|
expect(parseEndpoint(' ').error).toMatch(/required/);
|
|
});
|
|
it('rejects a non-http scheme', () => {
|
|
expect(parseEndpoint('ftp://h/v1').error).toMatch(/scheme/);
|
|
});
|
|
it('rejects unparseable input', () => {
|
|
expect(parseEndpoint('http://').error).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('buildWorkerEntry', () => {
|
|
it('builds a direct worker with all roles and no api_key', () => {
|
|
const w = buildWorkerEntry({ connectionType: 'direct', endpoint: 'http://h/v1', model: 'qwen3:32b' });
|
|
expect(w).toEqual({
|
|
id: 'local-llm', connection_type: 'direct', endpoint: 'http://h/v1', model: 'qwen3:32b',
|
|
roles: ALL_ROLES, max_concurrency: 1, enabled: true, vlm: false,
|
|
});
|
|
expect('api_key' in w).toBe(false);
|
|
});
|
|
it('builds a gateway worker with api_key and id "gateway"', () => {
|
|
const w = buildWorkerEntry({ connectionType: 'aao_gateway', endpoint: 'http://g/v1', model: 'm', apiKey: 'sk-aao-x' });
|
|
expect(w.id).toBe('gateway');
|
|
expect(w.connection_type).toBe('aao_gateway');
|
|
expect(w.api_key).toBe('sk-aao-x');
|
|
});
|
|
it('returns a fresh roles array (not a shared reference)', () => {
|
|
const a = buildWorkerEntry({ connectionType: 'direct', endpoint: 'e', model: 'm' });
|
|
a.roles.push('mutated');
|
|
const b = buildWorkerEntry({ connectionType: 'direct', endpoint: 'e', model: 'm' });
|
|
expect(b.roles).toEqual(ALL_ROLES);
|
|
});
|
|
});
|
|
|
|
describe('renderConfigYaml', () => {
|
|
it('emits config_version + a single llm.workers entry that re-parses correctly', () => {
|
|
const w = buildWorkerEntry({ connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'qwen3:32b' });
|
|
const text = renderConfigYaml(w);
|
|
const parsed = parseYaml(text);
|
|
expect(parsed.config_version).toBe(2);
|
|
expect(parsed.llm.workers).toHaveLength(1);
|
|
expect(parsed.llm.workers[0]).toMatchObject({
|
|
id: 'local-llm', connection_type: 'direct',
|
|
endpoint: 'http://localhost:11434/v1', model: 'qwen3:32b',
|
|
roles: ['auto', 'fast', 'quality', 'title', 'reflection'],
|
|
max_concurrency: 1, enabled: true, vlm: false,
|
|
});
|
|
});
|
|
it('quotes values containing a colon (model, endpoint) so they do not become maps', () => {
|
|
const w = buildWorkerEntry({ connectionType: 'direct', endpoint: 'http://h:11434/v1', model: 'qwen3:32b' });
|
|
const parsed = parseYaml(renderConfigYaml(w));
|
|
expect(parsed.llm.workers[0].model).toBe('qwen3:32b');
|
|
expect(parsed.llm.workers[0].endpoint).toBe('http://h:11434/v1');
|
|
});
|
|
it('includes api_key for gateway and round-trips it', () => {
|
|
const w = buildWorkerEntry({ connectionType: 'aao_gateway', endpoint: 'http://g:9876/v1', model: 'm', apiKey: 'sk-aao-abc123' });
|
|
const parsed = parseYaml(renderConfigYaml(w));
|
|
expect(parsed.llm.workers[0].api_key).toBe('sk-aao-abc123');
|
|
});
|
|
it('does not include example comment noise (minimal output)', () => {
|
|
const w = buildWorkerEntry({ connectionType: 'direct', endpoint: 'e', model: 'm' });
|
|
expect(renderConfigYaml(w)).not.toMatch(/AAO Gateway Server|backends:|metrics:/);
|
|
});
|
|
it('quotes and escapes a value containing a newline so YAML stays valid', () => {
|
|
const w = buildWorkerEntry({ connectionType: 'direct', endpoint: 'http://h/v1', model: 'a\nb' });
|
|
const parsed = parseYaml(renderConfigYaml(w));
|
|
expect(parsed.llm.workers[0].model).toBe('a\nb');
|
|
});
|
|
});
|
|
|
|
describe('renderDotenv', () => {
|
|
it('creates PORT line from empty input', () => {
|
|
expect(renderDotenv('', { port: 9876 })).toBe('PORT=9876\n');
|
|
});
|
|
it('upserts an existing PORT line and preserves other lines', () => {
|
|
const out = renderDotenv('FOO=bar\nPORT=1000\nBAZ=qux\n', { port: 9876 });
|
|
expect(out).toContain('FOO=bar');
|
|
expect(out).toContain('BAZ=qux');
|
|
expect(out).toContain('PORT=9876');
|
|
expect(out).not.toContain('PORT=1000');
|
|
});
|
|
it('appends PORT when absent and ends with a newline', () => {
|
|
const out = renderDotenv('FOO=bar\n', { port: 9876 });
|
|
expect(out).toBe('FOO=bar\nPORT=9876\n');
|
|
});
|
|
it('collapses multiple existing PORT lines into exactly one', () => {
|
|
const out = renderDotenv('PORT=1\nFOO=bar\nPORT=2\n', { port: 9876 });
|
|
expect(out.match(/^PORT=/gm)).toHaveLength(1);
|
|
expect(out).toContain('PORT=9876');
|
|
expect(out).toContain('FOO=bar');
|
|
});
|
|
});
|
|
|
|
describe('parseAnswersFromEnv', () => {
|
|
it('resolves a direct setup from env', () => {
|
|
const r = parseAnswersFromEnv({ SETUP_LLM_ENDPOINT: 'http://localhost:11434/v1', SETUP_MODEL: 'm' });
|
|
expect(r.error).toBeUndefined();
|
|
expect(r.answers).toMatchObject({ connectionType: 'direct', endpoint: 'http://localhost:11434/v1', base: 'http://localhost:11434', model: 'm', port: 9876 });
|
|
});
|
|
it('requires SETUP_LLM_API_KEY for aao_gateway', () => {
|
|
const r = parseAnswersFromEnv({ SETUP_CONNECTION_TYPE: 'aao_gateway', SETUP_LLM_ENDPOINT: 'http://g/v1', SETUP_MODEL: 'm' });
|
|
expect(r.error).toMatch(/SETUP_LLM_API_KEY/);
|
|
});
|
|
it('requires SETUP_MODEL', () => {
|
|
expect(parseAnswersFromEnv({ SETUP_LLM_ENDPOINT: 'http://h/v1' }).error).toMatch(/SETUP_MODEL/);
|
|
});
|
|
it('rejects a bad endpoint and a bad port', () => {
|
|
expect(parseAnswersFromEnv({ SETUP_LLM_ENDPOINT: 'ftp://h', SETUP_MODEL: 'm' }).error).toMatch(/SETUP_LLM_ENDPOINT/);
|
|
expect(parseAnswersFromEnv({ SETUP_LLM_ENDPOINT: 'http://h/v1', SETUP_MODEL: 'm', PORT: '70000' }).error).toMatch(/PORT/);
|
|
});
|
|
it('rejects an unknown connection type', () => {
|
|
expect(parseAnswersFromEnv({ SETUP_CONNECTION_TYPE: 'weird', SETUP_LLM_ENDPOINT: 'http://h/v1', SETUP_MODEL: 'm' }).error).toMatch(/SETUP_CONNECTION_TYPE/);
|
|
});
|
|
});
|
|
|
|
function res(status, body, { json = true } = {}) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: async () => { if (!json) throw new Error('not json'); return body; },
|
|
};
|
|
}
|
|
|
|
describe('probeModels', () => {
|
|
it('uses Ollama /api/tags first and parses {models:[{name}]}', async () => {
|
|
const calls = [];
|
|
const fetchImpl = async (url) => { calls.push(url); return res(200, { models: [{ name: 'qwen3:32b' }] }); };
|
|
const r = await probeModels({ endpoint: 'http://h:11434/v1', base: 'http://h:11434', fetchImpl });
|
|
expect(r).toEqual({ ok: true, models: ['qwen3:32b'] });
|
|
expect(calls[0]).toBe('http://h:11434/api/tags');
|
|
});
|
|
it('falls back to {endpoint}/models and parses {data:[{id}]}', async () => {
|
|
const calls = [];
|
|
const fetchImpl = async (url) => { calls.push(url); return url.endsWith('/api/tags') ? res(404, {}) : res(200, { data: [{ id: 'gpt-x' }] }); };
|
|
const r = await probeModels({ endpoint: 'http://g:9876/v1', base: 'http://g:9876', fetchImpl });
|
|
expect(r).toEqual({ ok: true, models: ['gpt-x'] });
|
|
expect(calls).toEqual(['http://g:9876/api/tags', 'http://g:9876/v1/models']);
|
|
});
|
|
it('forwards the Bearer header when apiKey is set', async () => {
|
|
let seen;
|
|
const fetchImpl = async (url, init) => { seen = init?.headers?.Authorization; return res(404, {}); };
|
|
await probeModels({ endpoint: 'http://g/v1', base: 'http://g', apiKey: 'sk-aao-x', fetchImpl });
|
|
expect(seen).toBe('Bearer sk-aao-x');
|
|
});
|
|
it('returns ok:false on non-2xx from both probes', async () => {
|
|
const fetchImpl = async () => res(500, {});
|
|
const r = await probeModels({ endpoint: 'http://h/v1', base: 'http://h', fetchImpl });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.models).toEqual([]);
|
|
expect(r.error).toBeTruthy();
|
|
});
|
|
it('treats non-JSON (HTML error) as failure', async () => {
|
|
const fetchImpl = async () => res(200, null, { json: false });
|
|
const r = await probeModels({ endpoint: 'http://h/v1', base: 'http://h', fetchImpl });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.error).toMatch(/non-JSON/);
|
|
});
|
|
it('reports a timeout via AbortError', async () => {
|
|
const fetchImpl = async (_url, init) => new Promise((_resolve, reject) => {
|
|
init.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })));
|
|
});
|
|
const r = await probeModels({ endpoint: 'http://h/v1', base: 'http://h', fetchImpl, timeoutMs: 10 });
|
|
expect(r.ok).toBe(false);
|
|
expect(r.error).toMatch(/timeout/);
|
|
});
|
|
});
|