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