diff --git a/desktop/tests/store/handStore.test.ts b/desktop/tests/store/handStore.test.ts new file mode 100644 index 0000000..b2fe499 --- /dev/null +++ b/desktop/tests/store/handStore.test.ts @@ -0,0 +1,453 @@ +/** + * Hand Store Tests + * + * Tests for Hands, Triggers, and Approval management. + * Uses a mock HandClient injected via setHandStoreClient. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + useHandStore, + type HandClient, + type TriggerCreateOptions, + type RawHandRun, + type RawApproval, +} from '../../src/store/handStore'; + +// ── Mock autonomy-manager (vi.hoisted to survive clearAllMocks) ── +const { mockCanAutoExecute, mockGetAutonomyManager } = vi.hoisted(() => ({ + mockCanAutoExecute: vi.fn(() => ({ canProceed: true, decision: null })), + mockGetAutonomyManager: vi.fn(() => ({ + getConfig: () => ({ level: 'supervised' }), + })), +})); + +vi.mock('../../src/lib/autonomy-manager', () => ({ + canAutoExecute: mockCanAutoExecute, + getAutonomyManager: mockGetAutonomyManager, +})); + +// ── Mock saas-client ── +const { mockSaasClient } = vi.hoisted(() => ({ + mockSaasClient: { + isAuthenticated: vi.fn(() => false), + reportUsageFireAndForget: vi.fn(), + }, +})); + +vi.mock('../../src/lib/saas-client', () => ({ + saasClient: mockSaasClient, +})); + +// ── Mock GatewayClient ── +vi.mock('../../src/lib/gateway-client', () => ({ + getGatewayClient: vi.fn(), +})); + +// ── Mock KernelClient ── +vi.mock('../../src/lib/kernel-client', () => ({})); + +// ── Mock client ── +let mockClient: HandClient; + +function createMockClient(): HandClient { + return { + listHands: vi.fn(async () => ({ hands: [] })), + getHand: vi.fn(async () => null), + listHandRuns: vi.fn(async () => ({ runs: [] })), + triggerHand: vi.fn(async () => null), + approveHand: vi.fn(async () => ({ status: 'approved' })), + cancelHand: vi.fn(async () => ({ status: 'cancelled' })), + listTriggers: vi.fn(async () => ({ triggers: [] })), + getTrigger: vi.fn(async () => null), + createTrigger: vi.fn(async () => null), + updateTrigger: vi.fn(async () => ({ id: 'trig-1' })), + deleteTrigger: vi.fn(async () => ({ status: 'deleted' })), + listApprovals: vi.fn(async () => ({ approvals: [] })), + respondToApproval: vi.fn(async () => ({ status: 'approved' })), + }; +} + +beforeEach(() => { + mockClient = createMockClient(); + useHandStore.setState({ + hands: [], + handRuns: {}, + triggers: [], + approvals: [], + isLoading: false, + error: null, + autonomyDecision: null, + client: mockClient, + }); + vi.clearAllMocks(); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Initial State +// ═══════════════════════════════════════════════════════════════════ + +describe('handStore — initial state', () => { + it('should start with empty hands', () => { + expect(useHandStore.getState().hands).toEqual([]); + }); + + it('should not be loading', () => { + expect(useHandStore.getState().isLoading).toBe(false); + }); + + it('should have no error', () => { + expect(useHandStore.getState().error).toBeNull(); + }); + + it('should have no autonomy decision', () => { + expect(useHandStore.getState().autonomyDecision).toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// loadHands +// ═══════════════════════════════════════════════════════════════════ + +describe('handStore — loadHands', () => { + it('should load and normalize hands', async () => { + (mockClient.listHands as ReturnType).mockResolvedValue({ + hands: [ + { id: 'h1', name: 'browser', description: 'Web automation', status: 'idle', requirements_met: true, category: 'automation', icon: '🌐', tool_count: 5, metric_count: 2 }, + { id: 'h2', name: 'researcher', description: 'Deep research', status: 'running', requirements_met: true }, + { id: 'h3', name: 'speech', description: 'TTS', requirements_met: false }, + ], + }); + + await useHandStore.getState().loadHands(); + + const state = useHandStore.getState(); + expect(state.hands).toHaveLength(3); + expect(state.hands[0].name).toBe('browser'); + expect(state.hands[0].status).toBe('idle'); + expect(state.hands[0].toolCount).toBe(5); + // Invalid status should fallback to setup_needed + expect(state.hands[2].status).toBe('setup_needed'); + expect(state.isLoading).toBe(false); + }); + + it('should handle client error', async () => { + (mockClient.listHands as ReturnType).mockRejectedValue(new Error('Network fail')); + + await useHandStore.getState().loadHands(); + + expect(useHandStore.getState().hands).toEqual([]); + expect(useHandStore.getState().isLoading).toBe(false); + }); + + it('should return early if no client', async () => { + useHandStore.setState({ client: null }); + + await useHandStore.getState().loadHands(); + + expect(useHandStore.getState().isLoading).toBe(false); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// getHandDetails +// ═══════════════════════════════════════════════════════════════════ + +describe('handStore — getHandDetails', () => { + it('should fetch and return hand details', async () => { + useHandStore.setState({ + hands: [{ id: 'h1', name: 'browser', description: '', status: 'idle' as const, requirements_met: true }], + }); + + (mockClient.getHand as ReturnType).mockResolvedValue({ + name: 'browser', + description: 'Web automation', + status: 'idle', + requirements_met: true, + requirements: [ + { description: 'Chrome installed', met: true, hint: 'Install Chrome' }, + { name: 'Network', satisfied: false }, + ], + provider: 'openai', + model: 'gpt-4', + tools: ['navigate', 'click', 'type'], + metrics: ['pages_visited', 'actions_taken'], + tool_count: 3, + metric_count: 2, + }); + + const hand = await useHandStore.getState().getHandDetails('browser'); + + expect(hand).toBeDefined(); + expect(hand!.name).toBe('browser'); + expect(hand!.provider).toBe('openai'); + expect(hand!.requirements).toHaveLength(2); + expect(hand!.requirements![0].met).toBe(true); + expect(hand!.requirements![1].met).toBe(false); + expect(hand!.tools).toEqual(['navigate', 'click', 'type']); + expect(hand!.toolCount).toBe(3); + }); + + it('should return undefined for null result', async () => { + (mockClient.getHand as ReturnType).mockResolvedValue(null); + + const result = await useHandStore.getState().getHandDetails('nonexistent'); + expect(result).toBeUndefined(); + }); + + it('should return undefined if no client', async () => { + useHandStore.setState({ client: null }); + + const result = await useHandStore.getState().getHandDetails('browser'); + expect(result).toBeUndefined(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// triggerHand +// ═══════════════════════════════════════════════════════════════════ + +describe('handStore — triggerHand', () => { + it('should trigger hand and add run', async () => { + (mockClient.triggerHand as ReturnType).mockResolvedValue({ + runId: 'run-123', + status: 'running', + }); + (mockClient.listHands as ReturnType).mockResolvedValue({ hands: [] }); + + const run = await useHandStore.getState().triggerHand('browser', { url: 'https://example.com' }); + + expect(run).toBeDefined(); + expect(run!.runId).toBe('run-123'); + expect(run!.status).toBe('running'); + + // Run should be added to handRuns + expect(useHandStore.getState().handRuns['browser']).toHaveLength(1); + }); + + it('should block when autonomy denies', async () => { + const { canAutoExecute } = await import('../../src/lib/autonomy-manager'); + (canAutoExecute as ReturnType).mockReturnValue({ + canProceed: false, + decision: { reason: 'Rate limited', action: 'hand_trigger' }, + }); + + const run = await useHandStore.getState().triggerHand('browser'); + + expect(run).toBeUndefined(); + expect(useHandStore.getState().autonomyDecision).not.toBeNull(); + }); + + it('should handle trigger error gracefully', async () => { + (mockClient.triggerHand as ReturnType).mockRejectedValue(new Error('Trigger failed')); + + const run = await useHandStore.getState().triggerHand('browser'); + + expect(run).toBeUndefined(); + // Either error is set (client threw) or autonomy decision is set (blocked) + const state = useHandStore.getState(); + expect(state.error || state.autonomyDecision).toBeTruthy(); + }); + + it('should return undefined if no client', async () => { + useHandStore.setState({ client: null }); + + const run = await useHandStore.getState().triggerHand('browser'); + expect(run).toBeUndefined(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// loadHandRuns +// ═══════════════════════════════════════════════════════════════════ + +describe('handStore — loadHandRuns', () => { + it('should load and normalize runs', async () => { + (mockClient.listHandRuns as ReturnType).mockResolvedValue({ + runs: [ + { runId: 'r1', status: 'completed', startedAt: '2026-01-01', completedAt: '2026-01-01', result: { url: 'test' } }, + { run_id: 'r2', status: 'failed', created_at: '2026-01-02', error: 'Timeout' }, + ] as RawHandRun[], + }); + + const runs = await useHandStore.getState().loadHandRuns('browser'); + + expect(runs).toHaveLength(2); + expect(runs[0].runId).toBe('r1'); + expect(runs[0].result).toEqual({ url: 'test' }); + expect(runs[1].runId).toBe('r2'); + expect(runs[1].error).toBe('Timeout'); + + // Stored in handRuns map + expect(useHandStore.getState().handRuns['browser']).toHaveLength(2); + }); + + it('should return empty array on error', async () => { + (mockClient.listHandRuns as ReturnType).mockRejectedValue(new Error('fail')); + + const runs = await useHandStore.getState().loadHandRuns('browser'); + expect(runs).toEqual([]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// approveHand / cancelHand +// ═══════════════════════════════════════════════════════════════════ + +describe('handStore — approveHand / cancelHand', () => { + it('should approve hand and reload', async () => { + (mockClient.listHands as ReturnType).mockResolvedValue({ hands: [] }); + + await useHandStore.getState().approveHand('browser', 'run-1', true, 'Looks safe'); + + expect(mockClient.approveHand).toHaveBeenCalledWith('browser', 'run-1', true, 'Looks safe'); + }); + + it('should cancel hand and reload', async () => { + (mockClient.listHands as ReturnType).mockResolvedValue({ hands: [] }); + + await useHandStore.getState().cancelHand('browser', 'run-1'); + + expect(mockClient.cancelHand).toHaveBeenCalledWith('browser', 'run-1'); + }); + + it('should handle approve error', async () => { + (mockClient.approveHand as ReturnType).mockRejectedValue(new Error('Approve fail')); + + await expect( + useHandStore.getState().approveHand('browser', 'run-1', true) + ).rejects.toThrow('Approve fail'); + }); + + it('should do nothing if no client', async () => { + useHandStore.setState({ client: null }); + + // Should not throw + await useHandStore.getState().approveHand('browser', 'run-1', true); + await useHandStore.getState().cancelHand('browser', 'run-1'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Triggers +// ═══════════════════════════════════════════════════════════════════ + +describe('handStore — triggers', () => { + it('should load triggers', async () => { + (mockClient.listTriggers as ReturnType).mockResolvedValue({ + triggers: [ + { id: 't1', type: 'schedule', enabled: true }, + { id: 't2', type: 'event', enabled: false }, + ], + }); + + await useHandStore.getState().loadTriggers(); + + expect(useHandStore.getState().triggers).toHaveLength(2); + expect(useHandStore.getState().triggers[0].type).toBe('schedule'); + }); + + it('should create trigger', async () => { + (mockClient.createTrigger as ReturnType).mockResolvedValue({ id: 't-new' }); + (mockClient.listTriggers as ReturnType).mockResolvedValue({ + triggers: [{ id: 't-new', type: 'schedule', enabled: true }], + }); + + const opts: TriggerCreateOptions = { type: 'schedule', enabled: true }; + const trigger = await useHandStore.getState().createTrigger(opts); + + expect(trigger).toBeDefined(); + expect(trigger!.id).toBe('t-new'); + }); + + it('should update trigger locally', async () => { + useHandStore.setState({ + triggers: [{ id: 't1', type: 'schedule', enabled: true }], + }); + + await useHandStore.getState().updateTrigger('t1', { enabled: false }); + + const updated = useHandStore.getState().triggers[0]; + expect(updated.enabled).toBe(false); + }); + + it('should delete trigger', async () => { + useHandStore.setState({ + triggers: [ + { id: 't1', type: 'schedule', enabled: true }, + { id: 't2', type: 'event', enabled: false }, + ], + }); + + await useHandStore.getState().deleteTrigger('t1'); + + expect(useHandStore.getState().triggers).toHaveLength(1); + expect(useHandStore.getState().triggers[0].id).toBe('t2'); + }); + + it('should handle delete trigger error', async () => { + (mockClient.deleteTrigger as ReturnType).mockRejectedValue(new Error('Delete fail')); + + await expect( + useHandStore.getState().deleteTrigger('t1') + ).rejects.toThrow('Delete fail'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Approvals +// ═══════════════════════════════════════════════════════════════════ + +describe('handStore — approvals', () => { + it('should load and normalize approvals', async () => { + (mockClient.listApprovals as ReturnType).mockResolvedValue({ + approvals: [ + { id: 'a1', hand_name: 'browser', status: 'pending', requested_at: '2026-01-01' }, + { approval_id: 'a2', handName: 'researcher', status: 'approved', requestedAt: '2026-01-02' }, + ] as RawApproval[], + }); + + await useHandStore.getState().loadApprovals(); + + const approvals = useHandStore.getState().approvals; + expect(approvals).toHaveLength(2); + expect(approvals[0].id).toBe('a1'); + expect(approvals[0].handName).toBe('browser'); + expect(approvals[1].id).toBe('a2'); + expect(approvals[1].handName).toBe('researcher'); + }); + + it('should respond to approval and reload', async () => { + (mockClient.listApprovals as ReturnType).mockResolvedValue({ approvals: [] }); + + await useHandStore.getState().respondToApproval('a1', true, 'Approved by admin'); + + expect(mockClient.respondToApproval).toHaveBeenCalledWith('a1', true, 'Approved by admin'); + }); + + it('should handle respond error', async () => { + (mockClient.respondToApproval as ReturnType).mockRejectedValue(new Error('Respond fail')); + + await expect( + useHandStore.getState().respondToApproval('a1', true) + ).rejects.toThrow('Respond fail'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Utility +// ═══════════════════════════════════════════════════════════════════ + +describe('handStore — utility actions', () => { + it('clearError should clear error', () => { + useHandStore.setState({ error: 'Test error' }); + useHandStore.getState().clearError(); + expect(useHandStore.getState().error).toBeNull(); + }); + + it('clearAutonomyDecision should clear decision', () => { + useHandStore.setState({ autonomyDecision: { reason: 'test', action: 'hand_trigger' } as any }); + useHandStore.getState().clearAutonomyDecision(); + expect(useHandStore.getState().autonomyDecision).toBeNull(); + }); +}); diff --git a/desktop/tests/store/offlineStore.test.ts b/desktop/tests/store/offlineStore.test.ts new file mode 100644 index 0000000..a90d848 --- /dev/null +++ b/desktop/tests/store/offlineStore.test.ts @@ -0,0 +1,314 @@ +/** + * Offline Store Tests + * + * Tests for offline state management, message queuing, + * reconnection logic, and graceful degradation. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useOfflineStore, type QueuedMessage } from '../../src/store/offlineStore'; +import { localStorageMock } from '../setup'; + +// ── Mock dependencies (vi.hoisted for availability in hoisted vi.mock) ── + +const { mockGetConnectionState, mockGetClient, mockConnect, mockGenerateRandomString, resetIdCounter } = vi.hoisted(() => { + let counter = 0; + return { + mockGetConnectionState: vi.fn(() => 'disconnected'), + mockGetClient: vi.fn(() => ({ chat: vi.fn(async () => {}) })), + mockConnect: vi.fn(async () => {}), + mockGenerateRandomString: (len: number) => String(++counter).padStart(len, '0'), + resetIdCounter: () => { counter = 0; }, + }; +}); + +vi.mock('../../src/store/connectionStore', () => ({ + useConnectionStore: { + getState: () => ({ + connect: mockConnect, + connectionState: 'connected', + }), + subscribe: () => () => {}, + }, + getConnectionState: () => mockGetConnectionState(), + getClient: () => mockGetClient(), +})); + +vi.mock('../../src/lib/crypto-utils', () => ({ + generateRandomString: mockGenerateRandomString, +})); + +vi.mock('../../src/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + resetIdCounter(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + localStorageMock.clear(); + + // Reset store to initial state + useOfflineStore.setState({ + isOffline: false, + isReconnecting: false, + reconnectAttempt: 0, + nextReconnectDelay: 1000, + lastOnlineTime: null, + queuedMessages: [], + maxRetryCount: 5, + maxQueueSize: 100, + }); + + // Default: disconnected so retryAllMessages won't try to send + mockGetConnectionState.mockReturnValue('disconnected'); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Initial State +// ═══════════════════════════════════════════════════════════════════ + +describe('offlineStore — initial state', () => { + it('should start online', () => { + expect(useOfflineStore.getState().isOffline).toBe(false); + }); + + it('should have empty queue', () => { + expect(useOfflineStore.getState().queuedMessages).toEqual([]); + }); + + it('should have no reconnect attempt', () => { + expect(useOfflineStore.getState().reconnectAttempt).toBe(0); + }); + + it('should have default max values', () => { + const state = useOfflineStore.getState(); + expect(state.maxRetryCount).toBe(5); + expect(state.maxQueueSize).toBe(100); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// setOffline +// ═══════════════════════════════════════════════════════════════════ + +describe('offlineStore — setOffline', () => { + it('should set offline state', () => { + useOfflineStore.getState().setOffline(true); + + expect(useOfflineStore.getState().isOffline).toBe(true); + }); + + it('should record lastOnlineTime when going online', () => { + useOfflineStore.getState().setOffline(true); + useOfflineStore.getState().setOffline(false); + + expect(useOfflineStore.getState().lastOnlineTime).toBeGreaterThan(0); + }); + + it('should schedule reconnect when going offline', () => { + useOfflineStore.getState().setOffline(true); + + expect(useOfflineStore.getState().isReconnecting).toBe(true); + expect(useOfflineStore.getState().reconnectAttempt).toBe(1); + }); + + it('should cancel reconnect and reset delay when going online', () => { + useOfflineStore.getState().setOffline(true); + useOfflineStore.getState().setOffline(false); + + const state = useOfflineStore.getState(); + expect(state.reconnectAttempt).toBe(0); + expect(state.nextReconnectDelay).toBe(1000); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// queueMessage +// ═══════════════════════════════════════════════════════════════════ + +describe('offlineStore — queueMessage', () => { + it('should add message to queue', () => { + const id = useOfflineStore.getState().queueMessage('Hello', 'agent-1', 'session-1'); + + expect(id).toBeTruthy(); + const queue = useOfflineStore.getState().queuedMessages; + expect(queue).toHaveLength(1); + expect(queue[0].content).toBe('Hello'); + expect(queue[0].agentId).toBe('agent-1'); + expect(queue[0].sessionKey).toBe('session-1'); + expect(queue[0].status).toBe('pending'); + expect(queue[0].retryCount).toBe(0); + }); + + it('should generate unique IDs', () => { + const id1 = useOfflineStore.getState().queueMessage('msg1'); + const id2 = useOfflineStore.getState().queueMessage('msg2'); + + expect(id1).not.toBe(id2); + }); + + it('should enforce max queue size', () => { + const state = useOfflineStore.getState(); + const maxSize = state.maxQueueSize; + + for (let i = 0; i < maxSize + 5; i++) { + state.queueMessage(`Message ${i}`); + } + + expect(useOfflineStore.getState().queuedMessages.length).toBeLessThanOrEqual(maxSize + 1); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// updateMessageStatus +// ═══════════════════════════════════════════════════════════════════ + +describe('offlineStore — updateMessageStatus', () => { + it('should update message status', () => { + const id = useOfflineStore.getState().queueMessage('Test'); + + useOfflineStore.getState().updateMessageStatus(id, 'sending'); + + const msg = useOfflineStore.getState().queuedMessages.find(m => m.id === id); + expect(msg!.status).toBe('sending'); + }); + + it('should increment retryCount on failed status', () => { + const id = useOfflineStore.getState().queueMessage('Test'); + + useOfflineStore.getState().updateMessageStatus(id, 'failed', 'Network error'); + + const msg = useOfflineStore.getState().queuedMessages.find(m => m.id === id); + expect(msg!.retryCount).toBe(1); + expect(msg!.lastError).toBe('Network error'); + }); + + it('should not affect other messages', () => { + const id1 = useOfflineStore.getState().queueMessage('Msg 1'); + const id2 = useOfflineStore.getState().queueMessage('Msg 2'); + + useOfflineStore.getState().updateMessageStatus(id1, 'sent'); + + const msg2 = useOfflineStore.getState().queuedMessages.find(m => m.id === id2); + expect(msg2!.status).toBe('pending'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// removeMessage +// ═══════════════════════════════════════════════════════════════════ + +describe('offlineStore — removeMessage', () => { + it('should remove message by id', () => { + const id1 = useOfflineStore.getState().queueMessage('Msg 1'); + const id2 = useOfflineStore.getState().queueMessage('Msg 2'); + + useOfflineStore.getState().removeMessage(id1); + + const queue = useOfflineStore.getState().queuedMessages; + expect(queue).toHaveLength(1); + expect(queue[0].id).toBe(id2); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// clearQueue +// ═══════════════════════════════════════════════════════════════════ + +describe('offlineStore — clearQueue', () => { + it('should remove all messages', () => { + useOfflineStore.getState().queueMessage('Msg 1'); + useOfflineStore.getState().queueMessage('Msg 2'); + + useOfflineStore.getState().clearQueue(); + + expect(useOfflineStore.getState().queuedMessages).toEqual([]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// getPendingMessages / hasPendingMessages +// ═══════════════════════════════════════════════════════════════════ + +describe('offlineStore — getters', () => { + it('getPendingMessages should return pending and failed', () => { + const id1 = useOfflineStore.getState().queueMessage('Pending'); + const id2 = useOfflineStore.getState().queueMessage('Will fail'); + const id3 = useOfflineStore.getState().queueMessage('Will send'); + + useOfflineStore.getState().updateMessageStatus(id2, 'failed', 'Error'); + useOfflineStore.getState().updateMessageStatus(id3, 'sent'); + + const pending = useOfflineStore.getState().getPendingMessages(); + expect(pending).toHaveLength(2); + expect(pending.map(m => m.id)).toContain(id1); + expect(pending.map(m => m.id)).toContain(id2); + }); + + it('hasPendingMessages should return true when pending exist', () => { + expect(useOfflineStore.getState().hasPendingMessages()).toBe(false); + + useOfflineStore.getState().queueMessage('Test'); + + expect(useOfflineStore.getState().hasPendingMessages()).toBe(true); + }); + + it('hasPendingMessages should return false when all sent', () => { + const id = useOfflineStore.getState().queueMessage('Test'); + useOfflineStore.getState().updateMessageStatus(id, 'sent'); + + expect(useOfflineStore.getState().hasPendingMessages()).toBe(false); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// reconnect schedule +// ═══════════════════════════════════════════════════════════════════ + +describe('offlineStore — reconnection', () => { + it('should increase delay with backoff', () => { + useOfflineStore.getState().setOffline(true); + + const delay1 = useOfflineStore.getState().nextReconnectDelay; + + // Simulate failed reconnect cycle + useOfflineStore.getState().setReconnecting(false); + useOfflineStore.getState().scheduleReconnect(); + + const delay2 = useOfflineStore.getState().nextReconnectDelay; + expect(delay2).toBeGreaterThan(delay1); + }); + + it('should cap delay at maximum', () => { + useOfflineStore.setState({ nextReconnectDelay: 50000 }); + + useOfflineStore.getState().setOffline(true); + useOfflineStore.getState().setReconnecting(false); + useOfflineStore.getState().scheduleReconnect(); + + expect(useOfflineStore.getState().nextReconnectDelay).toBeLessThanOrEqual(60000); + }); + + it('should cancel reconnect', () => { + useOfflineStore.getState().setOffline(true); + expect(useOfflineStore.getState().isReconnecting).toBe(true); + + useOfflineStore.getState().cancelReconnect(); + expect(useOfflineStore.getState().isReconnecting).toBe(false); + }); + + it('should not schedule reconnect when online', () => { + useOfflineStore.getState().scheduleReconnect(); + expect(useOfflineStore.getState().reconnectAttempt).toBe(0); + }); +}); diff --git a/desktop/tests/store/saasStore.test.ts b/desktop/tests/store/saasStore.test.ts new file mode 100644 index 0000000..73a1f03 --- /dev/null +++ b/desktop/tests/store/saasStore.test.ts @@ -0,0 +1,586 @@ +/** + * SaaS Store Tests + * + * Tests for SaaS login/logout, connection mode, billing, + * TOTP, and session management. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { localStorageMock } from '../setup'; + +// ── Mock saas-client (use vi.hoisted to ensure availability before hoisted vi.mock) ── +const { + mockSetBaseUrl, mockSetToken, mockLogin, mockRegister, mockMe, + mockRestoreFromCookie, mockListModels, mockRegisterDevice, mockDeviceHeartbeat, + mockSetupTotp, mockVerifyTotp, mockDisableTotp, + mockListPlans, mockGetSubscription, mockCreatePayment, mockGetPaymentStatus, + mockFetchAvailableTemplates, mockGetAssignedTemplate, mockAssignTemplate, mockUnassignTemplate, + mockPullConfig, mockSyncConfig, mockComputeConfigDiff, mockIsAuthenticated, +} = vi.hoisted(() => ({ + mockSetBaseUrl: vi.fn(), + mockSetToken: vi.fn(), + mockLogin: vi.fn(), + mockRegister: vi.fn(), + mockMe: vi.fn(), + mockRestoreFromCookie: vi.fn(), + mockListModels: vi.fn(), + mockRegisterDevice: vi.fn(), + mockDeviceHeartbeat: vi.fn(), + mockSetupTotp: vi.fn(), + mockVerifyTotp: vi.fn(), + mockDisableTotp: vi.fn(), + mockListPlans: vi.fn(), + mockGetSubscription: vi.fn(), + mockCreatePayment: vi.fn(), + mockGetPaymentStatus: vi.fn(), + mockFetchAvailableTemplates: vi.fn(), + mockGetAssignedTemplate: vi.fn(), + mockAssignTemplate: vi.fn(), + mockUnassignTemplate: vi.fn(), + mockPullConfig: vi.fn(), + mockSyncConfig: vi.fn(), + mockComputeConfigDiff: vi.fn(), + mockIsAuthenticated: vi.fn(() => false), +})); + +vi.mock('../../src/lib/saas-client', () => ({ + saasClient: { + setBaseUrl: mockSetBaseUrl, + setToken: mockSetToken, + login: mockLogin, + register: mockRegister, + me: mockMe, + restoreFromCookie: mockRestoreFromCookie, + listModels: mockListModels, + registerDevice: mockRegisterDevice, + deviceHeartbeat: mockDeviceHeartbeat, + setupTotp: mockSetupTotp, + verifyTotp: mockVerifyTotp, + disableTotp: mockDisableTotp, + listPlans: mockListPlans, + getSubscription: mockGetSubscription, + createPayment: mockCreatePayment, + getPaymentStatus: mockGetPaymentStatus, + fetchAvailableTemplates: mockFetchAvailableTemplates, + getAssignedTemplate: mockGetAssignedTemplate, + assignTemplate: mockAssignTemplate, + unassignTemplate: mockUnassignTemplate, + pullConfig: mockPullConfig, + syncConfig: mockSyncConfig, + computeConfigDiff: mockComputeConfigDiff, + isAuthenticated: mockIsAuthenticated, + }, + SaaSApiError: class SaaSApiError extends Error { + code: string; + status: number; + constructor(msg: string, code: string, status: number) { + super(msg); + this.code = code; + this.status = status; + } + }, + loadSaaSSession: vi.fn(async () => null), + loadSaaSSessionSync: vi.fn(() => null), + saveSaaSSession: vi.fn(async () => {}), + clearSaaSSession: vi.fn(async () => {}), + saveConnectionMode: vi.fn(), + loadConnectionMode: vi.fn(() => 'tauri'), +})); + +vi.mock('../../src/lib/telemetry-collector', () => ({ + initTelemetryCollector: vi.fn(), + stopTelemetryCollector: vi.fn(), +})); + +vi.mock('../../src/lib/llm-service', () => ({ + startPromptOTASync: vi.fn(), + stopPromptOTASync: vi.fn(), +})); + +vi.mock('../../src/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +// Import after mocks +import { useSaaSStore, type ConnectionMode } from '../../src/store/saasStore'; + +const ACCOUNT = { + id: 'acc-1', + username: 'testuser', + email: 'test@example.com', + role: 'user', + totp_enabled: false, + created_at: '2026-01-01', +}; + +beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + + useSaaSStore.setState({ + isLoggedIn: false, + account: null, + saasUrl: 'http://127.0.0.1:8080', + authToken: null, + connectionMode: 'tauri', + availableModels: [], + isLoading: false, + error: null, + totpRequired: false, + totpSetupData: null, + saasReachable: true, + availableTemplates: [], + assignedTemplate: null, + _consecutiveFailures: 0, + _heartbeatTimer: undefined, + _healthCheckTimer: undefined, + plans: [], + subscription: null, + billingLoading: false, + billingError: null, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Initial State +// ═══════════════════════════════════════════════════════════════════ + +describe('saasStore — initial state', () => { + it('should start not logged in', () => { + expect(useSaaSStore.getState().isLoggedIn).toBe(false); + }); + + it('should have no account', () => { + expect(useSaaSStore.getState().account).toBeNull(); + }); + + it('should default to tauri connection mode', () => { + expect(useSaaSStore.getState().connectionMode).toBe('tauri'); + }); + + it('should have empty models', () => { + expect(useSaaSStore.getState().availableModels).toEqual([]); + }); + + it('should not be loading', () => { + expect(useSaaSStore.getState().isLoading).toBe(false); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// login +// ═══════════════════════════════════════════════════════════════════ + +describe('saasStore — login', () => { + it('should login successfully', async () => { + mockLogin.mockResolvedValue({ + token: 'test-token', + account: ACCOUNT, + }); + mockRegisterDevice.mockResolvedValue({}); + mockListModels.mockResolvedValue([]); + mockFetchAvailableTemplates.mockResolvedValue([]); + mockGetAssignedTemplate.mockResolvedValue(null); + mockListPlans.mockResolvedValue([]); + mockGetSubscription.mockResolvedValue(null); + mockPullConfig.mockResolvedValue({ configs: [], pulled_at: '2026-01-01' }); + + await useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'password'); + + const state = useSaaSStore.getState(); + expect(state.isLoggedIn).toBe(true); + expect(state.account).toEqual(ACCOUNT); + expect(state.connectionMode).toBe('saas'); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + expect(mockSetBaseUrl).toHaveBeenCalledWith('http://localhost:8080'); + }); + + it('should set totpRequired when TOTP error', async () => { + const { SaaSApiError } = await import('../../src/lib/saas-client'); + mockLogin.mockRejectedValue(new SaaSApiError('TOTP required', 'TOTP_ERROR', 400)); + + await useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'password'); + + expect(useSaaSStore.getState().totpRequired).toBe(true); + expect(useSaaSStore.getState().isLoggedIn).toBe(false); + }); + + it('should validate empty username', async () => { + await expect( + useSaaSStore.getState().login('http://localhost:8080', ' ', 'password') + ).rejects.toThrow(); + }); + + it('should validate empty password', async () => { + await expect( + useSaaSStore.getState().login('http://localhost:8080', 'testuser', '') + ).rejects.toThrow(); + }); + + it('should handle login error', async () => { + mockLogin.mockRejectedValue(new Error('Invalid credentials')); + + await expect( + useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'wrong') + ).rejects.toThrow('Invalid credentials'); + + expect(useSaaSStore.getState().isLoggedIn).toBe(false); + expect(useSaaSStore.getState().error).toBeTruthy(); + }); + + it('should detect connection refused errors', async () => { + mockLogin.mockRejectedValue(new Error('Failed to fetch')); + + await expect( + useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'password') + ).rejects.toThrow(); + + expect(useSaaSStore.getState().error).toContain('无法连接'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// loginWithTotp +// ═══════════════════════════════════════════════════════════════════ + +describe('saasStore — loginWithTotp', () => { + it('should login with TOTP code', async () => { + mockLogin.mockResolvedValue({ + token: 'test-token', + account: { ...ACCOUNT, totp_enabled: true }, + }); + mockRegisterDevice.mockResolvedValue({}); + mockListModels.mockResolvedValue([]); + + await useSaaSStore.getState().loginWithTotp('http://localhost:8080', 'testuser', 'password', '123456'); + + expect(useSaaSStore.getState().isLoggedIn).toBe(true); + expect(useSaaSStore.getState().totpRequired).toBe(false); + }); + + it('should handle TOTP login error', async () => { + mockLogin.mockRejectedValue(new Error('Invalid TOTP code')); + + await expect( + useSaaSStore.getState().loginWithTotp('http://localhost:8080', 'testuser', 'password', '000000') + ).rejects.toThrow('Invalid TOTP code'); + + expect(useSaaSStore.getState().isLoggedIn).toBe(false); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// register +// ═══════════════════════════════════════════════════════════════════ + +describe('saasStore — register', () => { + it('should register and auto-login', async () => { + mockRegister.mockResolvedValue({ + token: 'reg-token', + account: ACCOUNT, + }); + mockRegisterDevice.mockResolvedValue({}); + mockListModels.mockResolvedValue([]); + + await useSaaSStore.getState().register('http://localhost:8080', 'newuser', 'new@example.com', 'password'); + + expect(useSaaSStore.getState().isLoggedIn).toBe(true); + expect(useSaaSStore.getState().connectionMode).toBe('saas'); + }); + + it('should validate empty email', async () => { + await expect( + useSaaSStore.getState().register('http://localhost:8080', 'user', '', 'password') + ).rejects.toThrow(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// logout +// ═══════════════════════════════════════════════════════════════════ + +describe('saasStore — logout', () => { + it('should clear all state on logout', async () => { + useSaaSStore.setState({ + isLoggedIn: true, + account: ACCOUNT, + connectionMode: 'saas', + availableModels: [{ id: 'm1', name: 'test', provider: 'p' }] as any, + plans: [{ id: 'p1', name: 'Pro' }] as any, + subscription: { id: 's1', plan_id: 'p1' } as any, + }); + + await useSaaSStore.getState().logout(); + + const state = useSaaSStore.getState(); + expect(state.isLoggedIn).toBe(false); + expect(state.account).toBeNull(); + expect(state.authToken).toBeNull(); + expect(state.connectionMode).toBe('tauri'); + expect(state.availableModels).toEqual([]); + expect(state.plans).toEqual([]); + expect(state.subscription).toBeNull(); + expect(mockSetToken).toHaveBeenCalledWith(null); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// setConnectionMode +// ═══════════════════════════════════════════════════════════════════ + +describe('saasStore — setConnectionMode', () => { + it('should switch to gateway mode', () => { + useSaaSStore.getState().setConnectionMode('gateway'); + expect(useSaaSStore.getState().connectionMode).toBe('gateway'); + }); + + it('should not switch to saas when not logged in', () => { + useSaaSStore.setState({ isLoggedIn: false }); + useSaaSStore.getState().setConnectionMode('saas'); + expect(useSaaSStore.getState().connectionMode).toBe('tauri'); + }); + + it('should allow saas mode when logged in', () => { + useSaaSStore.setState({ isLoggedIn: true, account: ACCOUNT }); + useSaaSStore.getState().setConnectionMode('saas'); + expect(useSaaSStore.getState().connectionMode).toBe('saas'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// fetchAvailableModels +// ═══════════════════════════════════════════════════════════════════ + +describe('saasStore — fetchAvailableModels', () => { + it('should fetch models when logged in', async () => { + useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' }); + mockListModels.mockResolvedValue([ + { id: 'glm-4', name: 'GLM-4', provider: 'zhipu' }, + { id: 'gpt-4', name: 'GPT-4', provider: 'openai' }, + ]); + + await useSaaSStore.getState().fetchAvailableModels(); + + expect(useSaaSStore.getState().availableModels).toHaveLength(2); + expect(useSaaSStore.getState().availableModels[0].id).toBe('glm-4'); + }); + + it('should clear models when not logged in', async () => { + useSaaSStore.setState({ + isLoggedIn: false, + availableModels: [{ id: 'm1', name: 'test', provider: 'p' }] as any, + }); + + await useSaaSStore.getState().fetchAvailableModels(); + + expect(useSaaSStore.getState().availableModels).toEqual([]); + }); + + it('should handle fetch error gracefully', async () => { + useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' }); + mockListModels.mockRejectedValue(new Error('Network error')); + + await useSaaSStore.getState().fetchAvailableModels(); + + expect(useSaaSStore.getState().availableModels).toEqual([]); + // Should not set global error + expect(useSaaSStore.getState().error).toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// TOTP +// ═══════════════════════════════════════════════════════════════════ + +describe('saasStore — TOTP', () => { + it('should setup TOTP', async () => { + const setupData = { secret: 'JBSWY3DPEHPK3PXP', qr_code_url: 'otpauth://...' }; + mockSetupTotp.mockResolvedValue(setupData); + + const result = await useSaaSStore.getState().setupTotp(); + + expect(result).toEqual(setupData); + expect(useSaaSStore.getState().totpSetupData).toEqual(setupData); + expect(useSaaSStore.getState().isLoading).toBe(false); + }); + + it('should verify TOTP', async () => { + useSaaSStore.setState({ + isLoggedIn: true, + saasUrl: 'http://localhost:8080', + }); + mockVerifyTotp.mockResolvedValue({}); + mockMe.mockResolvedValue({ ...ACCOUNT, totp_enabled: true }); + + await useSaaSStore.getState().verifyTotp('123456'); + + expect(useSaaSStore.getState().totpSetupData).toBeNull(); + expect(useSaaSStore.getState().account?.totp_enabled).toBe(true); + }); + + it('should disable TOTP', async () => { + useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' }); + mockDisableTotp.mockResolvedValue({}); + mockMe.mockResolvedValue({ ...ACCOUNT, totp_enabled: false }); + + await useSaaSStore.getState().disableTotp('password123'); + + expect(useSaaSStore.getState().account?.totp_enabled).toBe(false); + }); + + it('should handle TOTP setup error', async () => { + mockSetupTotp.mockRejectedValue(new Error('Setup failed')); + + await expect( + useSaaSStore.getState().setupTotp() + ).rejects.toThrow('Setup failed'); + + expect(useSaaSStore.getState().isLoading).toBe(false); + }); + + it('cancelTotpSetup should clear setup data', () => { + useSaaSStore.setState({ totpSetupData: { secret: 'abc', qr_code_url: 'url' } as any }); + useSaaSStore.getState().cancelTotpSetup(); + expect(useSaaSStore.getState().totpSetupData).toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Billing +// ═══════════════════════════════════════════════════════════════════ + +describe('saasStore — billing', () => { + it('should fetch billing overview', async () => { + useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' }); + mockListPlans.mockResolvedValue([{ id: 'p1', name: 'Pro', price: 99 }]); + mockGetSubscription.mockResolvedValue({ id: 's1', plan_id: 'p1', status: 'active' }); + + await useSaaSStore.getState().fetchBillingOverview(); + + const state = useSaaSStore.getState(); + expect(state.plans).toHaveLength(1); + expect(state.subscription).not.toBeNull(); + expect(state.billingLoading).toBe(false); + }); + + it('should handle billing fetch error', async () => { + useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' }); + mockListPlans.mockRejectedValue(new Error('Billing unavailable')); + + await useSaaSStore.getState().fetchBillingOverview(); + + expect(useSaaSStore.getState().billingLoading).toBe(false); + expect(useSaaSStore.getState().billingError).toBeTruthy(); + }); + + it('should create payment', async () => { + useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' }); + const paymentResult = { payment_id: 'pay-1', status: 'pending', payment_url: 'https://pay.example.com' }; + mockCreatePayment.mockResolvedValue(paymentResult); + + const result = await useSaaSStore.getState().createPayment('plan-1', 'alipay'); + + expect(result).toEqual(paymentResult); + expect(mockCreatePayment).toHaveBeenCalledWith({ + plan_id: 'plan-1', + payment_method: 'alipay', + }); + }); + + it('should poll payment status and refresh on success', async () => { + useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' }); + mockGetPaymentStatus.mockResolvedValue({ status: 'succeeded', payment_id: 'pay-1' }); + mockGetSubscription.mockResolvedValue({ id: 's1', plan_id: 'p1', status: 'active' }); + + const result = await useSaaSStore.getState().pollPaymentStatus('pay-1'); + + expect(result!.status).toBe('succeeded'); + expect(mockGetSubscription).toHaveBeenCalled(); + }); + + it('should handle payment creation error', async () => { + useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' }); + mockCreatePayment.mockRejectedValue(new Error('Payment failed')); + + const result = await useSaaSStore.getState().createPayment('plan-1', 'wechat'); + + expect(result).toBeNull(); + expect(useSaaSStore.getState().billingError).toBeTruthy(); + }); + + it('clearBillingError should clear error', () => { + useSaaSStore.setState({ billingError: 'Test error' }); + useSaaSStore.getState().clearBillingError(); + expect(useSaaSStore.getState().billingError).toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Templates +// ═══════════════════════════════════════════════════════════════════ + +describe('saasStore — templates', () => { + it('should fetch available templates', async () => { + const templates = [ + { id: 't1', name: 'E-commerce', description: 'For shops' }, + ]; + mockFetchAvailableTemplates.mockResolvedValue(templates); + + await useSaaSStore.getState().fetchAvailableTemplates(); + + expect(useSaaSStore.getState().availableTemplates).toEqual(templates); + }); + + it('should handle template fetch error gracefully', async () => { + mockFetchAvailableTemplates.mockRejectedValue(new Error('fail')); + + await useSaaSStore.getState().fetchAvailableTemplates(); + + expect(useSaaSStore.getState().availableTemplates).toEqual([]); + }); + + it('should assign template', async () => { + const template = { id: 't1', name: 'Test', steps: [] }; + mockAssignTemplate.mockResolvedValue(template); + + await useSaaSStore.getState().assignTemplate('t1'); + + expect(useSaaSStore.getState().assignedTemplate).toEqual(template); + }); + + it('should fetch assigned template', async () => { + const template = { id: 't1', name: 'Assigned' }; + mockGetAssignedTemplate.mockResolvedValue(template); + + await useSaaSStore.getState().fetchAssignedTemplate(); + + expect(useSaaSStore.getState().assignedTemplate).toEqual(template); + }); + + it('should unassign template', async () => { + useSaaSStore.setState({ assignedTemplate: { id: 't1', name: 'Test' } as any }); + mockUnassignTemplate.mockResolvedValue({}); + + await useSaaSStore.getState().unassignTemplate(); + + expect(useSaaSStore.getState().assignedTemplate).toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// clearError +// ═══════════════════════════════════════════════════════════════════ + +describe('saasStore — utility', () => { + it('clearError should clear error', () => { + useSaaSStore.setState({ error: 'Test error' }); + useSaaSStore.getState().clearError(); + expect(useSaaSStore.getState().error).toBeNull(); + }); +}); diff --git a/desktop/tests/store/streamStore.test.ts b/desktop/tests/store/streamStore.test.ts new file mode 100644 index 0000000..7368c1d --- /dev/null +++ b/desktop/tests/store/streamStore.test.ts @@ -0,0 +1,319 @@ +/** + * Stream Store Tests + * + * Tests for chat mode management, follow-up suggestions, + * cancel stream, and skill search. + * + * Note: sendMessage and initStreamListener have deep integration + * dependencies (connectionStore, conversationStore, etc.) and are + * tested indirectly through chatStore.test.ts. This file tests + * the standalone actions. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { localStorageMock } from '../setup'; + +// ── Mock all external dependencies ── + +vi.mock('../../src/store/connectionStore', () => ({ + getClient: vi.fn(() => ({ + chatStream: vi.fn(), + chat: vi.fn(), + onAgentStream: vi.fn(() => () => {}), + getState: vi.fn(() => 'disconnected'), + cancelStream: vi.fn(), + })), + useConnectionStore: { + getState: () => ({ + connectionState: 'connected', + connect: vi.fn(), + }), + setState: vi.fn(), + subscribe: () => () => {}, + }, +})); + +vi.mock('../../src/store/chat/conversationStore', () => ({ + useConversationStore: { + getState: () => ({ + currentAgent: { id: '1', name: 'ZCLAW' }, + sessionKey: null, + currentConversationId: null, + agents: [{ id: '1', name: 'ZCLAW' }], + }), + setState: vi.fn(), + subscribe: () => () => {}, + }, + resolveGatewayAgentId: vi.fn(() => 'agent-1'), +})); + +vi.mock('../../src/store/chat/messageStore', () => ({ + useMessageStore: { + getState: () => ({ + totalInputTokens: 0, + totalOutputTokens: 0, + addTokenUsage: vi.fn(), + }), + }, +})); + +vi.mock('../../src/store/chat/artifactStore', () => ({ + useArtifactStore: { + getState: () => ({ + addArtifact: vi.fn(), + }), + }, +})); + +vi.mock('../../src/store/offlineStore', () => ({ + useOfflineStore: { + getState: () => ({ + queueMessage: vi.fn(() => 'queued_123'), + }), + }, + isOffline: vi.fn(() => false), +})); + +vi.mock('../../src/lib/gateway-client', () => ({ + getGatewayClient: vi.fn(), +})); + +vi.mock('../../src/lib/intelligence-client', () => ({ + intelligenceClient: { + compactor: { + checkThreshold: vi.fn(), + compact: vi.fn(), + }, + memory: { + search: vi.fn(), + }, + identity: { + buildPrompt: vi.fn(), + }, + reflection: { + recordConversation: vi.fn(() => Promise.resolve()), + shouldReflect: vi.fn(() => Promise.resolve(false)), + reflect: vi.fn(), + }, + }, +})); + +vi.mock('../../src/lib/memory-extractor', () => ({ + getMemoryExtractor: vi.fn(() => ({ + extractFromConversation: vi.fn(() => Promise.resolve([])), + })), +})); + +vi.mock('../../src/lib/skill-discovery', () => ({ + getSkillDiscovery: vi.fn(() => ({ + searchSkills: vi.fn(() => ({ results: [], totalAvailable: 0 })), + })), +})); + +vi.mock('../../src/lib/speech-synth', () => ({ + speechSynth: { + speak: vi.fn(() => Promise.resolve()), + }, +})); + +vi.mock('../../src/lib/crypto-utils', () => ({ + generateRandomString: (len: number) => 'x'.repeat(len), +})); + +vi.mock('../../src/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +// CHAT_MODES mock - must match the actual component structure +vi.mock('../../src/components/ai', () => ({ + CHAT_MODES: { + thinking: { + config: { thinking_enabled: true, reasoning_effort: 'high', plan_mode: false, subagent_enabled: false }, + }, + normal: { + config: { thinking_enabled: false, reasoning_effort: 'medium', plan_mode: false, subagent_enabled: false }, + }, + agent: { + config: { thinking_enabled: true, reasoning_effort: 'high', plan_mode: true, subagent_enabled: true }, + }, + }, +})); + +import { useStreamStore } from '../../src/store/chat/streamStore'; + +beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + + useStreamStore.setState({ + isStreaming: false, + isLoading: false, + chatMode: 'thinking', + suggestions: [], + activeRunId: null, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Initial State +// ═══════════════════════════════════════════════════════════════════ + +describe('streamStore — initial state', () => { + it('should not be streaming', () => { + expect(useStreamStore.getState().isStreaming).toBe(false); + }); + + it('should have thinking chat mode', () => { + expect(useStreamStore.getState().chatMode).toBe('thinking'); + }); + + it('should have empty suggestions', () => { + expect(useStreamStore.getState().suggestions).toEqual([]); + }); + + it('should have no active run id', () => { + expect(useStreamStore.getState().activeRunId).toBeNull(); + }); + + it('should not be loading', () => { + expect(useStreamStore.getState().isLoading).toBe(false); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Chat Mode +// ═══════════════════════════════════════════════════════════════════ + +describe('streamStore — setChatMode', () => { + it('should switch to normal mode', () => { + useStreamStore.getState().setChatMode('normal'); + expect(useStreamStore.getState().chatMode).toBe('normal'); + }); + + it('should switch to agent mode', () => { + useStreamStore.getState().setChatMode('agent'); + expect(useStreamStore.getState().chatMode).toBe('agent'); + }); + + it('should switch back to thinking mode', () => { + useStreamStore.getState().setChatMode('agent'); + useStreamStore.getState().setChatMode('thinking'); + expect(useStreamStore.getState().chatMode).toBe('thinking'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// getChatModeConfig +// ═══════════════════════════════════════════════════════════════════ + +describe('streamStore — getChatModeConfig', () => { + it('should return thinking config by default', () => { + const config = useStreamStore.getState().getChatModeConfig(); + expect(config.thinking_enabled).toBe(true); + expect(config.reasoning_effort).toBe('high'); + }); + + it('should return normal config', () => { + useStreamStore.getState().setChatMode('normal'); + const config = useStreamStore.getState().getChatModeConfig(); + expect(config.thinking_enabled).toBe(false); + expect(config.reasoning_effort).toBe('medium'); + }); + + it('should return agent config with subagent enabled', () => { + useStreamStore.getState().setChatMode('agent'); + const config = useStreamStore.getState().getChatModeConfig(); + expect(config.subagent_enabled).toBe(true); + expect(config.plan_mode).toBe(true); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Suggestions +// ═══════════════════════════════════════════════════════════════════ + +describe('streamStore — suggestions', () => { + it('should set suggestions', () => { + useStreamStore.getState().setSuggestions(['Suggestion 1', 'Suggestion 2']); + expect(useStreamStore.getState().suggestions).toEqual(['Suggestion 1', 'Suggestion 2']); + }); + + it('should replace previous suggestions', () => { + useStreamStore.getState().setSuggestions(['Old']); + useStreamStore.getState().setSuggestions(['New 1', 'New 2']); + expect(useStreamStore.getState().suggestions).toEqual(['New 1', 'New 2']); + }); + + it('should clear suggestions with empty array', () => { + useStreamStore.getState().setSuggestions(['Something']); + useStreamStore.getState().setSuggestions([]); + expect(useStreamStore.getState().suggestions).toEqual([]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// setIsLoading +// ═══════════════════════════════════════════════════════════════════ + +describe('streamStore — setIsLoading', () => { + it('should set loading state', () => { + useStreamStore.getState().setIsLoading(true); + expect(useStreamStore.getState().isLoading).toBe(true); + }); + + it('should unset loading state', () => { + useStreamStore.getState().setIsLoading(true); + useStreamStore.getState().setIsLoading(false); + expect(useStreamStore.getState().isLoading).toBe(false); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// cancelStream (without chatStore injection) +// ═══════════════════════════════════════════════════════════════════ + +describe('streamStore — cancelStream', () => { + it('should do nothing when not streaming', () => { + useStreamStore.getState().cancelStream(); + // No crash, state unchanged + expect(useStreamStore.getState().isStreaming).toBe(false); + }); + + it('should not crash when no chatStore injected', () => { + useStreamStore.setState({ isStreaming: true, activeRunId: 'run-123' }); + + // Without _chat injected, cancelStream returns early + expect(() => useStreamStore.getState().cancelStream()).not.toThrow(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// searchSkills +// ═══════════════════════════════════════════════════════════════════ + +describe('streamStore — searchSkills', () => { + it('should return search results', () => { + const result = useStreamStore.getState().searchSkills('test query'); + + expect(result).toHaveProperty('results'); + expect(result).toHaveProperty('totalAvailable'); + expect(Array.isArray(result.results)).toBe(true); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// initStreamListener +// ═══════════════════════════════════════════════════════════════════ + +describe('streamStore — initStreamListener', () => { + it('should return unsubscribe function', () => { + const unsubscribe = useStreamStore.getState().initStreamListener(); + expect(typeof unsubscribe).toBe('function'); + unsubscribe(); + }); +}); diff --git a/desktop/tests/store/workflowStore.test.ts b/desktop/tests/store/workflowStore.test.ts new file mode 100644 index 0000000..925bca7 --- /dev/null +++ b/desktop/tests/store/workflowStore.test.ts @@ -0,0 +1,379 @@ +/** + * Workflow Store Tests + * + * Tests for workflow CRUD, trigger, cancel, and run management. + * Uses a mock WorkflowClient injected via setWorkflowStoreClient. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + useWorkflowStore, + type WorkflowClient, + type WorkflowCreateOptions, + type UpdateWorkflowInput, +} from '../../src/store/workflowStore'; + +// ── Mock Tauri ── +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +// ── Mock client ── +let mockClient: WorkflowClient; + +function createMockClient(): WorkflowClient { + return { + listWorkflows: vi.fn(async () => ({ workflows: [] })), + getWorkflow: vi.fn(async () => null), + createWorkflow: vi.fn(async () => null), + updateWorkflow: vi.fn(async () => null), + deleteWorkflow: vi.fn(async () => ({ status: 'deleted' })), + executeWorkflow: vi.fn(async () => null), + cancelWorkflow: vi.fn(async () => ({ status: 'cancelled' })), + listWorkflowRuns: vi.fn(async () => ({ runs: [] })), + }; +} + +beforeEach(() => { + mockClient = createMockClient(); + useWorkflowStore.setState({ + workflows: [], + workflowRuns: {}, + isLoading: false, + error: null, + client: mockClient, + }); + vi.clearAllMocks(); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Initial State +// ═══════════════════════════════════════════════════════════════════ + +describe('workflowStore — initial state', () => { + it('should start with empty workflows', () => { + const state = useWorkflowStore.getState(); + expect(state.workflows).toEqual([]); + }); + + it('should not be loading initially', () => { + expect(useWorkflowStore.getState().isLoading).toBe(false); + }); + + it('should have no error', () => { + expect(useWorkflowStore.getState().error).toBeNull(); + }); + + it('should have empty workflowRuns', () => { + expect(useWorkflowStore.getState().workflowRuns).toEqual({}); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// loadWorkflows +// ═══════════════════════════════════════════════════════════════════ + +describe('workflowStore — loadWorkflows', () => { + it('should load workflows from client', async () => { + (mockClient.listWorkflows as ReturnType).mockResolvedValue({ + workflows: [ + { id: 'wf-1', name: 'Test Workflow', steps: 3, description: 'desc', createdAt: '2026-01-01' }, + { id: 'wf-2', name: 'Another', steps: 1 }, + ], + }); + + await useWorkflowStore.getState().loadWorkflows(); + + const state = useWorkflowStore.getState(); + expect(state.workflows).toHaveLength(2); + expect(state.workflows[0].name).toBe('Test Workflow'); + expect(state.workflows[0].steps).toBe(3); + expect(state.workflows[1].id).toBe('wf-2'); + expect(state.isLoading).toBe(false); + }); + + it('should handle null result', async () => { + (mockClient.listWorkflows as ReturnType).mockResolvedValue(null); + + await useWorkflowStore.getState().loadWorkflows(); + + expect(useWorkflowStore.getState().workflows).toEqual([]); + }); + + it('should handle client error', async () => { + (mockClient.listWorkflows as ReturnType).mockRejectedValue(new Error('Network error')); + + await useWorkflowStore.getState().loadWorkflows(); + + const state = useWorkflowStore.getState(); + expect(state.error).toBe('Network error'); + expect(state.isLoading).toBe(false); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// createWorkflow +// ═══════════════════════════════════════════════════════════════════ + +describe('workflowStore — createWorkflow', () => { + it('should create workflow and add to list', async () => { + const opts: WorkflowCreateOptions = { + name: 'New Pipeline', + description: 'A test pipeline', + steps: [{ handName: 'browser' }], + }; + (mockClient.createWorkflow as ReturnType).mockResolvedValue({ + id: 'wf-new', + name: 'New Pipeline', + }); + + const result = await useWorkflowStore.getState().createWorkflow(opts); + + expect(result).toBeDefined(); + expect(result!.id).toBe('wf-new'); + expect(result!.steps).toBe(1); + expect(useWorkflowStore.getState().workflows).toHaveLength(1); + }); + + it('should return undefined on null result', async () => { + (mockClient.createWorkflow as ReturnType).mockResolvedValue(null); + + const result = await useWorkflowStore.getState().createWorkflow({ + name: 'X', + steps: [], + }); + + expect(result).toBeUndefined(); + }); + + it('should handle creation error', async () => { + (mockClient.createWorkflow as ReturnType).mockRejectedValue(new Error('Create failed')); + + const result = await useWorkflowStore.getState().createWorkflow({ + name: 'X', + steps: [], + }); + + expect(result).toBeUndefined(); + expect(useWorkflowStore.getState().error).toBe('Create failed'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// updateWorkflow +// ═══════════════════════════════════════════════════════════════════ + +describe('workflowStore — updateWorkflow', () => { + it('should update existing workflow', async () => { + useWorkflowStore.setState({ + workflows: [{ id: 'wf-1', name: 'Old', steps: 2 }], + }); + + (mockClient.updateWorkflow as ReturnType).mockResolvedValue({ + id: 'wf-1', + name: 'Updated', + }); + + const updates: UpdateWorkflowInput = { name: 'Updated', steps: [{ handName: 'researcher' }] }; + const result = await useWorkflowStore.getState().updateWorkflow('wf-1', updates); + + expect(result).toBeDefined(); + expect(result!.name).toBe('Updated'); + expect(result!.steps).toBe(1); + }); + + it('should preserve unchanged fields', async () => { + useWorkflowStore.setState({ + workflows: [{ id: 'wf-1', name: 'Old', steps: 5, description: 'desc' }], + }); + + (mockClient.updateWorkflow as ReturnType).mockResolvedValue({ + id: 'wf-1', + name: 'Old', + }); + + await useWorkflowStore.getState().updateWorkflow('wf-1', { description: 'new desc' }); + + const wf = useWorkflowStore.getState().workflows[0]; + expect(wf.name).toBe('Old'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// deleteWorkflow +// ═══════════════════════════════════════════════════════════════════ + +describe('workflowStore — deleteWorkflow', () => { + it('should remove workflow from list', async () => { + useWorkflowStore.setState({ + workflows: [ + { id: 'wf-1', name: 'First', steps: 1 }, + { id: 'wf-2', name: 'Second', steps: 2 }, + ], + workflowRuns: { 'wf-1': [{ runId: 'r1', status: 'completed', startedAt: '' }] }, + }); + + await useWorkflowStore.getState().deleteWorkflow('wf-1'); + + const state = useWorkflowStore.getState(); + expect(state.workflows).toHaveLength(1); + expect(state.workflows[0].id).toBe('wf-2'); + expect(state.workflowRuns).not.toHaveProperty('wf-1'); + }); + + it('should handle delete error', async () => { + (mockClient.deleteWorkflow as ReturnType).mockRejectedValue(new Error('Delete failed')); + + await expect( + useWorkflowStore.getState().deleteWorkflow('wf-1') + ).rejects.toThrow('Delete failed'); + + expect(useWorkflowStore.getState().error).toBe('Delete failed'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// triggerWorkflow +// ═══════════════════════════════════════════════════════════════════ + +describe('workflowStore — triggerWorkflow', () => { + it('should return run result on success', async () => { + (mockClient.executeWorkflow as ReturnType).mockResolvedValue({ + runId: 'run-123', + status: 'running', + }); + + const result = await useWorkflowStore.getState().triggerWorkflow('wf-1', { topic: 'test' }); + + expect(result).toEqual({ runId: 'run-123', status: 'running' }); + }); + + it('should return undefined on null result', async () => { + (mockClient.executeWorkflow as ReturnType).mockResolvedValue(null); + + const result = await useWorkflowStore.getState().triggerWorkflow('wf-1'); + + expect(result).toBeUndefined(); + }); + + it('should handle trigger error', async () => { + (mockClient.executeWorkflow as ReturnType).mockRejectedValue(new Error('Exec failed')); + + const result = await useWorkflowStore.getState().triggerWorkflow('wf-1'); + + expect(result).toBeUndefined(); + expect(useWorkflowStore.getState().error).toBe('Exec failed'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// cancelWorkflow +// ═══════════════════════════════════════════════════════════════════ + +describe('workflowStore — cancelWorkflow', () => { + it('should cancel and reload workflows', async () => { + (mockClient.listWorkflows as ReturnType).mockResolvedValue({ workflows: [] }); + + await useWorkflowStore.getState().cancelWorkflow('wf-1', 'run-1'); + + expect(mockClient.cancelWorkflow).toHaveBeenCalledWith('wf-1', 'run-1'); + }); + + it('should handle cancel error', async () => { + (mockClient.cancelWorkflow as ReturnType).mockRejectedValue(new Error('Cancel failed')); + + await expect( + useWorkflowStore.getState().cancelWorkflow('wf-1', 'run-1') + ).rejects.toThrow('Cancel failed'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// loadWorkflowRuns +// ═══════════════════════════════════════════════════════════════════ + +describe('workflowStore — loadWorkflowRuns', () => { + it('should load and normalize runs', async () => { + (mockClient.listWorkflowRuns as ReturnType).mockResolvedValue({ + runs: [ + { runId: 'r1', status: 'completed', started_at: '2026-01-01', completed_at: '2026-01-01' }, + { run_id: 'r2', status: 'running', current_step: 3 }, + { id: 'r3', status: 'failed', error: 'boom' }, + ], + }); + + const runs = await useWorkflowStore.getState().loadWorkflowRuns('wf-1'); + + expect(runs).toHaveLength(3); + expect(runs[0].runId).toBe('r1'); + expect(runs[0].startedAt).toBe('2026-01-01'); + expect(runs[1].runId).toBe('r2'); + expect(runs[1].step).toBe('3'); + expect(runs[2].runId).toBe('r3'); + expect(runs[2].error).toBe('boom'); + + // Stored in workflowRuns map + expect(useWorkflowStore.getState().workflowRuns['wf-1']).toHaveLength(3); + }); + + it('should return empty array on error', async () => { + (mockClient.listWorkflowRuns as ReturnType).mockRejectedValue(new Error('fail')); + + const runs = await useWorkflowStore.getState().loadWorkflowRuns('wf-1'); + expect(runs).toEqual([]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// getWorkflow (local lookup) +// ═══════════════════════════════════════════════════════════════════ + +describe('workflowStore — getWorkflow', () => { + it('should find workflow by id', () => { + useWorkflowStore.setState({ + workflows: [ + { id: 'wf-1', name: 'Test', steps: 3 }, + { id: 'wf-2', name: 'Other', steps: 1 }, + ], + }); + + const found = useWorkflowStore.getState().getWorkflow('wf-1'); + expect(found).toBeDefined(); + expect(found!.name).toBe('Test'); + }); + + it('should return undefined for unknown id', () => { + useWorkflowStore.setState({ + workflows: [{ id: 'wf-1', name: 'Test', steps: 1 }], + }); + + expect(useWorkflowStore.getState().getWorkflow('unknown')).toBeUndefined(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// clearError / reset +// ═══════════════════════════════════════════════════════════════════ + +describe('workflowStore — utility actions', () => { + it('clearError should clear error', () => { + useWorkflowStore.setState({ error: 'Some error' }); + useWorkflowStore.getState().clearError(); + expect(useWorkflowStore.getState().error).toBeNull(); + }); + + it('reset should restore initial state', () => { + useWorkflowStore.setState({ + workflows: [{ id: 'x', name: 'Y', steps: 1 }], + isLoading: true, + error: 'err', + }); + + useWorkflowStore.getState().reset(); + + const state = useWorkflowStore.getState(); + expect(state.workflows).toEqual([]); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + }); +});