import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Command } from 'commander'; import { registerConfigCommand } from './config.js'; // ── helpers ────────────────────────────────────────────────────────────────── /** Build a fresh Command tree with the config command registered. */ function buildProgram(): Command { const program = new Command(); program.exitOverride(); // prevent process.exit during tests registerConfigCommand(program); return program; } /** Locate the 'config' command registered on the root program. */ function getConfigCmd(program: Command): Command { const found = program.commands.find((c) => c.name() === 'config'); if (!found) throw new Error('config command not found'); return found; } // ── subcommand registration ─────────────────────────────────────────────────── describe('registerConfigCommand', () => { it('registers a "config" command on the program', () => { const program = buildProgram(); const names = program.commands.map((c) => c.name()); expect(names).toContain('config'); }); it('registers exactly the required subcommands', () => { const program = buildProgram(); const config = getConfigCmd(program); const subs = config.commands.map((c) => c.name()).sort(); expect(subs).toEqual(['edit', 'get', 'hooks', 'path', 'set', 'show']); }); it('registers hooks sub-subcommands: list, enable, disable', () => { const program = buildProgram(); const config = getConfigCmd(program); const hooks = config.commands.find((c) => c.name() === 'hooks'); expect(hooks).toBeDefined(); const hookSubs = hooks!.commands.map((c) => c.name()).sort(); expect(hookSubs).toEqual(['disable', 'enable', 'list']); }); }); // ── mock config service ─────────────────────────────────────────────────────── const mockSoul = { agentName: 'TestBot', roleDescription: 'test role', communicationStyle: 'direct' as const, }; const mockUser = { userName: 'Tester', pronouns: 'they/them', timezone: 'UTC' }; const mockTools = { credentialsLocation: '/dev/null' }; const mockSvc = { readSoul: vi.fn().mockResolvedValue(mockSoul), readUser: vi.fn().mockResolvedValue(mockUser), readTools: vi.fn().mockResolvedValue(mockTools), writeSoul: vi.fn().mockResolvedValue(undefined), writeUser: vi.fn().mockResolvedValue(undefined), writeTools: vi.fn().mockResolvedValue(undefined), syncFramework: vi.fn().mockResolvedValue(undefined), readAll: vi.fn().mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools }), getValue: vi.fn().mockResolvedValue('TestBot'), setValue: vi.fn().mockResolvedValue('OldBot'), getConfigPath: vi .fn() .mockImplementation((section?: string) => section ? `/home/user/.config/mosaic/${section.toUpperCase()}.md` : '/home/user/.config/mosaic', ), isInitialized: vi.fn().mockReturnValue(true), }; // Mock the config-service module so commands use our mock. vi.mock('../config/config-service.js', () => ({ createConfigService: vi.fn(() => mockSvc), })); // Also mock child_process for the edit command. vi.mock('node:child_process', () => ({ spawnSync: vi.fn().mockReturnValue({ status: 0, error: undefined }), })); // ── config show ─────────────────────────────────────────────────────────────── describe('config show', () => { let consoleSpy: ReturnType; beforeEach(() => { consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.clearAllMocks(); mockSvc.isInitialized.mockReturnValue(true); mockSvc.readAll.mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools }); }); afterEach(() => { consoleSpy.mockRestore(); }); it('calls readAll() and prints a table by default', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'show']); expect(mockSvc.readAll).toHaveBeenCalledOnce(); // Should have printed something expect(consoleSpy).toHaveBeenCalled(); }); it('prints JSON when --format json is passed', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'show', '--format', 'json']); expect(mockSvc.readAll).toHaveBeenCalledOnce(); // Verify JSON was logged const allOutput = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(allOutput).toContain('"agentName"'); }); }); // ── config get ──────────────────────────────────────────────────────────────── describe('config get', () => { let consoleSpy: ReturnType; beforeEach(() => { consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.clearAllMocks(); mockSvc.isInitialized.mockReturnValue(true); mockSvc.getValue.mockResolvedValue('TestBot'); }); afterEach(() => { consoleSpy.mockRestore(); }); it('delegates to getValue() with the provided key', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'get', 'soul.agentName']); expect(mockSvc.getValue).toHaveBeenCalledWith('soul.agentName'); }); it('prints the returned value', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'get', 'soul.agentName']); expect(consoleSpy).toHaveBeenCalledWith('TestBot'); }); }); // ── config set ──────────────────────────────────────────────────────────────── describe('config set', () => { let consoleSpy: ReturnType; beforeEach(() => { consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.clearAllMocks(); mockSvc.isInitialized.mockReturnValue(true); mockSvc.setValue.mockResolvedValue('OldBot'); }); afterEach(() => { consoleSpy.mockRestore(); }); it('delegates to setValue() with key and value', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'set', 'soul.agentName', 'NewBot']); expect(mockSvc.setValue).toHaveBeenCalledWith('soul.agentName', 'NewBot'); }); it('prints old and new values', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'set', 'soul.agentName', 'NewBot']); const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(output).toContain('OldBot'); expect(output).toContain('NewBot'); }); }); // ── config path ─────────────────────────────────────────────────────────────── describe('config path', () => { let consoleSpy: ReturnType; beforeEach(() => { consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.clearAllMocks(); mockSvc.getConfigPath.mockImplementation((section?: string) => section ? `/home/user/.config/mosaic/${section.toUpperCase()}.md` : '/home/user/.config/mosaic', ); }); afterEach(() => { consoleSpy.mockRestore(); }); it('prints the mosaicHome directory when no section is specified', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'path']); expect(mockSvc.getConfigPath).toHaveBeenCalledWith(); expect(consoleSpy).toHaveBeenCalledWith('/home/user/.config/mosaic'); }); it('prints the section file path when --section is given', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'path', '--section', 'soul']); expect(mockSvc.getConfigPath).toHaveBeenCalledWith('soul'); expect(consoleSpy).toHaveBeenCalledWith('/home/user/.config/mosaic/SOUL.md'); }); }); // ── config edit ─────────────────────────────────────────────────────────────── describe('config edit', () => { let consoleSpy: ReturnType; let spawnSyncMock: ReturnType; beforeEach(async () => { consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.clearAllMocks(); mockSvc.isInitialized.mockReturnValue(true); mockSvc.readAll.mockResolvedValue({ soul: mockSoul, user: mockUser, tools: mockTools }); mockSvc.getConfigPath.mockImplementation((section?: string) => section ? `/home/user/.config/mosaic/${section.toUpperCase()}.md` : '/home/user/.config/mosaic', ); // Re-import to get the mock reference const cp = await import('node:child_process'); spawnSyncMock = cp.spawnSync as ReturnType; spawnSyncMock.mockReturnValue({ status: 0, error: undefined }); }); afterEach(() => { consoleSpy.mockRestore(); }); it('calls spawnSync with the editor binary and config path', async () => { process.env['EDITOR'] = 'nano'; const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'edit']); expect(spawnSyncMock).toHaveBeenCalledWith( 'nano', ['/home/user/.config/mosaic'], expect.objectContaining({ stdio: 'inherit' }), ); delete process.env['EDITOR']; }); it('falls back to "vi" when EDITOR is not set', async () => { delete process.env['EDITOR']; const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'edit']); expect(spawnSyncMock).toHaveBeenCalledWith('vi', expect.any(Array), expect.any(Object)); }); it('opens the section-specific file when --section is provided', async () => { process.env['EDITOR'] = 'code'; const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'edit', '--section', 'soul']); expect(spawnSyncMock).toHaveBeenCalledWith( 'code', ['/home/user/.config/mosaic/SOUL.md'], expect.any(Object), ); delete process.env['EDITOR']; }); }); // ── config hooks ───────────────────────────────────────────────────────────── const MOCK_HOOKS_CONFIG = JSON.stringify({ name: 'Test Hooks', hooks: { PostToolUse: [ { matcher: 'Write|Edit', hooks: [{ type: 'command', command: 'bash', args: ['-c', 'echo'] }], }, ], }, }); const MOCK_HOOKS_WITH_DISABLED = JSON.stringify({ name: 'Test Hooks', hooks: { PostToolUse: [{ matcher: 'Write|Edit', hooks: [] }], _disabled_PreToolUse: [{ matcher: 'Bash', hooks: [] }], }, }); vi.mock('node:fs', () => ({ existsSync: vi.fn(), readFileSync: vi.fn(), writeFileSync: vi.fn(), })); async function getFsMock() { const fs = await import('node:fs'); return { existsSync: fs.existsSync as ReturnType, readFileSync: fs.readFileSync as ReturnType, writeFileSync: fs.writeFileSync as ReturnType, }; } describe('config hooks list', () => { let consoleSpy: ReturnType; beforeEach(async () => { consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.clearAllMocks(); mockSvc.isInitialized.mockReturnValue(true); const fs = await getFsMock(); fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(MOCK_HOOKS_CONFIG); // Ensure CLAUDE_HOME is set to a stable value for tests process.env['CLAUDE_HOME'] = '/tmp/claude-test'; }); afterEach(() => { consoleSpy.mockRestore(); delete process.env['CLAUDE_HOME']; }); it('lists hooks with enabled/disabled status', async () => { const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']); const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(output).toContain('PostToolUse'); expect(output).toContain('enabled'); }); it('shows disabled hooks from MOCK_HOOKS_WITH_DISABLED', async () => { const fs = await getFsMock(); fs.readFileSync.mockReturnValue(MOCK_HOOKS_WITH_DISABLED); const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']); const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(output).toContain('disabled'); expect(output).toContain('PreToolUse'); }); it('prints a message when hooks-config.json is missing', async () => { const fs = await getFsMock(); fs.existsSync.mockReturnValue(false); const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']); const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); expect(output).toContain('No hooks-config.json'); }); }); describe('config hooks disable / enable', () => { let consoleSpy: ReturnType; beforeEach(async () => { consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); vi.clearAllMocks(); mockSvc.isInitialized.mockReturnValue(true); const fs = await getFsMock(); fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(MOCK_HOOKS_CONFIG); process.env['CLAUDE_HOME'] = '/tmp/claude-test'; }); afterEach(() => { consoleSpy.mockRestore(); delete process.env['CLAUDE_HOME']; }); it('disables a hook by event name and writes updated config', async () => { const fs = await getFsMock(); const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'disable', 'PostToolUse']); expect(fs.writeFileSync).toHaveBeenCalled(); const written = JSON.parse((fs.writeFileSync.mock.calls[0] as [string, string])[1]) as { hooks: Record; }; expect(written.hooks['_disabled_PostToolUse']).toBeDefined(); expect(written.hooks['PostToolUse']).toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('disabled')); }); it('enables a disabled hook and writes updated config', async () => { const fs = await getFsMock(); fs.readFileSync.mockReturnValue(MOCK_HOOKS_WITH_DISABLED); const program = buildProgram(); await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'enable', 'PreToolUse']); expect(fs.writeFileSync).toHaveBeenCalled(); const written = JSON.parse((fs.writeFileSync.mock.calls[0] as [string, string])[1]) as { hooks: Record; }; expect(written.hooks['PreToolUse']).toBeDefined(); expect(written.hooks['_disabled_PreToolUse']).toBeUndefined(); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('enabled')); }); }); // ── not-initialized guard ──────────────────────────────────────────────────── describe('not-initialized guard', () => { let consoleErrorSpy: ReturnType; beforeEach(() => { consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); vi.clearAllMocks(); mockSvc.isInitialized.mockReturnValue(false); }); afterEach(() => { consoleErrorSpy.mockRestore(); mockSvc.isInitialized.mockReturnValue(true); }); it('prints a helpful message when config is missing (show)', async () => { const program = buildProgram(); // process.exit is intercepted; catch the resulting error from exitOverride await expect(program.parseAsync(['node', 'mosaic', 'config', 'show'])).rejects.toThrow(); expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('mosaic wizard')); }); });