import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ConfigStoreClient, QuickConfig, ChannelInfo, SkillInfo, ScheduledTask, } from '../../../desktop/src/store/configStore'; // Mock client with all config methods const createMockClient = (): ConfigStoreClient => ({ getWorkspaceInfo: vi.fn(), getQuickConfig: vi.fn(), saveQuickConfig: vi.fn(), listSkills: vi.fn(), getSkill: vi.fn(), createSkill: vi.fn(), updateSkill: vi.fn(), deleteSkill: vi.fn(), listChannels: vi.fn(), getChannel: vi.fn(), createChannel: vi.fn(), updateChannel: vi.fn(), deleteChannel: vi.fn(), listScheduledTasks: vi.fn(), createScheduledTask: vi.fn(), listModels: vi.fn(), getFeishuStatus: vi.fn(), }); let mockClient: ConfigStoreClient; function resetMocks() { vi.clearAllMocks(); mockClient = createMockClient(); mockClient.getWorkspaceInfo = vi.fn().mockResolvedValue({ path: '~/.zclaw/workspace', resolvedPath: '/home/user/.zclaw/workspace', exists: true, fileCount: 42, totalSize: 1024000, }); mockClient.getQuickConfig = vi.fn().mockResolvedValue({ quickConfig: { agentName: 'ZCLAW', theme: 'dark', gatewayUrl: 'ws://127.0.0.1:4200/ws', workspaceDir: '~/.zclaw/workspace', }, }); mockClient.saveQuickConfig = vi.fn().mockImplementation((config: QuickConfig) => ({ quickConfig: config, })); mockClient.listSkills = vi.fn().mockResolvedValue({ skills: [ { id: 'builtin:translation', name: 'translation', path: '/skills/translation/SKILL.md', source: 'builtin' }, { id: 'custom:summarize', name: 'summarize', path: '/custom/summarize/SKILL.md', source: 'extra' }, ], extraDirs: ['/custom-skills'], }); mockClient.getSkill = vi.fn().mockImplementation((id: string) => ({ skill: { id, name: id.split(':')[1], path: `/skills/${id}/SKILL.md`, source: 'builtin' }, })); mockClient.createSkill = vi.fn().mockImplementation((skill) => ({ skill: { id: `custom:${skill.name}`, ...skill, source: 'extra' as const, path: `/custom/${skill.name}/SKILL.md` }, })); mockClient.updateSkill = vi.fn().mockImplementation((id: string, updates) => ({ skill: { id, name: updates.name || 'skill', path: `/skills/${id}/SKILL.md`, source: 'builtin' as const, ...updates }, })); mockClient.deleteSkill = vi.fn().mockResolvedValue(undefined); mockClient.listChannels = vi.fn().mockResolvedValue({ channels: [ { id: 'feishu', type: 'feishu', label: 'Feishu', status: 'active', accounts: 2 }, { id: 'slack', type: 'slack', label: 'Slack', status: 'inactive' }, ], }); mockClient.getChannel = vi.fn().mockImplementation((id: string) => ({ channel: { id, type: id, label: id.charAt(0).toUpperCase() + id.slice(1), status: 'active' as const }, })); mockClient.createChannel = vi.fn().mockImplementation((channel) => ({ channel: { id: `channel_${Date.now()}`, ...channel, status: 'active' as const }, })); mockClient.updateChannel = vi.fn().mockImplementation((id: string, updates) => ({ channel: { id, type: 'test', label: 'Test', status: 'active' as const, ...updates }, })); mockClient.deleteChannel = vi.fn().mockResolvedValue(undefined); mockClient.listScheduledTasks = vi.fn().mockResolvedValue({ tasks: [ { id: 'task_1', name: 'Daily Report', schedule: '0 9 * * *', status: 'active' }, { id: 'task_2', name: 'Weekly Backup', schedule: '0 0 * * 0', status: 'paused' }, ], }); mockClient.createScheduledTask = vi.fn().mockImplementation((task) => ({ id: `task_${Date.now()}`, name: task.name, schedule: task.schedule, status: 'active' as const, })); mockClient.listModels = vi.fn().mockResolvedValue({ models: [ { id: 'glm-4', name: 'GLM-4', provider: 'zhipuai' }, { id: 'glm-5', name: 'GLM-5', provider: 'zhipuai' }, { id: 'gpt-4', name: 'GPT-4', provider: 'openai' }, ], }); mockClient.getFeishuStatus = vi.fn().mockResolvedValue({ configured: true, accounts: 2 }); } describe('configStore', () => { beforeEach(async () => { vi.resetModules(); resetMocks(); }); describe('initial state', () => { it('initializes with default empty state', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); const state = useConfigStore.getState(); expect(state.quickConfig).toEqual({}); expect(state.workspaceInfo).toBeNull(); expect(state.channels).toEqual([]); expect(state.scheduledTasks).toEqual([]); expect(state.skillsCatalog).toEqual([]); expect(state.models).toEqual([]); expect(state.modelsLoading).toBe(false); expect(state.modelsError).toBeNull(); expect(state.error).toBeNull(); expect(state.client).toBeNull(); }); }); describe('client injection', () => { it('accepts a client via setConfigStoreClient', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); expect(useConfigStore.getState().client).toBe(mockClient); }); }); describe('loadQuickConfig', () => { it('loads quick config from the client', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().loadQuickConfig(); expect(mockClient.getQuickConfig).toHaveBeenCalledTimes(1); const state = useConfigStore.getState(); expect(state.quickConfig).toMatchObject({ agentName: 'ZCLAW', theme: 'dark', gatewayUrl: 'ws://127.0.0.1:4200/ws', }); }); it('sets empty config when client returns null', async () => { mockClient.getQuickConfig = vi.fn().mockResolvedValue(null); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().loadQuickConfig(); expect(useConfigStore.getState().quickConfig).toEqual({}); }); it('handles errors silently', async () => { mockClient.getQuickConfig = vi.fn().mockRejectedValue(new Error('Config error')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); // Should not throw await expect(useConfigStore.getState().loadQuickConfig()).resolves.toBeUndefined(); expect(useConfigStore.getState().quickConfig).toEqual({}); }); it('does nothing when client is not set', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); await useConfigStore.getState().loadQuickConfig(); expect(mockClient.getQuickConfig).not.toHaveBeenCalled(); }); }); describe('saveQuickConfig', () => { it('merges updates with existing config and saves', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); useConfigStore.setState({ quickConfig: { agentName: 'ZCLAW', theme: 'dark' }, }); await useConfigStore.getState().saveQuickConfig({ theme: 'light', workspaceDir: '/new/path' }); expect(mockClient.saveQuickConfig).toHaveBeenCalledWith({ agentName: 'ZCLAW', theme: 'light', workspaceDir: '/new/path', }); const state = useConfigStore.getState(); expect(state.quickConfig.theme).toBe('light'); expect(state.quickConfig.workspaceDir).toBe('/new/path'); expect(state.quickConfig.agentName).toBe('ZCLAW'); }); it('sets error when save fails', async () => { mockClient.saveQuickConfig = vi.fn().mockRejectedValue(new Error('Save failed')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().saveQuickConfig({ theme: 'light' }); expect(useConfigStore.getState().error).toBe('Save failed'); }); it('does nothing when client is not set', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); await useConfigStore.getState().saveQuickConfig({ theme: 'light' }); expect(mockClient.saveQuickConfig).not.toHaveBeenCalled(); }); }); describe('loadWorkspaceInfo', () => { it('loads workspace info from the client', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().loadWorkspaceInfo(); expect(mockClient.getWorkspaceInfo).toHaveBeenCalledTimes(1); const state = useConfigStore.getState(); expect(state.workspaceInfo).toMatchObject({ path: '~/.zclaw/workspace', resolvedPath: '/home/user/.zclaw/workspace', exists: true, fileCount: 42, totalSize: 1024000, }); }); it('handles errors silently', async () => { mockClient.getWorkspaceInfo = vi.fn().mockRejectedValue(new Error('Workspace error')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await expect(useConfigStore.getState().loadWorkspaceInfo()).resolves.toBeUndefined(); expect(useConfigStore.getState().workspaceInfo).toBeNull(); }); }); describe('loadChannels', () => { it('loads channels from the client', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().loadChannels(); expect(mockClient.listChannels).toHaveBeenCalledTimes(1); const state = useConfigStore.getState(); expect(state.channels).toHaveLength(2); expect(state.channels[0]).toMatchObject({ id: 'feishu', type: 'feishu', label: 'Feishu', status: 'active', accounts: 2, }); }); it('falls back to probing feishu status when listChannels fails', async () => { mockClient.listChannels = vi.fn().mockRejectedValue(new Error('Channels unavailable')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().loadChannels(); expect(mockClient.getFeishuStatus).toHaveBeenCalledTimes(1); const state = useConfigStore.getState(); expect(state.channels).toHaveLength(1); expect(state.channels[0]).toMatchObject({ id: 'feishu', type: 'feishu', status: 'active', }); }); it('handles feishu status errors gracefully', async () => { mockClient.listChannels = vi.fn().mockRejectedValue(new Error('Channels unavailable')); mockClient.getFeishuStatus = vi.fn().mockRejectedValue(new Error('Feishu error')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().loadChannels(); const state = useConfigStore.getState(); expect(state.channels).toHaveLength(1); expect(state.channels[0]).toMatchObject({ id: 'feishu', status: 'inactive', }); }); }); describe('getChannel', () => { it('fetches and returns a single channel', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); const channel = await useConfigStore.getState().getChannel('feishu'); expect(mockClient.getChannel).toHaveBeenCalledWith('feishu'); expect(channel).toMatchObject({ id: 'feishu', status: 'active', }); }); it('updates existing channel in state', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); useConfigStore.setState({ channels: [{ id: 'feishu', type: 'feishu', label: 'Feishu', status: 'inactive' }], }); await useConfigStore.getState().getChannel('feishu'); const state = useConfigStore.getState(); expect(state.channels[0].status).toBe('active'); }); it('sets error on failure', async () => { mockClient.getChannel = vi.fn().mockRejectedValue(new Error('Channel error')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); const result = await useConfigStore.getState().getChannel('unknown'); expect(result).toBeUndefined(); expect(useConfigStore.getState().error).toBe('Channel error'); }); }); describe('createChannel', () => { it('creates a channel and adds it to state', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); const channel = await useConfigStore.getState().createChannel({ type: 'discord', name: 'Discord Bot', config: { webhook: 'https://discord.com/...' }, enabled: true, }); expect(mockClient.createChannel).toHaveBeenCalled(); expect(channel).toBeDefined(); expect(channel?.status).toBe('active'); const state = useConfigStore.getState(); expect(state.channels).toHaveLength(1); }); it('sets error on create failure', async () => { mockClient.createChannel = vi.fn().mockRejectedValue(new Error('Create failed')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); const result = await useConfigStore.getState().createChannel({ type: 'test', name: 'Test', config: {}, }); expect(result).toBeUndefined(); expect(useConfigStore.getState().error).toBe('Create failed'); }); }); describe('updateChannel', () => { it('updates a channel in state', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); useConfigStore.setState({ channels: [{ id: 'feishu', type: 'feishu', label: 'Feishu', status: 'inactive' }], }); const result = await useConfigStore.getState().updateChannel('feishu', { name: 'Updated Feishu', enabled: true, }); expect(mockClient.updateChannel).toHaveBeenCalledWith('feishu', expect.objectContaining({ name: 'Updated Feishu' })); expect(result).toBeDefined(); const state = useConfigStore.getState(); expect(state.channels[0].status).toBe('active'); }); it('sets error on update failure', async () => { mockClient.updateChannel = vi.fn().mockRejectedValue(new Error('Update failed')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); const result = await useConfigStore.getState().updateChannel('unknown', { name: 'Test' }); expect(result).toBeUndefined(); expect(useConfigStore.getState().error).toBe('Update failed'); }); }); describe('deleteChannel', () => { it('deletes a channel from state', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); useConfigStore.setState({ channels: [ { id: 'feishu', type: 'feishu', label: 'Feishu', status: 'active' }, { id: 'slack', type: 'slack', label: 'Slack', status: 'inactive' }, ], }); await useConfigStore.getState().deleteChannel('feishu'); expect(mockClient.deleteChannel).toHaveBeenCalledWith('feishu'); const state = useConfigStore.getState(); expect(state.channels).toHaveLength(1); expect(state.channels.find(c => c.id === 'feishu')).toBeUndefined(); }); it('sets error on delete failure', async () => { mockClient.deleteChannel = vi.fn().mockRejectedValue(new Error('Delete failed')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().deleteChannel('unknown'); expect(useConfigStore.getState().error).toBe('Delete failed'); }); }); describe('loadSkillsCatalog', () => { it('loads skills and extra dirs from the client', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().loadSkillsCatalog(); expect(mockClient.listSkills).toHaveBeenCalledTimes(1); const state = useConfigStore.getState(); expect(state.skillsCatalog).toHaveLength(2); expect(state.skillsCatalog[0]).toMatchObject({ id: 'builtin:translation', name: 'translation', source: 'builtin', }); expect(state.quickConfig.skillsExtraDirs).toEqual(['/custom-skills']); }); it('handles errors silently', async () => { mockClient.listSkills = vi.fn().mockRejectedValue(new Error('Skills error')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await expect(useConfigStore.getState().loadSkillsCatalog()).resolves.toBeUndefined(); expect(useConfigStore.getState().skillsCatalog).toEqual([]); }); }); describe('getSkill', () => { it('fetches a single skill by id', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); const skill = await useConfigStore.getState().getSkill('builtin:translation'); expect(mockClient.getSkill).toHaveBeenCalledWith('builtin:translation'); expect(skill).toMatchObject({ id: 'builtin:translation', name: 'translation', }); }); it('returns undefined on error', async () => { mockClient.getSkill = vi.fn().mockRejectedValue(new Error('Skill not found')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); const result = await useConfigStore.getState().getSkill('unknown'); expect(result).toBeUndefined(); }); }); describe('createSkill', () => { it('creates a skill and adds it to catalog', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); const skill = await useConfigStore.getState().createSkill({ name: 'analyzer', description: 'Analyzes content', triggers: [{ type: 'keyword', pattern: 'analyze' }], actions: [{ type: 'hand', params: { hand: 'analyzer' } }], enabled: true, }); expect(mockClient.createSkill).toHaveBeenCalled(); expect(skill).toBeDefined(); expect(skill?.id).toBe('custom:analyzer'); const state = useConfigStore.getState(); expect(state.skillsCatalog).toHaveLength(1); }); }); describe('updateSkill', () => { it('updates a skill in catalog', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); useConfigStore.setState({ skillsCatalog: [{ id: 'builtin:test', name: 'test', path: '/test', source: 'builtin' }], }); const result = await useConfigStore.getState().updateSkill('builtin:test', { name: 'updated', description: 'Updated skill', }); expect(mockClient.updateSkill).toHaveBeenCalledWith('builtin:test', expect.objectContaining({ name: 'updated' })); expect(result).toBeDefined(); }); }); describe('deleteSkill', () => { it('deletes a skill from catalog', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); useConfigStore.setState({ skillsCatalog: [ { id: 'skill_1', name: 'Skill 1', path: '/s1', source: 'builtin' }, { id: 'skill_2', name: 'Skill 2', path: '/s2', source: 'builtin' }, ], }); await useConfigStore.getState().deleteSkill('skill_1'); expect(mockClient.deleteSkill).toHaveBeenCalledWith('skill_1'); const state = useConfigStore.getState(); expect(state.skillsCatalog).toHaveLength(1); expect(state.skillsCatalog.find(s => s.id === 'skill_1')).toBeUndefined(); }); it('handles errors silently', async () => { mockClient.deleteSkill = vi.fn().mockRejectedValue(new Error('Delete failed')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); useConfigStore.setState({ skillsCatalog: [{ id: 'skill_1', name: 'Skill 1', path: '/s1', source: 'builtin' }], }); // Should not throw await expect(useConfigStore.getState().deleteSkill('skill_1')).resolves.toBeUndefined(); }); }); describe('loadScheduledTasks', () => { it('loads scheduled tasks from the client', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().loadScheduledTasks(); expect(mockClient.listScheduledTasks).toHaveBeenCalledTimes(1); const state = useConfigStore.getState(); expect(state.scheduledTasks).toHaveLength(2); expect(state.scheduledTasks[0]).toMatchObject({ id: 'task_1', name: 'Daily Report', schedule: '0 9 * * *', status: 'active', }); }); it('handles errors silently', async () => { mockClient.listScheduledTasks = vi.fn().mockRejectedValue(new Error('Tasks error')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await expect(useConfigStore.getState().loadScheduledTasks()).resolves.toBeUndefined(); expect(useConfigStore.getState().scheduledTasks).toEqual([]); }); }); describe('createScheduledTask', () => { it('creates a scheduled task and adds to state', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); const task = await useConfigStore.getState().createScheduledTask({ name: 'New Task', schedule: '0 10 * * *', scheduleType: 'cron', target: { type: 'hand', id: 'echo' }, description: 'A new task', enabled: true, }); expect(mockClient.createScheduledTask).toHaveBeenCalled(); expect(task).toBeDefined(); expect(task?.status).toBe('active'); const state = useConfigStore.getState(); expect(state.scheduledTasks).toHaveLength(1); }); it('sets error on create failure', async () => { mockClient.createScheduledTask = vi.fn().mockRejectedValue(new Error('Create task failed')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); const result = await useConfigStore.getState().createScheduledTask({ name: 'Failed Task', schedule: 'invalid', scheduleType: 'cron', }); expect(result).toBeUndefined(); expect(useConfigStore.getState().error).toBe('Create task failed'); }); }); describe('loadModels', () => { it('loads models from the client', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().loadModels(); expect(mockClient.listModels).toHaveBeenCalledTimes(1); const state = useConfigStore.getState(); expect(state.models).toHaveLength(3); expect(state.modelsLoading).toBe(false); expect(state.modelsError).toBeNull(); }); it('sets modelsLoading during load', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); const loadPromise = useConfigStore.getState().loadModels(); await loadPromise; expect(useConfigStore.getState().modelsLoading).toBe(false); }); it('sets modelsError when load fails', async () => { mockClient.listModels = vi.fn().mockRejectedValue(new Error('Models error')); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().loadModels(); const state = useConfigStore.getState(); expect(state.modelsError).toBe('Models error'); expect(state.modelsLoading).toBe(false); expect(state.models).toEqual([]); }); it('handles null result from client', async () => { mockClient.listModels = vi.fn().mockResolvedValue({ models: null }); const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.getState().setConfigStoreClient(mockClient); await useConfigStore.getState().loadModels(); expect(useConfigStore.getState().models).toEqual([]); }); }); describe('clearError', () => { it('clears the error state', async () => { const { useConfigStore } = await import('../../../desktop/src/store/configStore'); useConfigStore.setState({ error: 'Some error' }); useConfigStore.getState().clearError(); expect(useConfigStore.getState().error).toBeNull(); }); }); });