Files
ccs/tests/unit/cliproxy/composite-variant-service.test.ts
T

480 lines
13 KiB
TypeScript

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