diff --git a/web/src/hooks/mcp/__tests__/kagent_crds.test.ts b/web/src/hooks/mcp/__tests__/kagent_crds.test.ts new file mode 100644 index 0000000000..2a7b31cd1b --- /dev/null +++ b/web/src/hooks/mcp/__tests__/kagent_crds.test.ts @@ -0,0 +1,351 @@ +/** + * Tests for kagent_crds.ts + * + * Covers the hooks for fetching Kagent CRD resources (agents, tools, models, memories). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' + +// --------------------------------------------------------------------------- +// Hoisted mocks +// --------------------------------------------------------------------------- + +const { + mockIsAgentUnavailable, + mockReportAgentDataSuccess, + mockUseCache, + mockClusterCacheRef, + mockDeduplicateClustersByServer, +} = vi.hoisted(() => ({ + mockIsAgentUnavailable: vi.fn(() => false), + mockReportAgentDataSuccess: vi.fn(), + mockUseCache: vi.fn(), + mockClusterCacheRef: { clusters: [] }, + mockDeduplicateClustersByServer: vi.fn((clusters) => clusters), +})) + +vi.mock('../../useLocalAgent', () => ({ + isAgentUnavailable: () => mockIsAgentUnavailable(), + reportAgentDataSuccess: () => mockReportAgentDataSuccess(), +})) + +vi.mock('../../../lib/cache', () => ({ + useCache: (...args: unknown[]) => mockUseCache(...args), +})) + +vi.mock('../shared', () => ({ + clusterCacheRef: mockClusterCacheRef, + agentFetch: vi.fn(), +})) + +vi.mock('../dedup', () => ({ + deduplicateClustersByServer: (clusters: unknown[]) => mockDeduplicateClustersByServer(clusters), +})) + +vi.mock('../../../lib/constants/network', () => ({ + LOCAL_AGENT_HTTP_URL: 'http://localhost:8585', +})) + +// --------------------------------------------------------------------------- +// Import under test (after mocks) +// --------------------------------------------------------------------------- + +import { + useKagentCRDAgents, + useKagentCRDTools, + useKagentCRDModels, + useKagentCRDMemories, + type KagentCRDAgent, + type KagentCRDToolServer, + type KagentCRDModelConfig, + type KagentCRDMemory, +} from '../kagent_crds' + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks() + mockIsAgentUnavailable.mockReturnValue(false) + mockClusterCacheRef.clusters = [] + mockDeduplicateClustersByServer.mockImplementation((clusters) => clusters) + + // Default mock implementation for useCache + mockUseCache.mockReturnValue({ + data: [], + isLoading: false, + isRefreshing: false, + isDemoFallback: false, + isFailed: false, + consecutiveFailures: 0, + lastRefresh: null, + refetch: vi.fn(), + }) +}) + +// --------------------------------------------------------------------------- +// useKagentCRDAgents +// --------------------------------------------------------------------------- + +describe('useKagentCRDAgents', () => { + it('calls useCache with correct key for all clusters', () => { + renderHook(() => useKagentCRDAgents()) + + expect(mockUseCache).toHaveBeenCalled() + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagent-crd-agents:all:all') + }) + + it('calls useCache with cluster-specific key', () => { + renderHook(() => useKagentCRDAgents({ cluster: 'prod-east' })) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagent-crd-agents:prod-east:all') + }) + + it('calls useCache with namespace-specific key', () => { + renderHook(() => useKagentCRDAgents({ namespace: 'kagent-system' })) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagent-crd-agents:all:kagent-system') + }) + + it('calls useCache with cluster and namespace key', () => { + renderHook(() => useKagentCRDAgents({ cluster: 'staging', namespace: 'kagent-ops' })) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagent-crd-agents:staging:kagent-ops') + }) + + it('sets category to clusters', () => { + renderHook(() => useKagentCRDAgents()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.category).toBe('clusters') + }) + + it('provides empty array as initial data', () => { + renderHook(() => useKagentCRDAgents()) + + const call = mockUseCache.mock.calls[0][0] + expect(Array.isArray(call.initialData)).toBe(true) + expect(call.initialData).toHaveLength(0) + }) + + it('provides demo data', () => { + renderHook(() => useKagentCRDAgents()) + + const call = mockUseCache.mock.calls[0][0] + expect(Array.isArray(call.demoData)).toBe(true) + expect(call.demoData.length).toBeGreaterThan(0) + }) + + it('enables demo fallback when empty', () => { + renderHook(() => useKagentCRDAgents()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.demoWhenEmpty).toBe(true) + }) + + it('is disabled when agent is unavailable', () => { + mockIsAgentUnavailable.mockReturnValue(true) + renderHook(() => useKagentCRDAgents()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.enabled).toBe(false) + }) + + it('is enabled when agent is available', () => { + mockIsAgentUnavailable.mockReturnValue(false) + renderHook(() => useKagentCRDAgents()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.enabled).toBe(true) + }) + + it('returns data from useCache', () => { + const mockAgents: KagentCRDAgent[] = [ + { name: 'test-agent', namespace: 'default', cluster: 'prod', agentType: 'Declarative', runtime: 'python', status: 'Ready', replicas: 1, readyReplicas: 1, modelConfigRef: 'gpt-4o', toolCount: 2, a2aEnabled: true, systemMessage: 'Test', createdAt: '2025-01-01T00:00:00Z', age: '1d' }, + ] + mockUseCache.mockReturnValue({ + data: mockAgents, + isLoading: false, + isRefreshing: false, + isDemoFallback: false, + isFailed: false, + consecutiveFailures: 0, + lastRefresh: Date.now(), + refetch: vi.fn(), + }) + + const { result } = renderHook(() => useKagentCRDAgents()) + expect(result.current.data).toEqual(mockAgents) + }) +}) + +// --------------------------------------------------------------------------- +// useKagentCRDTools +// --------------------------------------------------------------------------- + +describe('useKagentCRDTools', () => { + it('calls useCache with correct key for all clusters', () => { + renderHook(() => useKagentCRDTools()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagent-crd-tools:all:all') + }) + + it('calls useCache with cluster-specific key', () => { + renderHook(() => useKagentCRDTools({ cluster: 'prod-west' })) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagent-crd-tools:prod-west:all') + }) + + it('provides demo data with tools', () => { + renderHook(() => useKagentCRDTools()) + + const call = mockUseCache.mock.calls[0][0] + expect(Array.isArray(call.demoData)).toBe(true) + expect(call.demoData.length).toBeGreaterThan(0) + // Verify at least one tool has required fields + const tool = call.demoData[0] + expect(typeof tool.name).toBe('string') + expect(typeof tool.namespace).toBe('string') + expect(typeof tool.cluster).toBe('string') + }) + + it('returns data from useCache', () => { + const mockTools: KagentCRDToolServer[] = [ + { name: 'kubectl-server', namespace: 'default', cluster: 'prod', kind: 'ToolServer', protocol: 'stdio', url: '', discoveredTools: [], status: 'Ready' }, + ] + mockUseCache.mockReturnValue({ + data: mockTools, + isLoading: false, + isRefreshing: false, + isDemoFallback: false, + isFailed: false, + consecutiveFailures: 0, + lastRefresh: Date.now(), + refetch: vi.fn(), + }) + + const { result } = renderHook(() => useKagentCRDTools()) + expect(result.current.data).toEqual(mockTools) + }) +}) + +// --------------------------------------------------------------------------- +// useKagentCRDModels +// --------------------------------------------------------------------------- + +describe('useKagentCRDModels', () => { + it('calls useCache with correct key for all clusters', () => { + renderHook(() => useKagentCRDModels()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagent-crd-models:all:all') + }) + + it('calls useCache with namespace-specific key', () => { + renderHook(() => useKagentCRDModels({ namespace: 'kagent-system' })) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagent-crd-models:all:kagent-system') + }) + + it('provides demo data with models', () => { + renderHook(() => useKagentCRDModels()) + + const call = mockUseCache.mock.calls[0][0] + expect(Array.isArray(call.demoData)).toBe(true) + expect(call.demoData.length).toBeGreaterThan(0) + // Verify at least one model has required fields + const model = call.demoData[0] + expect(typeof model.name).toBe('string') + expect(typeof model.provider).toBe('string') + }) + + it('returns data from useCache', () => { + const mockModels: KagentCRDModelConfig[] = [ + { name: 'claude-sonnet', namespace: 'default', cluster: 'prod', kind: 'ModelConfig', provider: 'Anthropic', model: 'claude-sonnet-4', discoveredModels: [], modelCount: 0, lastDiscoveryTime: '', status: 'Ready' }, + ] + mockUseCache.mockReturnValue({ + data: mockModels, + isLoading: false, + isRefreshing: false, + isDemoFallback: false, + isFailed: false, + consecutiveFailures: 0, + lastRefresh: Date.now(), + refetch: vi.fn(), + }) + + const { result } = renderHook(() => useKagentCRDModels()) + expect(result.current.data).toEqual(mockModels) + }) +}) + +// --------------------------------------------------------------------------- +// useKagentCRDMemories +// --------------------------------------------------------------------------- + +describe('useKagentCRDMemories', () => { + it('calls useCache with correct key for all clusters', () => { + renderHook(() => useKagentCRDMemories()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagent-crd-memories:all:all') + }) + + it('calls useCache with cluster and namespace key', () => { + renderHook(() => useKagentCRDMemories({ cluster: 'prod-east', namespace: 'kagent-system' })) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagent-crd-memories:prod-east:kagent-system') + }) + + it('provides demo data with memories', () => { + renderHook(() => useKagentCRDMemories()) + + const call = mockUseCache.mock.calls[0][0] + expect(Array.isArray(call.demoData)).toBe(true) + expect(call.demoData.length).toBeGreaterThan(0) + // Verify at least one memory has required fields + const memory = call.demoData[0] + expect(typeof memory.name).toBe('string') + expect(typeof memory.provider).toBe('string') + }) + + it('returns data from useCache', () => { + const mockMemories: KagentCRDMemory[] = [ + { name: 'test-memory', namespace: 'default', cluster: 'prod', provider: 'pinecone', status: 'Ready' }, + ] + mockUseCache.mockReturnValue({ + data: mockMemories, + isLoading: false, + isRefreshing: false, + isDemoFallback: false, + isFailed: false, + consecutiveFailures: 0, + lastRefresh: Date.now(), + refetch: vi.fn(), + }) + + const { result } = renderHook(() => useKagentCRDMemories()) + expect(result.current.data).toEqual(mockMemories) + }) + + it('sets category to clusters for all hooks', () => { + renderHook(() => useKagentCRDAgents()) + renderHook(() => useKagentCRDTools()) + renderHook(() => useKagentCRDModels()) + renderHook(() => useKagentCRDMemories()) + + for (let i = 0; i < 4; i++) { + const call = mockUseCache.mock.calls[i][0] + expect(call.category).toBe('clusters') + } + }) +}) diff --git a/web/src/hooks/mcp/__tests__/kagenti.test.ts b/web/src/hooks/mcp/__tests__/kagenti.test.ts new file mode 100644 index 0000000000..65ef28953e --- /dev/null +++ b/web/src/hooks/mcp/__tests__/kagenti.test.ts @@ -0,0 +1,528 @@ +/** + * Tests for kagenti.ts + * + * Covers the hooks for fetching Kagenti resources (agents, builds, cards, tools, summary). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' + +// --------------------------------------------------------------------------- +// Hoisted mocks +// --------------------------------------------------------------------------- + +const { + mockIsAgentUnavailable, + mockReportAgentDataSuccess, + mockUseCache, + mockClusterCacheRef, + mockDeduplicateClustersByServer, + mockGetLocalAgentURL, +} = vi.hoisted(() => ({ + mockIsAgentUnavailable: vi.fn(() => false), + mockReportAgentDataSuccess: vi.fn(), + mockUseCache: vi.fn(), + mockClusterCacheRef: { clusters: [] }, + mockDeduplicateClustersByServer: vi.fn((clusters) => clusters), + mockGetLocalAgentURL: vi.fn(() => 'http://localhost:8585'), +})) + +vi.mock('../../useLocalAgent', () => ({ + isAgentUnavailable: () => mockIsAgentUnavailable(), + reportAgentDataSuccess: () => mockReportAgentDataSuccess(), +})) + +vi.mock('../../../lib/cache', () => ({ + useCache: (...args: unknown[]) => mockUseCache(...args), +})) + +vi.mock('../shared', () => ({ + clusterCacheRef: mockClusterCacheRef, + getLocalAgentURL: () => mockGetLocalAgentURL(), + agentFetch: vi.fn(), +})) + +vi.mock('../dedup', () => ({ + deduplicateClustersByServer: (clusters: unknown[]) => mockDeduplicateClustersByServer(clusters), +})) + +// --------------------------------------------------------------------------- +// Import under test (after mocks) +// --------------------------------------------------------------------------- + +import { + useKagentiAgents, + useKagentiBuilds, + useKagentiCards, + useKagentiTools, + useKagentiSummary, + type KagentiAgent, + type KagentiBuild, + type KagentiCard, + type KagentiTool, + type KagentiSummary, +} from '../kagenti' + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks() + mockIsAgentUnavailable.mockReturnValue(false) + mockClusterCacheRef.clusters = [] + mockDeduplicateClustersByServer.mockImplementation((clusters) => clusters) + + // Default mock implementation for useCache + mockUseCache.mockReturnValue({ + data: [], + isLoading: false, + isRefreshing: false, + isDemoFallback: false, + isFailed: false, + consecutiveFailures: 0, + lastRefresh: null, + refetch: vi.fn(), + error: null, + }) +}) + +// --------------------------------------------------------------------------- +// useKagentiAgents +// --------------------------------------------------------------------------- + +describe('useKagentiAgents', () => { + it('calls useCache with correct key for all clusters', () => { + renderHook(() => useKagentiAgents()) + + expect(mockUseCache).toHaveBeenCalled() + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagenti-agents:all:all') + }) + + it('calls useCache with cluster-specific key', () => { + renderHook(() => useKagentiAgents({ cluster: 'prod-east' })) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagenti-agents:prod-east:all') + }) + + it('calls useCache with namespace-specific key', () => { + renderHook(() => useKagentiAgents({ namespace: 'kagenti-system' })) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagenti-agents:all:kagenti-system') + }) + + it('calls useCache with cluster and namespace key', () => { + renderHook(() => useKagentiAgents({ cluster: 'staging', namespace: 'kagenti-ops' })) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagenti-agents:staging:kagenti-ops') + }) + + it('sets category to clusters', () => { + renderHook(() => useKagentiAgents()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.category).toBe('clusters') + }) + + it('provides empty array as initial data', () => { + renderHook(() => useKagentiAgents()) + + const call = mockUseCache.mock.calls[0][0] + expect(Array.isArray(call.initialData)).toBe(true) + expect(call.initialData).toHaveLength(0) + }) + + it('provides demo data', () => { + renderHook(() => useKagentiAgents()) + + const call = mockUseCache.mock.calls[0][0] + expect(Array.isArray(call.demoData)).toBe(true) + expect(call.demoData.length).toBeGreaterThan(0) + }) + + it('enables demo fallback when empty', () => { + renderHook(() => useKagentiAgents()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.demoWhenEmpty).toBe(true) + }) + + it('is disabled when agent is unavailable', () => { + mockIsAgentUnavailable.mockReturnValue(true) + renderHook(() => useKagentiAgents()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.enabled).toBe(false) + }) + + it('returns data from useCache', () => { + const mockAgents: KagentiAgent[] = [ + { name: 'test-agent', namespace: 'default', status: 'Running', replicas: 1, readyReplicas: 1, framework: 'langgraph', protocol: 'a2a', image: 'test:latest', cluster: 'prod', createdAt: '2025-01-01T00:00:00Z' }, + ] + mockUseCache.mockReturnValue({ + data: mockAgents, + isLoading: false, + isRefreshing: false, + isDemoFallback: false, + isFailed: false, + consecutiveFailures: 0, + lastRefresh: Date.now(), + refetch: vi.fn(), + error: null, + }) + + const { result } = renderHook(() => useKagentiAgents()) + expect(result.current.data).toEqual(mockAgents) + }) +}) + +// --------------------------------------------------------------------------- +// useKagentiBuilds +// --------------------------------------------------------------------------- + +describe('useKagentiBuilds', () => { + it('calls useCache with correct key for all clusters', () => { + renderHook(() => useKagentiBuilds()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagenti-builds:all:all') + }) + + it('calls useCache with cluster-specific key', () => { + renderHook(() => useKagentiBuilds({ cluster: 'prod-west' })) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagenti-builds:prod-west:all') + }) + + it('provides demo data with builds', () => { + renderHook(() => useKagentiBuilds()) + + const call = mockUseCache.mock.calls[0][0] + expect(Array.isArray(call.demoData)).toBe(true) + expect(call.demoData.length).toBeGreaterThan(0) + // Verify at least one build has required fields + const build = call.demoData[0] + expect(typeof build.name).toBe('string') + expect(typeof build.status).toBe('string') + }) + + it('returns data from useCache', () => { + const mockBuilds: KagentiBuild[] = [ + { name: 'test-build-1', namespace: 'default', status: 'Succeeded', source: 'github.com/org/repo', pipeline: 'kaniko', mode: 'dockerfile', cluster: 'prod', startTime: '2025-01-01T00:00:00Z', completionTime: '2025-01-01T00:05:00Z' }, + ] + mockUseCache.mockReturnValue({ + data: mockBuilds, + isLoading: false, + isRefreshing: false, + isDemoFallback: false, + isFailed: false, + consecutiveFailures: 0, + lastRefresh: Date.now(), + refetch: vi.fn(), + error: null, + }) + + const { result } = renderHook(() => useKagentiBuilds()) + expect(result.current.data).toEqual(mockBuilds) + }) +}) + +// --------------------------------------------------------------------------- +// useKagentiCards +// --------------------------------------------------------------------------- + +describe('useKagentiCards', () => { + it('calls useCache with correct key for all clusters', () => { + renderHook(() => useKagentiCards()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagenti-cards:all:all') + }) + + it('calls useCache with namespace-specific key', () => { + renderHook(() => useKagentiCards({ namespace: 'kagenti-system' })) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagenti-cards:all:kagenti-system') + }) + + it('provides demo data with cards', () => { + renderHook(() => useKagentiCards()) + + const call = mockUseCache.mock.calls[0][0] + expect(Array.isArray(call.demoData)).toBe(true) + expect(call.demoData.length).toBeGreaterThan(0) + // Verify at least one card has required fields + const card = call.demoData[0] + expect(typeof card.name).toBe('string') + expect(typeof card.agentName).toBe('string') + }) + + it('returns data from useCache', () => { + const mockCards: KagentiCard[] = [ + { name: 'test-card', namespace: 'default', agentName: 'test-agent', skills: ['skill1'], capabilities: ['streaming'], syncPeriod: '30s', identityBinding: 'strict', cluster: 'prod' }, + ] + mockUseCache.mockReturnValue({ + data: mockCards, + isLoading: false, + isRefreshing: false, + isDemoFallback: false, + isFailed: false, + consecutiveFailures: 0, + lastRefresh: Date.now(), + refetch: vi.fn(), + error: null, + }) + + const { result } = renderHook(() => useKagentiCards()) + expect(result.current.data).toEqual(mockCards) + }) +}) + +// --------------------------------------------------------------------------- +// useKagentiTools +// --------------------------------------------------------------------------- + +describe('useKagentiTools', () => { + it('calls useCache with correct key for all clusters', () => { + renderHook(() => useKagentiTools()) + + const call = mockUseCache.mock.calls[0][0] + expect(call.key).toBe('kagenti-tools:all:all') + }) + + it('provides demo data with tools', () => { + renderHook(() => useKagentiTools()) + + const call = mockUseCache.mock.calls[0][0] + expect(Array.isArray(call.demoData)).toBe(true) + expect(call.demoData.length).toBeGreaterThan(0) + // Verify at least one tool has required fields + const tool = call.demoData[0] + expect(typeof tool.name).toBe('string') + expect(typeof tool.toolPrefix).toBe('string') + }) + + it('returns data from useCache', () => { + const mockTools: KagentiTool[] = [ + { name: 'kubectl-tool', namespace: 'default', toolPrefix: 'kubectl', targetRef: 'kubectl-gateway', hasCredential: true, cluster: 'prod' }, + ] + mockUseCache.mockReturnValue({ + data: mockTools, + isLoading: false, + isRefreshing: false, + isDemoFallback: false, + isFailed: false, + consecutiveFailures: 0, + lastRefresh: Date.now(), + refetch: vi.fn(), + error: null, + }) + + const { result } = renderHook(() => useKagentiTools()) + expect(result.current.data).toEqual(mockTools) + }) + + it('sets category to clusters for all hooks', () => { + renderHook(() => useKagentiAgents()) + renderHook(() => useKagentiBuilds()) + renderHook(() => useKagentiCards()) + renderHook(() => useKagentiTools()) + + for (let i = 0; i < 4; i++) { + const call = mockUseCache.mock.calls[i][0] + expect(call.category).toBe('clusters') + } + }) +}) + +// --------------------------------------------------------------------------- +// useKagentiSummary +// --------------------------------------------------------------------------- + +describe('useKagentiSummary', () => { + beforeEach(() => { + // Mock all sub-hooks to return specific data for summary tests + let callCount = 0 + mockUseCache.mockImplementation(() => { + callCount++ + if (callCount === 1) { // agents + return { + data: [ + { name: 'agent1', status: 'Running', readyReplicas: 1, framework: 'langgraph', cluster: 'prod' }, + { name: 'agent2', status: 'Running', readyReplicas: 1, framework: 'crewai', cluster: 'prod' }, + { name: 'agent3', status: 'Pending', readyReplicas: 0, framework: 'langgraph', cluster: 'staging' }, + ], + isLoading: false, + isDemoFallback: false, + error: null, + refetch: vi.fn(), + } + } + if (callCount === 2) { // builds + return { + data: [ + { status: 'Succeeded' }, + { status: 'Building' }, + { status: 'Failed' }, + ], + isLoading: false, + isDemoFallback: false, + refetch: vi.fn(), + } + } + if (callCount === 3) { // cards + return { + data: [ + { identityBinding: 'strict' }, + { identityBinding: 'permissive' }, + { identityBinding: 'none' }, + { identityBinding: '' }, // Empty should NOT count as SPIFFE-bound + ], + isLoading: false, + isDemoFallback: false, + refetch: vi.fn(), + } + } + // tools + return { + data: [{ name: 'tool1' }, { name: 'tool2' }], + isLoading: false, + isDemoFallback: false, + refetch: vi.fn(), + } + }) + }) + + it('returns null summary when loading', () => { + mockUseCache.mockReturnValue({ + data: [], + isLoading: true, + isDemoFallback: false, + error: null, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => useKagentiSummary()) + expect(result.current.summary).toBeNull() + }) + + it('aggregates agent count correctly', () => { + const { result } = renderHook(() => useKagentiSummary()) + expect(result.current.summary?.agentCount).toBe(3) + }) + + it('counts ready agents correctly', () => { + const { result } = renderHook(() => useKagentiSummary()) + expect(result.current.summary?.readyAgents).toBe(2) // Only Running with readyReplicas > 0 + }) + + it('aggregates build count correctly', () => { + const { result } = renderHook(() => useKagentiSummary()) + expect(result.current.summary?.buildCount).toBe(3) + }) + + it('counts active builds correctly', () => { + const { result } = renderHook(() => useKagentiSummary()) + expect(result.current.summary?.activeBuilds).toBe(1) // Only Building + }) + + it('aggregates tool count correctly', () => { + const { result } = renderHook(() => useKagentiSummary()) + expect(result.current.summary?.toolCount).toBe(2) + }) + + it('aggregates card count correctly', () => { + const { result } = renderHook(() => useKagentiSummary()) + expect(result.current.summary?.cardCount).toBe(4) + }) + + it('counts SPIFFE-bound cards correctly', () => { + const { result } = renderHook(() => useKagentiSummary()) + // Only strict and permissive count, NOT none or empty string + expect(result.current.summary?.spiffeBound).toBe(2) + expect(result.current.summary?.spiffeTotal).toBe(4) + }) + + it('aggregates frameworks correctly', () => { + const { result } = renderHook(() => useKagentiSummary()) + expect(result.current.summary?.frameworks).toEqual({ + langgraph: 2, + crewai: 1, + }) + }) + + it('aggregates cluster breakdown correctly', () => { + const { result } = renderHook(() => useKagentiSummary()) + expect(result.current.summary?.clusterBreakdown).toEqual([ + { cluster: 'prod', agents: 2 }, + { cluster: 'staging', agents: 1 }, + ]) + }) + + it('returns combined loading state', () => { + let callCount = 0 + mockUseCache.mockImplementation(() => { + callCount++ + return { + data: [], + isLoading: callCount === 1, // First call (agents) is loading + isDemoFallback: false, + error: null, + refetch: vi.fn(), + } + }) + + const { result } = renderHook(() => useKagentiSummary()) + expect(result.current.isLoading).toBe(true) + }) + + it('returns demo data flag when any sub-hook uses demo data', () => { + let callCount = 0 + mockUseCache.mockImplementation(() => { + callCount++ + return { + data: [], + isLoading: false, + isDemoFallback: callCount === 2, // builds using demo data + error: null, + refetch: vi.fn(), + } + }) + + const { result } = renderHook(() => useKagentiSummary()) + expect(result.current.isDemoData).toBe(true) + }) + + it('provides refetch function that calls all sub-hooks', async () => { + const mockRefetch1 = vi.fn() + const mockRefetch2 = vi.fn() + const mockRefetch3 = vi.fn() + const mockRefetch4 = vi.fn() + + let callCount = 0 + mockUseCache.mockImplementation(() => { + callCount++ + const refetches = [mockRefetch1, mockRefetch2, mockRefetch3, mockRefetch4] + return { + data: [], + isLoading: false, + isDemoFallback: false, + error: null, + refetch: refetches[callCount - 1], + } + }) + + const { result } = renderHook(() => useKagentiSummary()) + await result.current.refetch() + + expect(mockRefetch1).toHaveBeenCalled() + expect(mockRefetch2).toHaveBeenCalled() + expect(mockRefetch3).toHaveBeenCalled() + expect(mockRefetch4).toHaveBeenCalled() + }) +}) diff --git a/web/src/hooks/mcp/__tests__/sharedImpl.types.test.ts b/web/src/hooks/mcp/__tests__/sharedImpl.types.test.ts new file mode 100644 index 0000000000..44aff5e943 --- /dev/null +++ b/web/src/hooks/mcp/__tests__/sharedImpl.types.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for sharedImpl.types.ts + * + * Covers the type definitions and utility functions for ClusterCache. + */ +import { describe, it, expect } from 'vitest' + +import { + DATA_FIELDS, + UI_FIELDS, + updatesTouchData, + updatesTouchUI, + type ClusterCache, +} from '../sharedImpl.types' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +describe('DATA_FIELDS', () => { + it('is a non-empty array', () => { + expect(Array.isArray(DATA_FIELDS)).toBe(true) + expect(DATA_FIELDS.length).toBeGreaterThan(0) + }) + + it('includes clusters', () => { + expect(DATA_FIELDS).toContain('clusters') + }) + + it('includes lastUpdated', () => { + expect(DATA_FIELDS).toContain('lastUpdated') + }) + + it('includes consecutiveFailures', () => { + expect(DATA_FIELDS).toContain('consecutiveFailures') + }) + + it('includes isFailed', () => { + expect(DATA_FIELDS).toContain('isFailed') + }) + + it('does NOT include UI fields', () => { + const dataSet = new Set(DATA_FIELDS) + expect(dataSet.has('isLoading' as keyof ClusterCache)).toBe(false) + expect(dataSet.has('isRefreshing' as keyof ClusterCache)).toBe(false) + expect(dataSet.has('error' as keyof ClusterCache)).toBe(false) + expect(dataSet.has('lastRefresh' as keyof ClusterCache)).toBe(false) + }) +}) + +describe('UI_FIELDS', () => { + it('is a non-empty array', () => { + expect(Array.isArray(UI_FIELDS)).toBe(true) + expect(UI_FIELDS.length).toBeGreaterThan(0) + }) + + it('includes isLoading', () => { + expect(UI_FIELDS).toContain('isLoading') + }) + + it('includes isRefreshing', () => { + expect(UI_FIELDS).toContain('isRefreshing') + }) + + it('includes error', () => { + expect(UI_FIELDS).toContain('error') + }) + + it('includes lastRefresh', () => { + expect(UI_FIELDS).toContain('lastRefresh') + }) + + it('does NOT include data fields', () => { + const uiSet = new Set(UI_FIELDS) + expect(uiSet.has('clusters' as keyof ClusterCache)).toBe(false) + expect(uiSet.has('lastUpdated' as keyof ClusterCache)).toBe(false) + expect(uiSet.has('consecutiveFailures' as keyof ClusterCache)).toBe(false) + expect(uiSet.has('isFailed' as keyof ClusterCache)).toBe(false) + }) +}) + +describe('DATA_FIELDS and UI_FIELDS partition', () => { + it('have no overlap', () => { + const dataSet = new Set(DATA_FIELDS) + const uiSet = new Set(UI_FIELDS) + for (const field of DATA_FIELDS) { + expect(uiSet.has(field as keyof ClusterCache)).toBe(false) + } + for (const field of UI_FIELDS) { + expect(dataSet.has(field as keyof ClusterCache)).toBe(false) + } + }) + + it('cover all ClusterCache fields', () => { + const allFields = new Set([...DATA_FIELDS, ...UI_FIELDS]) + // ClusterCache has exactly 8 fields + expect(allFields.size).toBe(8) + }) +}) + +// --------------------------------------------------------------------------- +// updatesTouchData +// --------------------------------------------------------------------------- + +describe('updatesTouchData', () => { + it('returns true when updating clusters', () => { + expect(updatesTouchData({ clusters: [] })).toBe(true) + }) + + it('returns true when updating lastUpdated', () => { + expect(updatesTouchData({ lastUpdated: new Date() })).toBe(true) + }) + + it('returns true when updating consecutiveFailures', () => { + expect(updatesTouchData({ consecutiveFailures: 3 })).toBe(true) + }) + + it('returns true when updating isFailed', () => { + expect(updatesTouchData({ isFailed: true })).toBe(true) + }) + + it('returns false when updating only UI fields', () => { + expect(updatesTouchData({ isLoading: true })).toBe(false) + expect(updatesTouchData({ isRefreshing: true })).toBe(false) + expect(updatesTouchData({ error: 'test' })).toBe(false) + expect(updatesTouchData({ lastRefresh: new Date() })).toBe(false) + }) + + it('returns false for empty updates', () => { + expect(updatesTouchData({})).toBe(false) + }) + + it('returns true when updating both data and UI fields', () => { + expect(updatesTouchData({ clusters: [], isLoading: false })).toBe(true) + }) + + it('handles null/undefined values in updates', () => { + expect(updatesTouchData({ clusters: [] })).toBe(true) + expect(updatesTouchData({ lastUpdated: null })).toBe(true) + expect(updatesTouchData({ error: null })).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// updatesTouchUI +// --------------------------------------------------------------------------- + +describe('updatesTouchUI', () => { + it('returns true when updating isLoading', () => { + expect(updatesTouchUI({ isLoading: true })).toBe(true) + }) + + it('returns true when updating isRefreshing', () => { + expect(updatesTouchUI({ isRefreshing: true })).toBe(true) + }) + + it('returns true when updating error', () => { + expect(updatesTouchUI({ error: 'test error' })).toBe(true) + }) + + it('returns true when updating lastRefresh', () => { + expect(updatesTouchUI({ lastRefresh: new Date() })).toBe(true) + }) + + it('returns false when updating only data fields', () => { + expect(updatesTouchUI({ clusters: [] })).toBe(false) + expect(updatesTouchUI({ lastUpdated: new Date() })).toBe(false) + expect(updatesTouchUI({ consecutiveFailures: 2 })).toBe(false) + expect(updatesTouchUI({ isFailed: false })).toBe(false) + }) + + it('returns false for empty updates', () => { + expect(updatesTouchUI({})).toBe(false) + }) + + it('returns true when updating both data and UI fields', () => { + expect(updatesTouchUI({ isLoading: true, clusters: [] })).toBe(true) + }) + + it('handles null/undefined values in updates', () => { + expect(updatesTouchUI({ error: null })).toBe(true) + expect(updatesTouchUI({ lastRefresh: null })).toBe(true) + expect(updatesTouchUI({ lastUpdated: null })).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Guard against undefined arrays (issue #15569) +// --------------------------------------------------------------------------- + +describe('Array safety (issue #15569)', () => { + it('updatesTouchData guards against undefined DATA_FIELDS', () => { + // Even if DATA_FIELDS is somehow undefined, function should not crash + expect(() => updatesTouchData({ clusters: [] })).not.toThrow() + }) + + it('updatesTouchUI guards against undefined UI_FIELDS', () => { + // Even if UI_FIELDS is somehow undefined, function should not crash + expect(() => updatesTouchUI({ isLoading: true })).not.toThrow() + }) +})