Files
ccs/tests/unit/targets/droid-config-manager.test.ts
T

735 lines
24 KiB
TypeScript

/**
* Unit tests for Droid config manager
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
upsertCcsModel,
removeCcsModel,
listCcsModels,
pruneOrphanedModels,
} from '../../../src/targets/droid-config-manager';
describe('droid-config-manager', () => {
let tmpDir: string;
let originalCcsHome: string | undefined;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('upsertCcsModel', () => {
it('should return a selector reference for the managed model', async () => {
const ref = await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy-key',
provider: 'anthropic',
});
expect(ref.profile).toBe('gemini');
expect(ref.selectorAlias).toBe('CCS-gemini-0');
expect(ref.selector).toBe('custom:CCS-gemini-0');
expect(ref.index).toBe(0);
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.model).toBe('custom:CCS-gemini-0');
});
it('should create settings.json with customModels', async () => {
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy-key',
provider: 'anthropic',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
expect(fs.existsSync(settingsPath)).toBe(true);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels).toHaveLength(1);
expect(settings.customModels[0].displayName).toBe('CCS gemini');
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8317');
expect(settings.model).toBe('custom:CCS-gemini-0');
});
it('should update existing entry on second upsert', async () => {
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'key-1',
provider: 'anthropic',
});
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8318',
apiKey: 'key-2',
provider: 'anthropic',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels).toHaveLength(1);
expect(settings.customModels[0].apiKey).toBe('key-2');
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318');
});
it('should persist generic provider reasoning_effort from override', async () => {
await upsertCcsModel('glm', {
model: 'glm-4.7',
displayName: 'CCS glm',
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
apiKey: 'glm-key',
provider: 'generic-chat-completion-api',
reasoningOverride: 'high',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels[0].extraArgs?.reasoning_effort).toBe('high');
expect(settings.customModels[0].extraArgs?.reasoning).toBeUndefined();
expect(settings.customModels[0].extraArgs?.thinking).toBeUndefined();
});
it('should persist openai provider reasoning.effort from --effort alias override', async () => {
await upsertCcsModel('codex', {
model: 'gpt-5.2',
displayName: 'CCS codex',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'openai-key',
provider: 'openai',
reasoningOverride: 'xhigh',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels[0].extraArgs?.reasoning?.effort).toBe('xhigh');
expect(settings.customModels[0].extraArgs?.reasoning_effort).toBeUndefined();
});
it('should persist anthropic thinking budget from numeric override', async () => {
await upsertCcsModel('agy', {
model: 'claude-opus-4-5-thinking',
displayName: 'CCS agy',
baseUrl: 'https://api.anthropic.com',
apiKey: 'anthropic-key',
provider: 'anthropic',
reasoningOverride: 40960,
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels[0].extraArgs?.thinking?.type).toBe('enabled');
expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBe(40960);
});
it('should clear prior reasoning config when override disables thinking', async () => {
await upsertCcsModel('glm', {
model: 'glm-4.7',
displayName: 'CCS glm',
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
apiKey: 'glm-key',
provider: 'generic-chat-completion-api',
reasoningOverride: 'high',
});
await upsertCcsModel('glm', {
model: 'glm-4.7',
displayName: 'CCS glm',
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
apiKey: 'glm-key',
provider: 'generic-chat-completion-api',
reasoningOverride: 'off',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels[0].extraArgs).toBeUndefined();
});
it('should preserve user entries', async () => {
// Create existing settings with user's own custom model
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{
model: 'gpt-4o',
displayName: 'My GPT',
baseUrl: 'https://api.openai.com',
apiKey: 'sk-xxx',
provider: 'openai',
},
],
})
);
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy',
provider: 'anthropic',
});
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
expect(settings.customModels).toHaveLength(2);
expect(settings.customModels[0].displayName).toBe('My GPT');
expect(settings.customModels[1].displayName).toBe('CCS gemini');
});
it('should preserve user entries with unknown provider strings', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{
model: 'user-model',
displayName: 'My Custom Provider',
baseUrl: 'https://example.invalid',
apiKey: 'user-key',
provider: 'custom-provider',
},
],
})
);
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy',
provider: 'anthropic',
});
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
expect(settings.customModels).toHaveLength(2);
expect(settings.customModels[0].provider).toBe('custom-provider');
expect(settings.customModels[1].displayName).toBe('CCS gemini');
});
it('should write with restricted permissions', async () => {
await upsertCcsModel('test', {
model: 'test-model',
displayName: 'CCS test',
baseUrl: 'http://localhost:8317',
apiKey: 'secret',
provider: 'anthropic',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const stat = fs.statSync(settingsPath);
// eslint-disable-next-line no-bitwise
const otherPerms = stat.mode & 0o077;
expect(otherPerms).toBe(0);
});
it('should reject symlinked temp file path', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({ customModels: [] })
);
fs.symlinkSync('/tmp', path.join(factoryDir, 'settings.json.tmp'));
await expect(
upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy-key',
provider: 'anthropic',
})
).rejects.toThrow(/settings\.json\.tmp is a symlink/);
});
it('should update legacy ccs- alias entry on upsert', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{
model: 'claude-opus-4-6',
displayName: 'ccs-gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'old-key',
provider: 'anthropic',
},
],
})
);
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8318',
apiKey: 'new-key',
provider: 'anthropic',
});
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
expect(settings.customModels).toHaveLength(1);
expect(settings.customModels[0].displayName).toBe('CCS gemini');
expect(settings.customModels[0].apiKey).toBe('new-key');
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318');
});
it('should reject symlinked settings file on write', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
const realSettings = path.join(factoryDir, 'real-settings.json');
fs.writeFileSync(realSettings, JSON.stringify({ customModels: [] }));
fs.symlinkSync(realSettings, path.join(factoryDir, 'settings.json'));
await expect(
upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy-key',
provider: 'anthropic',
})
).rejects.toThrow(/settings\.json is a symlink/);
});
});
describe('removeCcsModel', () => {
it('should remove a CCS entry', async () => {
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy',
provider: 'anthropic',
});
await removeCcsModel('gemini');
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels).toHaveLength(0);
});
it('should not remove user entries', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{
model: 'gpt-4o',
displayName: 'My GPT',
baseUrl: 'x',
apiKey: 'y',
provider: 'openai',
},
{
model: 'opus',
displayName: 'CCS gemini',
baseUrl: 'x',
apiKey: 'y',
provider: 'anthropic',
},
],
})
);
await removeCcsModel('gemini');
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
expect(settings.customModels).toHaveLength(1);
expect(settings.customModels[0].displayName).toBe('My GPT');
});
it('should remove legacy ccs- alias entries', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{
model: 'opus',
displayName: 'ccs-gemini',
baseUrl: 'x',
apiKey: 'y',
provider: 'anthropic',
},
{
model: 'gpt-4o',
displayName: 'My GPT',
baseUrl: 'x',
apiKey: 'y',
provider: 'openai',
},
],
})
);
await removeCcsModel('gemini');
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
expect(settings.customModels).toHaveLength(1);
expect(settings.customModels[0].displayName).toBe('My GPT');
});
});
describe('listCcsModels', () => {
it('should list only CCS entries', async () => {
await upsertCcsModel('gemini', {
model: 'opus',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy',
provider: 'anthropic',
});
await upsertCcsModel('codex', {
model: 'sonnet',
displayName: 'CCS codex',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy',
provider: 'anthropic',
});
const models = await listCcsModels();
expect(models.size).toBe(2);
expect(models.has('gemini')).toBe(true);
expect(models.has('codex')).toBe(true);
});
it('should return empty map when no settings file', async () => {
const models = await listCcsModels();
expect(models.size).toBe(0);
});
it('should normalize legacy object-map customModels', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: {
gemini: {
model: 'opus',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy',
provider: 'anthropic',
},
invalid: {
model: 'x',
baseUrl: 'x',
},
},
})
);
const models = await listCcsModels();
expect(models.size).toBe(1);
expect(models.has('gemini')).toBe(true);
});
it('should ignore malformed customModels entries', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
null,
123,
'bad',
{ displayName: 'CCS ok', model: 'x', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
],
})
);
const models = await listCcsModels();
expect(models.size).toBe(1);
expect(models.has('ok')).toBe(true);
});
it('should reject symlinked settings file on read', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
const target = path.join(factoryDir, 'real-settings.json');
fs.writeFileSync(target, JSON.stringify({ customModels: [] }));
fs.symlinkSync(target, path.join(factoryDir, 'settings.json'));
await expect(listCcsModels()).rejects.toThrow(/settings\.json is a symlink/);
});
it('should include legacy ccs- alias entries', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{
model: 'opus',
displayName: 'ccs-gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy',
provider: 'anthropic',
},
],
})
);
const models = await listCcsModels();
expect(models.size).toBe(1);
expect(models.has('gemini')).toBe(true);
});
it('should ignore malformed managed display names', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{ model: 'x', displayName: 'CCS ', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
{ model: 'x', displayName: 'ccs-', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
{ model: 'x', displayName: 'CCS ok', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
],
})
);
const models = await listCcsModels();
expect(models.size).toBe(1);
expect(models.has('ok')).toBe(true);
});
it('should recover from corrupted JSON by backing up and returning empty models', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
const settingsPath = path.join(factoryDir, 'settings.json');
fs.writeFileSync(settingsPath, '{"customModels":[', 'utf8');
const models = await listCcsModels();
expect(models.size).toBe(0);
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(true);
});
});
describe('pruneOrphanedModels', () => {
it('should remove orphaned CCS entries', async () => {
await upsertCcsModel('gemini', {
model: 'opus',
displayName: 'CCS gemini',
baseUrl: 'x',
apiKey: 'y',
provider: 'anthropic',
});
await upsertCcsModel('codex', {
model: 'sonnet',
displayName: 'CCS codex',
baseUrl: 'x',
apiKey: 'y',
provider: 'anthropic',
});
// Only gemini is active — codex should be pruned
const removed = await pruneOrphanedModels(['gemini']);
expect(removed).toBe(1);
const models = await listCcsModels();
expect(models.size).toBe(1);
expect(models.has('gemini')).toBe(true);
});
it('should preserve user entries during prune', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{
model: 'gpt-4o',
displayName: 'My GPT',
baseUrl: 'x',
apiKey: 'y',
provider: 'openai',
},
{
model: 'opus',
displayName: 'CCS old-profile',
baseUrl: 'x',
apiKey: 'y',
provider: 'anthropic',
},
],
})
);
const removed = await pruneOrphanedModels([]);
expect(removed).toBe(1);
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
expect(settings.customModels).toHaveLength(1);
expect(settings.customModels[0].displayName).toBe('My GPT');
});
it('should prune orphaned legacy ccs- alias entries', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{
model: 'opus',
displayName: 'ccs-gemini',
baseUrl: 'x',
apiKey: 'y',
provider: 'anthropic',
},
{
model: 'sonnet',
displayName: 'ccs-codex',
baseUrl: 'x',
apiKey: 'y',
provider: 'anthropic',
},
],
})
);
const removed = await pruneOrphanedModels(['gemini']);
expect(removed).toBe(1);
const models = await listCcsModels();
expect(models.size).toBe(1);
expect(models.has('gemini')).toBe(true);
});
it('should prune malformed managed entries while preserving user models', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{ model: 'x', displayName: 'CCS ', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
{ model: 'x', displayName: 'ccs-', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
{
model: 'gpt-4o',
displayName: 'My GPT',
baseUrl: 'x',
apiKey: 'y',
provider: 'openai',
},
],
})
);
const removed = await pruneOrphanedModels([]);
expect(removed).toBe(2);
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
expect(settings.customModels).toHaveLength(1);
expect(settings.customModels[0].displayName).toBe('My GPT');
});
it('should reject invalid active profile names', async () => {
await expect(pruneOrphanedModels(['bad profile'])).rejects.toThrow(/Invalid profile name/);
});
it('should use active profile snapshot taken at call time', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{
model: 'opus',
displayName: 'CCS gemini',
baseUrl: 'x',
apiKey: 'y',
provider: 'anthropic',
},
{
model: 'sonnet',
displayName: 'CCS codex',
baseUrl: 'x',
apiKey: 'y',
provider: 'anthropic',
},
],
})
);
const activeProfiles = ['gemini'];
const prunePromise = pruneOrphanedModels(activeProfiles);
activeProfiles.push('codex'); // Mutation after call should not affect in-flight prune decision.
const removed = await prunePromise;
expect(removed).toBe(1);
const models = await listCcsModels();
expect(models.size).toBe(1);
expect(models.has('gemini')).toBe(true);
expect(models.has('codex')).toBe(false);
});
});
describe('concurrent writes', () => {
it('should handle concurrent upserts without data loss', async () => {
const profiles = Array.from({ length: 10 }, (_, i) => `profile-${i}`);
await Promise.all(
profiles.map((p) =>
upsertCcsModel(p, {
model: 'test-model',
displayName: `CCS ${p}`,
baseUrl: 'http://localhost:8317',
apiKey: 'key',
provider: 'anthropic',
})
)
);
const models = await listCcsModels();
expect(models.size).toBe(10);
for (const p of profiles) {
expect(models.has(p)).toBe(true);
}
}, 15000);
});
});