mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 22:15:33 +00:00
480 lines
13 KiB
TypeScript
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');
|
|
});
|
|
});
|