maestro/scripts/setup-lib.test.mjs
oss-sync 42031b18b0
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (8447335)
2026-06-05 06:25:26 +00:00

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