/** * Unit tests for composite variant service operations */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { updateCompositeVariant } from '../../../src/cliproxy/services/variant-service'; import { saveCompositeVariantUnified, listVariantsFromConfig, } from '../../../src/cliproxy/services/variant-config-adapter'; import { CompositeVariantConfig } from '../../../src/config/unified-config-types'; describe('updateCompositeVariant', () => { let tmpDir: string; let originalCcsDir: string | undefined; beforeEach(() => { // Create temp directory for isolated config tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-')); originalCcsDir = process.env.CCS_DIR; // Use CCS_DIR (not CCS_HOME which appends .ccs) process.env.CCS_DIR = tmpDir; // Create unified config file const configDir = tmpDir; const configPath = path.join(configDir, 'config.yaml'); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync( configPath, `version: 2 accounts: {} profiles: {} preferences: theme: system telemetry: false auto_update: true cliproxy: oauth_accounts: {} providers: - gemini - codex - agy variants: {} `, 'utf-8' ); }); afterEach(() => { // Restore original CCS_DIR if (originalCcsDir !== undefined) { process.env.CCS_DIR = originalCcsDir; } else { delete process.env.CCS_DIR; } // Clean up temp directory if (tmpDir && fs.existsSync(tmpDir)) { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); it('should update partial tier config (only opus model)', () => { // Setup: Create initial composite variant const initialConfig: CompositeVariantConfig = { type: 'composite', default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', port: 8318, }; saveCompositeVariantUnified('test', initialConfig); // Create dummy settings file to avoid deletion error const settingsDir = path.join(tmpDir, 'cliproxy'); fs.mkdirSync(settingsDir, { recursive: true }); fs.writeFileSync( path.join(settingsDir, 'composite-test.settings.json'), JSON.stringify({ env: {} }), 'utf-8' ); // Test: Update only opus tier const result = updateCompositeVariant('test', { tiers: { opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' }, }, }); expect(result.success).toBe(true); expect(result.variant?.tiers?.opus.model).toBe('claude-opus-4-6-thinking'); expect(result.variant?.tiers?.sonnet.model).toBe('claude-sonnet-4-5-thinking'); // Unchanged expect(result.variant?.tiers?.haiku.model).toBe('claude-haiku-4-5-20251001'); // Unchanged }); it('should update default tier', () => { // Setup: Create initial composite variant const initialConfig: CompositeVariantConfig = { type: 'composite', default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', port: 8318, }; saveCompositeVariantUnified('test', initialConfig); // Create dummy settings file const settingsDir = path.join(tmpDir, 'cliproxy'); fs.mkdirSync(settingsDir, { recursive: true }); fs.writeFileSync( path.join(settingsDir, 'composite-test.settings.json'), JSON.stringify({ env: {} }), 'utf-8' ); // Test: Update default tier const result = updateCompositeVariant('test', { defaultTier: 'opus', }); expect(result.success).toBe(true); expect(result.variant?.default_tier).toBe('opus'); expect(result.variant?.provider).toBe('gemini'); // Provider from opus tier }); it('should update all tiers', () => { // Setup: Create initial composite variant const initialConfig: CompositeVariantConfig = { type: 'composite', default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', port: 8318, }; saveCompositeVariantUnified('test', initialConfig); // Create dummy settings file const settingsDir = path.join(tmpDir, 'cliproxy'); fs.mkdirSync(settingsDir, { recursive: true }); fs.writeFileSync( path.join(settingsDir, 'composite-test.settings.json'), JSON.stringify({ env: {} }), 'utf-8' ); // Test: Update all tiers const result = updateCompositeVariant('test', { tiers: { opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' }, sonnet: { provider: 'codex', model: 'codex-sonnet-4-5' }, haiku: { provider: 'gemini', model: 'gemini-2.5-flash' }, }, }); expect(result.success).toBe(true); expect(result.variant?.tiers?.opus.provider).toBe('agy'); expect(result.variant?.tiers?.sonnet.provider).toBe('codex'); expect(result.variant?.tiers?.haiku.provider).toBe('gemini'); }); it('should return error when variant does not exist', () => { const result = updateCompositeVariant('nonexistent', { tiers: { opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' }, }, }); expect(result.success).toBe(false); expect(result.error).toContain('not found'); }); it('should return error when variant is not composite type', () => { // Setup: Create non-composite variant in unified config const configPath = path.join(tmpDir, 'config.yaml'); const yamlContent = `version: 2 accounts: {} profiles: {} preferences: theme: system telemetry: false auto_update: true cliproxy: oauth_accounts: {} providers: - gemini - codex - agy variants: simple: provider: gemini settings: cliproxy/simple.settings.json port: 8318 `; fs.writeFileSync(configPath, yamlContent, 'utf-8'); const result = updateCompositeVariant('simple', { tiers: { opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' }, }, }); expect(result.success).toBe(false); expect(result.error).toContain('not a composite variant'); }); }); describe('saveCompositeVariantUnified', () => { let tmpDir: string; let originalCcsDir: string | undefined; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-')); originalCcsDir = process.env.CCS_DIR; process.env.CCS_DIR = tmpDir; // Create unified config file const configPath = path.join(tmpDir, 'config.yaml'); fs.writeFileSync( configPath, `version: 2 accounts: {} profiles: {} preferences: theme: system telemetry: false auto_update: true cliproxy: oauth_accounts: {} providers: - gemini - codex - agy variants: {} `, 'utf-8' ); }); afterEach(() => { if (originalCcsDir !== undefined) { process.env.CCS_DIR = originalCcsDir; } else { delete process.env.CCS_DIR; } if (tmpDir && fs.existsSync(tmpDir)) { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); it('should save composite variant to unified config', () => { const config: CompositeVariantConfig = { type: 'composite', default_tier: 'sonnet', tiers: { opus: { provider: 'gemini', model: 'gemini-3-pro-preview' }, sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, }, settings: 'cliproxy/composite-test.settings.json', port: 8318, }; saveCompositeVariantUnified('test', config); const variants = listVariantsFromConfig(); expect(variants.test).toBeDefined(); expect(variants.test.type).toBe('composite'); expect(variants.test.default_tier).toBe('sonnet'); expect(variants.test.tiers?.opus.model).toBe('gemini-3-pro-preview'); }); }); describe('listVariantsFromConfig - composite variants', () => { let tmpDir: string; let originalCcsDir: string | undefined; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-')); originalCcsDir = process.env.CCS_DIR; process.env.CCS_DIR = tmpDir; }); afterEach(() => { if (originalCcsDir !== undefined) { process.env.CCS_DIR = originalCcsDir; } else { delete process.env.CCS_DIR; } if (tmpDir && fs.existsSync(tmpDir)) { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); it('should list composite variants with fallback field', () => { // Create unified config with composite variant that has fallback const configPath = path.join(tmpDir, 'config.yaml'); const yamlContent = `version: 2 accounts: {} profiles: {} preferences: theme: system telemetry: false auto_update: true cliproxy: oauth_accounts: {} providers: - gemini - codex - agy variants: test: type: composite default_tier: sonnet tiers: opus: provider: agy model: claude-opus-4-6-thinking fallback: provider: gemini model: gemini-3-pro-preview sonnet: provider: agy model: claude-sonnet-4-5-thinking haiku: provider: agy model: claude-haiku-4-5-20251001 settings: cliproxy/composite-test.settings.json port: 8318 `; fs.writeFileSync(configPath, yamlContent, 'utf-8'); const variants = listVariantsFromConfig(); expect(variants.test).toBeDefined(); expect(variants.test.hasFallback).toBe(true); }); it('should list composite variants with thinking field', () => { // Create unified config with composite variant that has per-tier thinking const configPath = path.join(tmpDir, 'config.yaml'); const yamlContent = `version: 2 accounts: {} profiles: {} preferences: theme: system telemetry: false auto_update: true cliproxy: oauth_accounts: {} providers: - gemini - codex - agy variants: test: type: composite default_tier: sonnet tiers: opus: provider: agy model: claude-opus-4-6-thinking thinking: xhigh sonnet: provider: agy model: claude-sonnet-4-5-thinking thinking: medium haiku: provider: agy model: claude-haiku-4-5-20251001 thinking: off settings: cliproxy/composite-test.settings.json port: 8318 `; fs.writeFileSync(configPath, yamlContent, 'utf-8'); const variants = listVariantsFromConfig(); expect(variants.test).toBeDefined(); expect(variants.test.tiers?.opus.thinking).toBe('xhigh'); expect(variants.test.tiers?.sonnet.thinking).toBe('medium'); expect(variants.test.tiers?.haiku.thinking).toBe('off'); }); it('should have hasFallback=false when no fallbacks configured', () => { // Create unified config with composite variant WITHOUT fallback const configPath = path.join(tmpDir, 'config.yaml'); const yamlContent = `version: 2 accounts: {} profiles: {} preferences: theme: system telemetry: false auto_update: true cliproxy: oauth_accounts: {} providers: - gemini - codex - agy variants: test: type: composite default_tier: sonnet tiers: opus: provider: agy model: claude-opus-4-6-thinking sonnet: provider: agy model: claude-sonnet-4-5-thinking haiku: provider: agy model: claude-haiku-4-5-20251001 settings: cliproxy/composite-test.settings.json port: 8318 `; fs.writeFileSync(configPath, yamlContent, 'utf-8'); const variants = listVariantsFromConfig(); expect(variants.test).toBeDefined(); expect(variants.test.hasFallback).toBe(false); }); it('should skip malformed composite variant and keep valid variants', () => { const configPath = path.join(tmpDir, 'config.yaml'); const yamlContent = `version: 2 accounts: {} profiles: {} preferences: theme: system telemetry: false auto_update: true cliproxy: oauth_accounts: {} providers: - gemini - codex - agy variants: bad: type: composite default_tier: sonnet tiers: opus: provider: agy model: claude-opus-4-6-thinking sonnet: provider: agy model: claude-sonnet-4-5-thinking settings: cliproxy/bad.settings.json port: 8318 good: provider: gemini settings: cliproxy/good.settings.json port: 8319 `; fs.writeFileSync(configPath, yamlContent, 'utf-8'); const variants = listVariantsFromConfig(); expect(variants.bad).toBeUndefined(); expect(variants.good).toBeDefined(); expect(variants.good.provider).toBe('gemini'); }); });