/** * Tests for loadGlobalConfig default values when config.yaml is missing */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { vi } from 'vitest'; // Mock the home directory to use a temp directory const testHomeDir = join(tmpdir(), `takt-gc-test-${Date.now()}`); vi.mock('node:os', async () => { const actual = await vi.importActual('node:os'); return { ...actual, homedir: () => testHomeDir, }; }); // Import after mocks are set up const { loadGlobalConfig, saveGlobalConfig, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js'); const { getGlobalConfigPath } = await import('../infra/config/paths.js'); describe('loadGlobalConfig', () => { beforeEach(() => { invalidateGlobalConfigCache(); mkdirSync(testHomeDir, { recursive: true }); }); afterEach(() => { if (existsSync(testHomeDir)) { rmSync(testHomeDir, { recursive: true }); } }); it('should return default values when config.yaml does not exist', () => { const config = loadGlobalConfig(); expect(config.language).toBe('en'); expect(config.logLevel).toBe('info'); expect(config.provider).toBe('claude'); expect(config.model).toBeUndefined(); expect(config.verbose).toBeUndefined(); expect(config.pipeline).toBeUndefined(); }); it('should return the same cached object on subsequent calls', () => { const config1 = loadGlobalConfig(); const config2 = loadGlobalConfig(); expect(config1).toBe(config2); }); it('should return a fresh object after cache invalidation', () => { const config1 = loadGlobalConfig(); invalidateGlobalConfigCache(); const config2 = loadGlobalConfig(); expect(config1).not.toBe(config2); expect(config1).toEqual(config2); }); it('should load from config.yaml when it exists', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'language: ja\nprovider: codex\nlog_level: debug\n', 'utf-8', ); const config = loadGlobalConfig(); expect(config.language).toBe('ja'); expect(config.provider).toBe('codex'); expect(config.logLevel).toBe('debug'); }); it('should apply env override for nested provider_options key', () => { const original = process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS; try { process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS = 'true'; invalidateGlobalConfigCache(); const config = loadGlobalConfig(); expect(config.providerOptions?.claude?.sandbox?.allowUnsandboxedCommands).toBe(true); } finally { if (original === undefined) { delete process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS; } else { process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS = original; } } }); it('should load pipeline config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), [ 'language: en', 'pipeline:', ' default_branch_prefix: "feat/"', ' commit_message_template: "fix: {title} (#{issue})"', ].join('\n'), 'utf-8', ); const config = loadGlobalConfig(); expect(config.pipeline).toBeDefined(); expect(config.pipeline!.defaultBranchPrefix).toBe('feat/'); expect(config.pipeline!.commitMessageTemplate).toBe('fix: {title} (#{issue})'); expect(config.pipeline!.prBodyTemplate).toBeUndefined(); }); it('should save and reload pipeline config', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); // Create minimal config first writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.pipeline = { defaultBranchPrefix: 'takt/', commitMessageTemplate: 'feat: {title} (#{issue})', }; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.pipeline).toBeDefined(); expect(reloaded.pipeline!.defaultBranchPrefix).toBe('takt/'); expect(reloaded.pipeline!.commitMessageTemplate).toBe('feat: {title} (#{issue})'); }); it('should load auto_pr config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'language: en\nauto_pr: true\n', 'utf-8', ); const config = loadGlobalConfig(); expect(config.autoPr).toBe(true); }); it('should save and reload auto_pr config', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); // Create minimal config first writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.autoPr = true; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.autoPr).toBe(true); }); it('should save auto_pr: false when explicitly set', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.autoPr = false; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.autoPr).toBe(false); }); it('should read from cache without hitting disk on second call', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'language: ja\nprovider: codex\n', 'utf-8', ); const config1 = loadGlobalConfig(); expect(config1.language).toBe('ja'); // Overwrite file on disk - cached result should still be returned writeFileSync( getGlobalConfigPath(), 'language: en\nprovider: claude\n', 'utf-8', ); const config2 = loadGlobalConfig(); expect(config2.language).toBe('ja'); expect(config2).toBe(config1); // After invalidation, the new file content is read invalidateGlobalConfigCache(); const config3 = loadGlobalConfig(); expect(config3.language).toBe('en'); expect(config3).not.toBe(config1); }); it('should load prevent_sleep config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'language: en\nprevent_sleep: true\n', 'utf-8', ); const config = loadGlobalConfig(); expect(config.preventSleep).toBe(true); }); it('should save and reload prevent_sleep config', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.preventSleep = true; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.preventSleep).toBe(true); }); it('should save prevent_sleep: false when explicitly set', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.preventSleep = false; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.preventSleep).toBe(false); }); it('should have undefined preventSleep by default', () => { const config = loadGlobalConfig(); expect(config.preventSleep).toBeUndefined(); }); it('should load notification_sound config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'language: en\nnotification_sound: false\n', 'utf-8', ); const config = loadGlobalConfig(); expect(config.notificationSound).toBe(false); }); it('should save and reload notification_sound config', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.notificationSound = true; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.notificationSound).toBe(true); }); it('should save notification_sound: false when explicitly set', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.notificationSound = false; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.notificationSound).toBe(false); }); it('should have undefined notificationSound by default', () => { const config = loadGlobalConfig(); expect(config.notificationSound).toBeUndefined(); }); it('should load notification_sound_events config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), [ 'language: en', 'notification_sound_events:', ' iteration_limit: false', ' piece_complete: true', ' piece_abort: true', ' run_complete: true', ' run_abort: false', ].join('\n'), 'utf-8', ); const config = loadGlobalConfig(); expect(config.notificationSoundEvents).toEqual({ iterationLimit: false, pieceComplete: true, pieceAbort: true, runComplete: true, runAbort: false, }); }); it('should load observability.provider_events config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), [ 'language: en', 'observability:', ' provider_events: false', ].join('\n'), 'utf-8', ); const config = loadGlobalConfig(); expect(config.observability).toEqual({ providerEvents: false, }); }); it('should save and reload observability.provider_events config', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.observability = { providerEvents: false, }; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.observability).toEqual({ providerEvents: false, }); }); it('should save and reload notification_sound_events config', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.notificationSoundEvents = { iterationLimit: false, pieceComplete: true, pieceAbort: false, runComplete: true, runAbort: true, }; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.notificationSoundEvents).toEqual({ iterationLimit: false, pieceComplete: true, pieceAbort: false, runComplete: true, runAbort: true, }); }); it('should load interactive_preview_movements config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'language: en\ninteractive_preview_movements: 5\n', 'utf-8', ); const config = loadGlobalConfig(); expect(config.interactivePreviewMovements).toBe(5); }); it('should save and reload interactive_preview_movements config', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.interactivePreviewMovements = 7; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.interactivePreviewMovements).toBe(7); }); it('should default interactive_preview_movements to 3', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); expect(config.interactivePreviewMovements).toBe(3); }); it('should accept interactive_preview_movements: 0 to disable', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'language: en\ninteractive_preview_movements: 0\n', 'utf-8', ); const config = loadGlobalConfig(); expect(config.interactivePreviewMovements).toBe(0); }); describe('persona_providers', () => { it('should load persona_providers from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), [ 'language: en', 'persona_providers:', ' coder:', ' provider: codex', ' reviewer:', ' provider: claude', ' model: claude-3-5-sonnet-latest', ].join('\n'), 'utf-8', ); const config = loadGlobalConfig(); expect(config.personaProviders).toEqual({ coder: { provider: 'codex' }, reviewer: { provider: 'claude', model: 'claude-3-5-sonnet-latest' }, }); }); it('should load persona_providers with model only (no provider)', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), [ 'language: en', 'persona_providers:', ' coder:', ' model: o3-mini', ].join('\n'), 'utf-8', ); const config = loadGlobalConfig(); expect(config.personaProviders).toEqual({ coder: { model: 'o3-mini' }, }); }); it('should save and reload persona_providers', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.personaProviders = { coder: { provider: 'codex', model: 'o3-mini' } }; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.personaProviders).toEqual({ coder: { provider: 'codex', model: 'o3-mini' } }); }); it('should normalize legacy string format to object format', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'language: en\npersona_providers:\n coder: codex\n', 'utf-8', ); const config = loadGlobalConfig(); expect(config.personaProviders).toEqual({ coder: { provider: 'codex' }, }); }); it('should have undefined personaProviders by default', () => { const config = loadGlobalConfig(); expect(config.personaProviders).toBeUndefined(); }); it('should not save persona_providers when empty', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.personaProviders = {}; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.personaProviders).toBeUndefined(); }); it('should throw when persona entry has codex provider with Claude model alias', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'language: en\npersona_providers:\n coder:\n provider: codex\n model: opus\n', 'utf-8', ); expect(() => loadGlobalConfig()).toThrow(/Claude model alias/); }); it('should throw when persona entry has opencode provider without model', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'language: en\npersona_providers:\n reviewer:\n provider: opencode\n', 'utf-8', ); expect(() => loadGlobalConfig()).toThrow(/requires model/); }); it('should not throw when persona entry has opencode provider with compatible model', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'language: en\npersona_providers:\n coder:\n provider: opencode\n model: opencode/big-pickle\n', 'utf-8', ); expect(() => loadGlobalConfig()).not.toThrow(); }); }); describe('runtime', () => { it('should load runtime.prepare from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), [ 'language: en', 'runtime:', ' prepare:', ' - gradle', ' - node', ].join('\n'), 'utf-8', ); const config = loadGlobalConfig(); expect(config.runtime).toEqual({ prepare: ['gradle', 'node'] }); }); it('should save and reload runtime.prepare', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); const config = loadGlobalConfig(); config.runtime = { prepare: ['gradle', 'node'] }; saveGlobalConfig(config); invalidateGlobalConfigCache(); const reloaded = loadGlobalConfig(); expect(reloaded.runtime).toEqual({ prepare: ['gradle', 'node'] }); }); }); describe('provider/model compatibility validation', () => { it('should throw when provider is codex but model is a Claude alias (opus)', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: codex\nmodel: opus\n', 'utf-8', ); expect(() => loadGlobalConfig()).toThrow(/model 'opus' is a Claude model alias but provider is 'codex'/); }); it('should throw when provider is codex but model is sonnet', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: codex\nmodel: sonnet\n', 'utf-8', ); expect(() => loadGlobalConfig()).toThrow(/model 'sonnet' is a Claude model alias but provider is 'codex'/); }); it('should throw when provider is codex but model is haiku', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: codex\nmodel: haiku\n', 'utf-8', ); expect(() => loadGlobalConfig()).toThrow(/model 'haiku' is a Claude model alias but provider is 'codex'/); }); it('should not throw when provider is codex with a compatible model', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: codex\nmodel: gpt-4o\n', 'utf-8', ); expect(() => loadGlobalConfig()).not.toThrow(); }); it('should not throw when provider is claude with Claude models', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: claude\nmodel: opus\n', 'utf-8', ); expect(() => loadGlobalConfig()).not.toThrow(); }); it('should not throw when provider is codex without a model', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: codex\n', 'utf-8', ); expect(() => loadGlobalConfig()).not.toThrow(); }); it('should throw when provider is opencode but model is a Claude alias (opus)', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: opencode\nmodel: opus\n', 'utf-8', ); expect(() => loadGlobalConfig()).toThrow(/model 'opus' is a Claude model alias but provider is 'opencode'/); }); it('should throw when provider is opencode but model is sonnet', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: opencode\nmodel: sonnet\n', 'utf-8', ); expect(() => loadGlobalConfig()).toThrow(/model 'sonnet' is a Claude model alias but provider is 'opencode'/); }); it('should throw when provider is opencode but model is haiku', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: opencode\nmodel: haiku\n', 'utf-8', ); expect(() => loadGlobalConfig()).toThrow(/model 'haiku' is a Claude model alias but provider is 'opencode'/); }); it('should not throw when provider is opencode with a compatible model', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: opencode\nmodel: opencode/big-pickle\n', 'utf-8', ); expect(() => loadGlobalConfig()).not.toThrow(); }); it('should throw when provider is opencode without a model', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: opencode\n', 'utf-8', ); expect(() => loadGlobalConfig()).toThrow(/provider 'opencode' requires model in 'provider\/model' format/i); }); it('should throw when provider is opencode and model is not provider/model format', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), 'provider: opencode\nmodel: big-pickle\n', 'utf-8', ); expect(() => loadGlobalConfig()).toThrow(/must be in 'provider\/model' format/i); }); }); });