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