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/); }); });