import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { WaggleConfig, type ProviderEntry, type TeamServerConfig } from '../src/config.js'; describe('WaggleConfig', () => { const tempDirs: string[] = []; function makeTempDir(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-config-test-')); tempDirs.push(dir); return dir; } afterEach(() => { for (const dir of tempDirs) { fs.rmSync(dir, { recursive: true, force: true }); } tempDirs.length = 0; }); it('creates config directory if missing', () => { const base = makeTempDir(); const configDir = path.join(base, 'nested', '.waggle'); new WaggleConfig(configDir); expect(fs.existsSync(configDir)).toBe(true); }); it('returns default config when no file exists', () => { const configDir = makeTempDir(); const config = new WaggleConfig(configDir); expect(config.getDefaultModel()).toBe('claude-sonnet-4-6'); expect(config.getProviders()).toEqual({}); expect(config.getMindPath()).toBe(path.join(configDir, 'default.mind')); }); it('saves and loads provider config', () => { const configDir = makeTempDir(); const config = new WaggleConfig(configDir); const provider: ProviderEntry = { apiKey: 'sk-test-key', models: ['claude-sonnet-4-6', 'claude-haiku-3'], baseUrl: 'https://api.anthropic.com', }; config.setProvider('anthropic', provider); config.save(); // Load fresh instance from same directory const config2 = new WaggleConfig(configDir); const providers = config2.getProviders(); expect(providers['anthropic']).toEqual(provider); expect(providers['anthropic'].apiKey).toBe('sk-test-key'); expect(providers['anthropic'].models).toHaveLength(2); }); it('sets and gets default model', () => { const configDir = makeTempDir(); const config = new WaggleConfig(configDir); config.setDefaultModel('gpt-4o'); config.save(); const config2 = new WaggleConfig(configDir); expect(config2.getDefaultModel()).toBe('gpt-4o'); }); it('returns mind file path', () => { const configDir = makeTempDir(); const config = new WaggleConfig(configDir); expect(config.getMindPath()).toBe(path.join(configDir, 'default.mind')); }); it('removes a provider', () => { const configDir = makeTempDir(); const config = new WaggleConfig(configDir); config.setProvider('anthropic', { apiKey: 'key1', models: ['m1'] }); config.setProvider('openai', { apiKey: 'key2', models: ['m2'] }); config.removeProvider('anthropic'); config.save(); const config2 = new WaggleConfig(configDir); const providers = config2.getProviders(); expect(providers['anthropic']).toBeUndefined(); expect(providers['openai']).toBeDefined(); }); it('returns config directory path', () => { const configDir = makeTempDir(); const config = new WaggleConfig(configDir); expect(config.getConfigDir()).toBe(configDir); }); describe('team server config', () => { it('returns null when no team server configured', () => { const configDir = makeTempDir(); const config = new WaggleConfig(configDir); expect(config.getTeamServer()).toBeNull(); expect(config.isTeamConnected()).toBe(false); }); it('sets and gets team server config', () => { const configDir = makeTempDir(); const config = new WaggleConfig(configDir); const teamConfig: TeamServerConfig = { url: 'https://team.waggle.dev', token: 'clerk-jwt-token', userId: 'user-123', displayName: 'Marko', }; config.setTeamServer(teamConfig); config.save(); const config2 = new WaggleConfig(configDir); const loaded = config2.getTeamServer(); expect(loaded).toEqual(teamConfig); expect(config2.isTeamConnected()).toBe(true); }); it('clears team server config', () => { const configDir = makeTempDir(); const config = new WaggleConfig(configDir); config.setTeamServer({ url: 'https://team.waggle.dev' }); config.clearTeamServer(); config.save(); const config2 = new WaggleConfig(configDir); expect(config2.getTeamServer()).toBeNull(); expect(config2.isTeamConnected()).toBe(false); }); it('persists team server through save/load cycle', () => { const configDir = makeTempDir(); const config = new WaggleConfig(configDir); config.setTeamServer({ url: 'https://example.com', userId: 'u1' }); config.save(); const config2 = new WaggleConfig(configDir); expect(config2.getTeamServer()!.url).toBe('https://example.com'); expect(config2.getTeamServer()!.userId).toBe('u1'); }); }); describe('governed CLI config', () => { it('normalizes, deduplicates, and persists the CLI allowlist', () => { const configDir = makeTempDir(); const config = new WaggleConfig(configDir); config.setCliAllowlist([' node ', 'NODE', '', 'git']); expect(config.getCliAllowlist()).toEqual(['node', 'git']); config.save(); const config2 = new WaggleConfig(configDir); expect(config2.getCliAllowlist()).toEqual(['node', 'git']); }); }); describe('embedding config', () => { const envNames = [ 'EMBEDDING_PROVIDER', 'EMBEDDING_MODEL', 'OLLAMA_HOST', 'OLLAMA_EMBED_MODEL', ] as const; const originalEnv = new Map(); beforeEach(() => { originalEnv.clear(); for (const name of envNames) { originalEnv.set(name, process.env[name]); delete process.env[name]; } }); afterEach(() => { for (const name of envNames) { const value = originalEnv.get(name); if (value === undefined) delete process.env[name]; else process.env[name] = value; } }); it.each(['', ' '])('treats blank provider override %j as unset', override => { process.env.EMBEDDING_PROVIDER = override; const config = new WaggleConfig(makeTempDir()); expect(config.getEmbeddingConfig().provider).toBe('auto'); config.setEmbeddingProvider('inprocess'); expect(config.getEmbeddingConfig().provider).toBe('inprocess'); }); it('trims a valid provider override and keeps it authoritative', () => { process.env.EMBEDDING_PROVIDER = ' inprocess '; const config = new WaggleConfig(makeTempDir()); config.setEmbeddingProvider('ollama'); expect(config.getEmbeddingConfig().provider).toBe('inprocess'); }); it('treats blank embedding model and Ollama overrides as unset', () => { const configDir = makeTempDir(); fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({ defaultModel: 'claude-sonnet-4-6', providers: {}, embedding: { provider: 'auto', inprocessModel: 'persisted-inprocess-model', ollamaUrl: 'http://127.0.0.1:11434', ollamaModel: 'persisted-ollama-model', }, })); process.env.EMBEDDING_MODEL = ' '; process.env.OLLAMA_HOST = ''; process.env.OLLAMA_EMBED_MODEL = ' '; expect(new WaggleConfig(configDir).getEmbeddingConfig()).toMatchObject({ inprocess: { model: 'persisted-inprocess-model' }, ollama: { baseUrl: 'http://127.0.0.1:11434', model: 'persisted-ollama-model', }, }); }); }); describe('Model Pilot config fields', () => { let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-config-pilot-')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it('returns null for fallbackModel when not set', () => { const config = new WaggleConfig(tmpDir); expect(config.getFallbackModel()).toBeNull(); }); it('persists fallbackModel', () => { const config = new WaggleConfig(tmpDir); config.setFallbackModel('qwen/qwen3.6-plus:free'); config.save(); const config2 = new WaggleConfig(tmpDir); expect(config2.getFallbackModel()).toBe('qwen/qwen3.6-plus:free'); }); it('returns null for budgetModel when not set', () => { const config = new WaggleConfig(tmpDir); expect(config.getBudgetModel()).toBeNull(); }); it('persists budgetModel', () => { const config = new WaggleConfig(tmpDir); config.setBudgetModel('deepseek/deepseek-chat-v3-0324:free'); config.save(); const config2 = new WaggleConfig(tmpDir); expect(config2.getBudgetModel()).toBe('deepseek/deepseek-chat-v3-0324:free'); }); it('returns 0.8 as default budgetThreshold', () => { const config = new WaggleConfig(tmpDir); expect(config.getBudgetThreshold()).toBe(0.8); }); it('persists budgetThreshold', () => { const config = new WaggleConfig(tmpDir); config.setBudgetThreshold(0.6); config.save(); const config2 = new WaggleConfig(tmpDir); expect(config2.getBudgetThreshold()).toBe(0.6); }); it('clearFallbackModel removes the field', () => { const config = new WaggleConfig(tmpDir); config.setFallbackModel('test-model'); config.save(); config.clearFallbackModel(); config.save(); const config2 = new WaggleConfig(tmpDir); expect(config2.getFallbackModel()).toBeNull(); }); it('returns 90 as default maxIterations', () => { const config = new WaggleConfig(tmpDir); expect(config.getMaxIterations()).toBe(90); }); it('persists maxIterations', () => { const config = new WaggleConfig(tmpDir); config.setMaxIterations(50); config.save(); const config2 = new WaggleConfig(tmpDir); expect(config2.getMaxIterations()).toBe(50); }); }); });