/** * Tests for `shard setup` — interactive first-provider config wizard. * * Covers the pure pieces (parseSetupArgs, buildSetupConfig, collectAnswers) and the * I/O wrapper (runSetupSub) with injected fs + prompt deps so no real filesystem or TTY * is touched. Expert skip (--yes), existing-config-safe, and --force paths are exercised. */ import { describe, expect, it } from 'bun:test'; import { parseSetupArgs, buildSetupConfig, collectAnswers, runSetupSub, PROVIDER_PRESETS, SetupHelpRequested, type PromptFn, } from '@/cli/commands/setup-cmd'; import type { ShardConfig } from '@/cli/config'; // ─── parseSetupArgs ──────────────────────────────────────────────────────────── describe('shard setup — parseSetupArgs', () => { it('parses --yes / -y', () => { expect(parseSetupArgs(['--yes'])).toMatchObject({ yes: true }); expect(parseSetupArgs(['-y'])).toMatchObject({ yes: true }); }); it('parses --force', () => { expect(parseSetupArgs(['--force'])).toMatchObject({ force: true }); }); it('parses --provider / --model / --alias / --base-url', () => { const p = parseSetupArgs(['--provider', 'ollama', '--model', 'llama3.2', '--alias', 'local', '--base-url', 'http://x/v1']); expect(p).toMatchObject({ providerKey: 'ollama', modelName: 'llama3.2', modelAlias: 'local', baseURL: 'http://x/v1' }); }); it('parses = forms', () => { const p = parseSetupArgs(['--provider=ollama', '--model=m', '--alias=a', '--base-url=u', '--env-var=K', '--auth=none', '--config=/c.json']); expect(p).toMatchObject({ providerKey: 'ollama', modelName: 'm', modelAlias: 'a', baseURL: 'u', envVar: 'K', authKind: 'none', configPath: '/c.json' }); }); it('parses --auth bearer', () => { expect(parseSetupArgs(['--auth', 'bearer'])).toMatchObject({ authKind: 'bearer' }); }); it('throws on invalid --auth', () => { expect(() => parseSetupArgs(['--auth', 'bogus'])).toThrow('invalid --auth'); }); it('throws on unknown argument', () => { expect(() => parseSetupArgs(['--bogus'])).toThrow('unknown argument'); }); it('throws SetupHelpRequested on --help', () => { expect(() => parseSetupArgs(['--help'])).toThrow(SetupHelpRequested); expect(() => parseSetupArgs(['-h'])).toThrow(SetupHelpRequested); }); it('throws on --provider with no value', () => { expect(() => parseSetupArgs(['--provider'])).toThrow('invalid --provider'); }); it('defaults yes=false, force=false', () => { const p = parseSetupArgs([]); expect(p.yes).toBe(false); expect(p.force).toBe(false); }); }); // ─── buildSetupConfig ────────────────────────────────────────────────────────── describe('shard setup — buildSetupConfig', () => { it('builds a complete OpenAI starter config (bearer)', () => { const cfg = buildSetupConfig({ providerKey: 'openai', baseURL: 'https://api.openai.com/v1', auth: { kind: 'bearer', envVar: 'OPENAI_API_KEY' }, modelAlias: 'fast', modelName: 'gpt-4o-mini', }); expect(cfg.defaultProvider).toBe('openai'); expect(cfg.defaultModel).toEqual({ openai: 'fast' }); expect(cfg.providers.openai).toEqual({ baseURL: 'https://api.openai.com/v1', auth: { kind: 'bearer', envVar: 'OPENAI_API_KEY' }, }); expect(cfg.models!.fast).toEqual({ provider: 'openai', name: 'gpt-4o-mini' }); }); it('builds an Ollama config (none auth)', () => { const cfg = buildSetupConfig({ providerKey: 'ollama', baseURL: 'http://127.0.0.1:11434/v1', auth: { kind: 'none' }, modelAlias: 'local', modelName: 'llama3.2:3b', }); expect(cfg.providers.ollama!.auth).toEqual({ kind: 'none' }); expect(cfg.models!.local).toEqual({ provider: 'ollama', name: 'llama3.2:3b' }); }); it('builds a custom-provider config', () => { const cfg = buildSetupConfig({ providerKey: 'my-gw', baseURL: 'https://gw.example/v1', auth: { kind: 'bearer', envVar: 'GW_KEY' }, modelAlias: 'default', modelName: 'gpt-4o', }); expect(cfg.defaultProvider).toBe('my-gw'); expect(cfg.providers['my-gw']).toBeDefined(); expect(cfg.defaultModel).toEqual({ 'my-gw': 'default' }); }); }); // ─── collectAnswers ──────────────────────────────────────────────────────────── /** Build a prompt fn from a queue of answers (consumed in order). An empty or missing * queued answer falls back to the defaultValue, mirroring the real readline prompt. */ function queuedPrompt(answers: string[]): PromptFn { let i = 0; return async (_question: string, defaultValue?: string) => { const a = answers[i++]; if (a !== undefined && a.length > 0) return a; return defaultValue ?? ''; }; } describe('shard setup — collectAnswers', () => { it('--yes uses OpenAI preset defaults (no prompts)', async () => { const answers = await collectAnswers(parseSetupArgs(['--yes']), async () => 'SHOULD-NOT-CALL', false); expect(answers.providerKey).toBe('openai'); expect(answers.baseURL).toBe('https://api.openai.com/v1'); expect(answers.auth).toEqual({ kind: 'bearer', envVar: 'OPENAI_API_KEY' }); expect(answers.modelName).toBe('gpt-4o-mini'); expect(answers.modelAlias).toBe('fast'); }); it('--yes --provider ollama uses Ollama preset (none auth)', async () => { const answers = await collectAnswers(parseSetupArgs(['--yes', '--provider', 'ollama']), async () => '', false); expect(answers.providerKey).toBe('ollama'); expect(answers.auth).toEqual({ kind: 'none' }); expect(answers.modelName).toBe('llama3.2:3b'); }); it('interactive: choose Ollama (2) via menu', async () => { const prompt = queuedPrompt(['2']); // menu choice = Ollama const answers = await collectAnswers(parseSetupArgs([]), prompt, true); expect(answers.providerKey).toBe('ollama'); expect(answers.auth).toEqual({ kind: 'none' }); // Ollama preset has a model name, so no model prompt; alias IS prompted interactively. expect(answers.modelName).toBe('llama3.2:3b'); }); it('interactive: custom provider prompts for key, baseURL, model', async () => { // menu=3 (custom), providerKey, baseURL, envVar, modelName, alias const prompt = queuedPrompt(['3', 'my-gw', 'https://gw/v1', 'GW_KEY', 'gpt-4o', 'main']); const answers = await collectAnswers(parseSetupArgs([]), prompt, true); expect(answers.providerKey).toBe('my-gw'); expect(answers.baseURL).toBe('https://gw/v1'); expect(answers.auth).toEqual({ kind: 'bearer', envVar: 'GW_KEY' }); expect(answers.modelName).toBe('gpt-4o'); expect(answers.modelAlias).toBe('main'); }); it('interactive: default menu choice (enter) → OpenAI', async () => { // empty answer falls back to default '1' → OpenAI; then alias prompt. const prompt = queuedPrompt(['', 'smart']); const answers = await collectAnswers(parseSetupArgs([]), prompt, true); expect(answers.providerKey).toBe('openai'); expect(answers.modelAlias).toBe('smart'); }); it('flags override prompts (interactive but values pre-supplied)', async () => { const prompt = queuedPrompt(['UNREACHABLE']); const answers = await collectAnswers( parseSetupArgs(['--provider', 'openai', '--model', 'gpt-4o', '--alias', 'big', '--base-url', 'https://api.openai.com/v1']), prompt, true, ); // provider is a known preset → no menu. baseURL/model/alias from flags. Only alias // is prompted interactively, but it was supplied via flag so the prompt is skipped. expect(answers.modelName).toBe('gpt-4o'); expect(answers.modelAlias).toBe('big'); }); }); // ─── runSetupSub (I/O wrapper) ───────────────────────────────────────────────── /** In-memory fs for runSetupSub tests. */ function memFs(initial: Record = {}) { const files = new Map(Object.entries(initial)); return { existsSync: (p: string) => files.has(p), mkdirSync: () => {}, writeFileSync: (p: string, d: string) => { files.set(p, d); }, read: (p: string) => files.get(p), }; } describe('shard setup — runSetupSub', () => { it('--yes writes a valid config and exits 0', async () => { const fs = memFs(); const configPath = '/tmp/x/config.json'; const code = await runSetupSub(['--yes'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }); expect(code).toBe(0); const written = JSON.parse(fs.read(configPath)!) as ShardConfig; expect(written.defaultProvider).toBe('openai'); expect(written.providers.openai!.auth).toEqual({ kind: 'bearer', envVar: 'OPENAI_API_KEY' }); }); it('existing config is safe (exit 0, no overwrite)', async () => { const configPath = '/tmp/x/config.json'; const fs = memFs({ [configPath]: '{"defaultProvider":"ollama"}' }); const code = await runSetupSub(['--yes'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }); expect(code).toBe(0); // Original content preserved. expect(fs.read(configPath)).toBe('{"defaultProvider":"ollama"}'); }); it('--force overwrites an existing config', async () => { const configPath = '/tmp/x/config.json'; const fs = memFs({ [configPath]: '{"defaultProvider":"ollama"}' }); const code = await runSetupSub(['--yes', '--force'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }); expect(code).toBe(0); const written = JSON.parse(fs.read(configPath)!) as ShardConfig; expect(written.defaultProvider).toBe('openai'); // overwritten }); it('--yes --provider ollama writes a none-auth config (no export hint)', async () => { const fs = memFs(); const configPath = '/tmp/x/config.json'; const code = await runSetupSub(['--yes', '--provider', 'ollama'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }); expect(code).toBe(0); const written = JSON.parse(fs.read(configPath)!) as ShardConfig; expect(written.providers.ollama!.auth).toEqual({ kind: 'none' }); }); it('interactive path with injected prompt writes config from queued answers', async () => { const fs = memFs(); const configPath = '/tmp/x/config.json'; // menu=2 (ollama), alias prompt → 'local-model' const prompt = queuedPrompt(['2', 'local-model']); const code = await runSetupSub([], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, prompt, env: {}, }); expect(code).toBe(0); const written = JSON.parse(fs.read(configPath)!) as ShardConfig; expect(written.defaultProvider).toBe('ollama'); expect(written.models!['local-model']).toEqual({ provider: 'ollama', name: 'llama3.2:3b' }); }); it('custom provider via flags + --yes (no prompts)', async () => { const fs = memFs(); const configPath = '/tmp/x/config.json'; const code = await runSetupSub( ['--yes', '--provider', 'my-gw', '--base-url', 'https://gw/v1', '--model', 'gpt-4o', '--alias', 'main', '--env-var', 'GW_KEY'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }, ); expect(code).toBe(0); const written = JSON.parse(fs.read(configPath)!) as ShardConfig; expect(written.defaultProvider).toBe('my-gw'); expect(written.providers['my-gw']).toEqual({ baseURL: 'https://gw/v1', auth: { kind: 'bearer', envVar: 'GW_KEY' } }); expect(written.models!.main).toEqual({ provider: 'my-gw', name: 'gpt-4o' }); }); it('--help exits 0', async () => { const code = await runSetupSub(['--help'], { env: {} }); expect(code).toBe(0); }); it('unknown flag exits 2', async () => { const code = await runSetupSub(['--bogus'], { env: {} }); expect(code).toBe(2); }); it('written config passes the loadConfig schema (round-trip)', async () => { const { loadConfig } = await import('@/cli/config'); const fs = memFs(); const configPath = '/tmp/x/config.json'; await runSetupSub(['--yes'], { configPath, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, env: {}, }); // loadConfig should accept the wizard output without throwing. const { config } = loadConfig({ configPath, env: { OPENAI_API_KEY: 'sk-test' }, deps: { readFileSync: () => fs.read(configPath)!, existsSync: () => true }, }); expect(config.defaultProvider).toBe('openai'); }); }); // ─── Presets sanity ──────────────────────────────────────────────────────────── describe('shard setup — PROVIDER_PRESETS', () => { it('has openai first (default choice)', () => { expect(PROVIDER_PRESETS[0]!.key).toBe('openai'); }); it('includes ollama (none auth) and custom', () => { const keys = PROVIDER_PRESETS.map((p) => p.key); expect(keys).toContain('ollama'); expect(keys).toContain('custom'); }); it('ollama preset has none auth', () => { const ollama = PROVIDER_PRESETS.find((p) => p.key === 'ollama')!; expect(ollama.auth).toBe('none'); }); });