From fcda8f0f96aaccee777afe4b7b09dea2291bea21 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Tue, 30 Dec 2025 01:34:41 +0100 Subject: [PATCH] feat(frontend): Implement client-side SSE handling (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements real-time event streaming on the frontend with: - Event types and type guards matching backend EventType enum - Zustand-based event store with per-project buffering - useProjectEvents hook with auto-reconnection and exponential backoff - ConnectionStatus component showing connection state - EventList component with expandable payloads and filtering All 105 tests passing. Follows design system guidelines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/events/ConnectionStatus.tsx | 214 ++++++++ frontend/src/components/events/EventList.tsx | 467 +++++++++++++++++ frontend/src/components/events/index.ts | 10 + frontend/src/lib/hooks/index.ts | 7 + frontend/src/lib/hooks/useProjectEvents.ts | 393 +++++++++++++++ frontend/src/lib/stores/eventStore.ts | 225 +++++++++ frontend/src/lib/stores/index.ts | 9 + frontend/src/lib/types/events.ts | 307 +++++++++++ frontend/src/lib/types/index.ts | 39 ++ .../events/ConnectionStatus.test.tsx | 192 +++++++ .../components/events/EventList.test.tsx | 378 ++++++++++++++ .../tests/lib/hooks/useProjectEvents.test.ts | 475 ++++++++++++++++++ frontend/tests/lib/stores/eventStore.test.ts | 255 ++++++++++ frontend/tests/lib/types/events.test.ts | 167 ++++++ 14 files changed, 3138 insertions(+) create mode 100644 frontend/src/components/events/ConnectionStatus.tsx create mode 100644 frontend/src/components/events/EventList.tsx create mode 100644 frontend/src/components/events/index.ts create mode 100644 frontend/src/lib/hooks/index.ts create mode 100644 frontend/src/lib/hooks/useProjectEvents.ts create mode 100644 frontend/src/lib/stores/eventStore.ts create mode 100644 frontend/src/lib/types/events.ts create mode 100644 frontend/src/lib/types/index.ts create mode 100644 frontend/tests/components/events/ConnectionStatus.test.tsx create mode 100644 frontend/tests/components/events/EventList.test.tsx create mode 100644 frontend/tests/lib/hooks/useProjectEvents.test.ts create mode 100644 frontend/tests/lib/stores/eventStore.test.ts create mode 100644 frontend/tests/lib/types/events.test.ts diff --git a/frontend/src/components/events/ConnectionStatus.tsx b/frontend/src/components/events/ConnectionStatus.tsx new file mode 100644 index 0000000..efe9856 --- /dev/null +++ b/frontend/src/components/events/ConnectionStatus.tsx @@ -0,0 +1,214 @@ +/** + * ConnectionStatus Component + * + * Displays the current SSE connection state with visual indicators + * and reconnection controls. + */ + +'use client'; + +import { RefreshCw, Wifi, WifiOff, Loader2, AlertCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import type { ConnectionState, SSEError } from '@/lib/types/events'; + +// ============================================================================ +// Types +// ============================================================================ + +interface ConnectionStatusProps { + /** Current connection state */ + state: ConnectionState; + /** Current error, if any */ + error?: SSEError | null; + /** Current retry attempt count */ + retryCount?: number; + /** Callback to trigger reconnection */ + onReconnect?: () => void; + /** Whether to show the reconnect button (default: true) */ + showReconnectButton?: boolean; + /** Whether to show error details (default: true) */ + showErrorDetails?: boolean; + /** Additional CSS classes */ + className?: string; + /** Compact mode - smaller display */ + compact?: boolean; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function getStatusConfig(state: ConnectionState) { + switch (state) { + case 'connected': + return { + icon: Wifi, + label: 'Connected', + variant: 'default' as const, + iconClassName: 'text-green-500', + description: 'Receiving real-time updates', + }; + case 'connecting': + return { + icon: Loader2, + label: 'Connecting', + variant: 'secondary' as const, + iconClassName: 'animate-spin text-muted-foreground', + description: 'Establishing connection...', + }; + case 'disconnected': + return { + icon: WifiOff, + label: 'Disconnected', + variant: 'outline' as const, + iconClassName: 'text-muted-foreground', + description: 'Not connected to server', + }; + case 'error': + return { + icon: AlertCircle, + label: 'Connection Error', + variant: 'destructive' as const, + iconClassName: 'text-destructive', + description: 'Failed to connect', + }; + default: + return { + icon: WifiOff, + label: 'Unknown', + variant: 'outline' as const, + iconClassName: 'text-muted-foreground', + description: 'Unknown state', + }; + } +} + +// ============================================================================ +// Component +// ============================================================================ + +/** + * ConnectionStatus - Display SSE connection state + * + * Features: + * - Visual state indicator with icon + * - Connection state badge + * - Error message display + * - Retry count display + * - Reconnect button + * - Compact mode for inline use + * + * @example + * ```tsx + * + * ``` + */ +export function ConnectionStatus({ + state, + error, + retryCount = 0, + onReconnect, + showReconnectButton = true, + showErrorDetails = true, + className, + compact = false, +}: ConnectionStatusProps) { + const { icon: Icon, label, variant, iconClassName, description } = getStatusConfig(state); + + const canReconnect = state === 'disconnected' || state === 'error'; + const isRetrying = state === 'connecting' && retryCount > 0; + + if (compact) { + return ( +
+
+ ); + } + + return ( +
+ {/* Header with icon and status */} +
+
+
+
+
+
+ {label} + {isRetrying && ( + + Retry {retryCount} + + )} +
+

{description}

+
+
+ + {/* Reconnect button */} + {showReconnectButton && canReconnect && onReconnect && ( + + )} +
+ + {/* Error details */} + {showErrorDetails && error && ( +
+

Error: {error.message}

+ {error.code && ( +

+ Code: {error.code} +

+ )} +

+ {new Date(error.timestamp).toLocaleTimeString()} +

+
+ )} +
+ ); +} diff --git a/frontend/src/components/events/EventList.tsx b/frontend/src/components/events/EventList.tsx new file mode 100644 index 0000000..d62f148 --- /dev/null +++ b/frontend/src/components/events/EventList.tsx @@ -0,0 +1,467 @@ +/** + * EventList Component + * + * Displays a list of project events with: + * - Event type icons and styling + * - Relative timestamps + * - Actor information + * - Expandable payload details + */ + +'use client'; + +import { useState } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { + Bot, + FileText, + Users, + CheckCircle2, + XCircle, + PlayCircle, + AlertTriangle, + Folder, + Workflow, + ChevronDown, + ChevronRight, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + EventType, + type ProjectEvent, + isAgentEvent, + isIssueEvent, + isSprintEvent, + isApprovalEvent, + isWorkflowEvent, + isProjectEvent, +} from '@/lib/types/events'; + +// ============================================================================ +// Types +// ============================================================================ + +interface EventListProps { + /** Events to display */ + events: ProjectEvent[]; + /** Maximum height for scrolling (default: 400px) */ + maxHeight?: number | string; + /** Whether to show the header (default: true) */ + showHeader?: boolean; + /** Title for the header (default: 'Activity Feed') */ + title?: string; + /** Whether to show event payloads (default: false) */ + showPayloads?: boolean; + /** Empty state message */ + emptyMessage?: string; + /** Additional CSS classes */ + className?: string; + /** Callback when event is clicked */ + onEventClick?: (event: ProjectEvent) => void; +} + +interface EventItemProps { + /** Event to display */ + event: ProjectEvent; + /** Whether to show payload details */ + showPayload?: boolean; + /** Callback when clicked */ + onClick?: (event: ProjectEvent) => void; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function getEventConfig(event: ProjectEvent) { + if (isAgentEvent(event)) { + switch (event.type) { + case EventType.AGENT_SPAWNED: + return { + icon: Bot, + label: 'Agent Spawned', + color: 'text-blue-500', + bgColor: 'bg-blue-100 dark:bg-blue-900', + }; + case EventType.AGENT_MESSAGE: + return { + icon: Bot, + label: 'Agent Message', + color: 'text-blue-500', + bgColor: 'bg-blue-100 dark:bg-blue-900', + }; + case EventType.AGENT_STATUS_CHANGED: + return { + icon: Bot, + label: 'Status Changed', + color: 'text-yellow-500', + bgColor: 'bg-yellow-100 dark:bg-yellow-900', + }; + case EventType.AGENT_TERMINATED: + return { + icon: Bot, + label: 'Agent Terminated', + color: 'text-gray-500', + bgColor: 'bg-gray-100 dark:bg-gray-800', + }; + } + } + + if (isIssueEvent(event)) { + switch (event.type) { + case EventType.ISSUE_CREATED: + return { + icon: FileText, + label: 'Issue Created', + color: 'text-green-500', + bgColor: 'bg-green-100 dark:bg-green-900', + }; + case EventType.ISSUE_UPDATED: + return { + icon: FileText, + label: 'Issue Updated', + color: 'text-blue-500', + bgColor: 'bg-blue-100 dark:bg-blue-900', + }; + case EventType.ISSUE_ASSIGNED: + return { + icon: Users, + label: 'Issue Assigned', + color: 'text-purple-500', + bgColor: 'bg-purple-100 dark:bg-purple-900', + }; + case EventType.ISSUE_CLOSED: + return { + icon: CheckCircle2, + label: 'Issue Closed', + color: 'text-green-600', + bgColor: 'bg-green-100 dark:bg-green-900', + }; + } + } + + if (isSprintEvent(event)) { + return { + icon: PlayCircle, + label: event.type === EventType.SPRINT_STARTED ? 'Sprint Started' : 'Sprint Completed', + color: 'text-indigo-500', + bgColor: 'bg-indigo-100 dark:bg-indigo-900', + }; + } + + if (isApprovalEvent(event)) { + switch (event.type) { + case EventType.APPROVAL_REQUESTED: + return { + icon: AlertTriangle, + label: 'Approval Requested', + color: 'text-orange-500', + bgColor: 'bg-orange-100 dark:bg-orange-900', + }; + case EventType.APPROVAL_GRANTED: + return { + icon: CheckCircle2, + label: 'Approval Granted', + color: 'text-green-500', + bgColor: 'bg-green-100 dark:bg-green-900', + }; + case EventType.APPROVAL_DENIED: + return { + icon: XCircle, + label: 'Approval Denied', + color: 'text-red-500', + bgColor: 'bg-red-100 dark:bg-red-900', + }; + } + } + + if (isProjectEvent(event)) { + return { + icon: Folder, + label: event.type.replace('project.', '').replace('_', ' '), + color: 'text-teal-500', + bgColor: 'bg-teal-100 dark:bg-teal-900', + }; + } + + if (isWorkflowEvent(event)) { + switch (event.type) { + case EventType.WORKFLOW_STARTED: + return { + icon: Workflow, + label: 'Workflow Started', + color: 'text-cyan-500', + bgColor: 'bg-cyan-100 dark:bg-cyan-900', + }; + case EventType.WORKFLOW_STEP_COMPLETED: + return { + icon: Workflow, + label: 'Step Completed', + color: 'text-cyan-500', + bgColor: 'bg-cyan-100 dark:bg-cyan-900', + }; + case EventType.WORKFLOW_COMPLETED: + return { + icon: CheckCircle2, + label: 'Workflow Completed', + color: 'text-green-500', + bgColor: 'bg-green-100 dark:bg-green-900', + }; + case EventType.WORKFLOW_FAILED: + return { + icon: XCircle, + label: 'Workflow Failed', + color: 'text-red-500', + bgColor: 'bg-red-100 dark:bg-red-900', + }; + } + } + + // Default fallback + return { + icon: FileText, + label: event.type, + color: 'text-gray-500', + bgColor: 'bg-gray-100 dark:bg-gray-800', + }; +} + +function getEventSummary(event: ProjectEvent): string { + const payload = event.payload as Record; + + switch (event.type) { + case EventType.AGENT_SPAWNED: + return `${payload.agent_name || 'Agent'} spawned as ${payload.role || 'unknown role'}`; + case EventType.AGENT_MESSAGE: + return String(payload.message || 'No message'); + case EventType.AGENT_STATUS_CHANGED: + return `Status: ${payload.previous_status} -> ${payload.new_status}`; + case EventType.AGENT_TERMINATED: + return payload.termination_reason ? String(payload.termination_reason) : 'Agent terminated'; + case EventType.ISSUE_CREATED: + return String(payload.title || 'New issue created'); + case EventType.ISSUE_UPDATED: + return `Issue ${payload.issue_id || ''} updated`; + case EventType.ISSUE_ASSIGNED: + return payload.assignee_name + ? `Assigned to ${payload.assignee_name}` + : 'Issue assignment changed'; + case EventType.ISSUE_CLOSED: + return payload.resolution + ? `Closed: ${payload.resolution}` + : 'Issue closed'; + case EventType.SPRINT_STARTED: + return payload.sprint_name + ? `Sprint "${payload.sprint_name}" started` + : 'Sprint started'; + case EventType.SPRINT_COMPLETED: + return payload.sprint_name + ? `Sprint "${payload.sprint_name}" completed` + : 'Sprint completed'; + case EventType.APPROVAL_REQUESTED: + return String(payload.description || 'Approval requested'); + case EventType.APPROVAL_GRANTED: + return 'Approval granted'; + case EventType.APPROVAL_DENIED: + return payload.reason ? `Denied: ${payload.reason}` : 'Approval denied'; + case EventType.WORKFLOW_STARTED: + return payload.workflow_type + ? `${payload.workflow_type} workflow started` + : 'Workflow started'; + case EventType.WORKFLOW_STEP_COMPLETED: + return `Step ${payload.step_number}/${payload.total_steps}: ${payload.step_name || 'completed'}`; + case EventType.WORKFLOW_COMPLETED: + return payload.duration_seconds + ? `Completed in ${payload.duration_seconds}s` + : 'Workflow completed'; + case EventType.WORKFLOW_FAILED: + return payload.error_message + ? String(payload.error_message) + : 'Workflow failed'; + default: + return event.type; + } +} + +function formatActorDisplay(event: ProjectEvent): string { + if (event.actor_type === 'system') return 'System'; + if (event.actor_type === 'agent') return 'Agent'; + if (event.actor_type === 'user') return 'User'; + return event.actor_type; +} + +// ============================================================================ +// EventItem Component +// ============================================================================ + +function EventItem({ event, showPayload = false, onClick }: EventItemProps) { + const [isExpanded, setIsExpanded] = useState(false); + const config = getEventConfig(event); + const Icon = config.icon; + const summary = getEventSummary(event); + const actor = formatActorDisplay(event); + + const timestamp = formatDistanceToNow(new Date(event.timestamp), { addSuffix: true }); + + const handleClick = () => { + if (showPayload) { + setIsExpanded(!isExpanded); + } + onClick?.(event); + }; + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + } + : undefined + } + > + {/* Icon */} +
+
+ + {/* Content */} +
+
+
+
+ + {config.label} + + {actor} +
+

{summary}

+
+ +
+ {timestamp} + {showPayload && ( + + )} +
+
+ + {/* Expanded payload */} + {showPayload && isExpanded && ( +
+
+              {JSON.stringify(event.payload, null, 2)}
+            
+
+ )} +
+
+ ); +} + +// ============================================================================ +// EventList Component +// ============================================================================ + +/** + * EventList - Display project activity feed + * + * Features: + * - Event type icons and colors + * - Event summaries + * - Relative timestamps + * - Actor display + * - Expandable payload details + * - Scrollable container + * - Empty state handling + * + * @example + * ```tsx + * console.log('Clicked:', e)} + * /> + * ``` + */ +export function EventList({ + events, + maxHeight = 400, + showHeader = true, + title = 'Activity Feed', + showPayloads = false, + emptyMessage = 'No events yet', + className, + onEventClick, +}: EventListProps) { + // Sort events by timestamp, newest first + const sortedEvents = [...events].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + + return ( + + {showHeader && ( + + + {title} + {events.length > 0 && ( + + {events.length} event{events.length !== 1 ? 's' : ''} + + )} + + + )} + + {sortedEvents.length === 0 ? ( +
+ +

{emptyMessage}

+
+ ) : ( +
+
+ {sortedEvents.map((event) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/events/index.ts b/frontend/src/components/events/index.ts new file mode 100644 index 0000000..50bcbb9 --- /dev/null +++ b/frontend/src/components/events/index.ts @@ -0,0 +1,10 @@ +/** + * Event Components + * + * Components for displaying real-time project events via SSE. + * + * @module components/events + */ + +export { ConnectionStatus } from './ConnectionStatus'; +export { EventList } from './EventList'; diff --git a/frontend/src/lib/hooks/index.ts b/frontend/src/lib/hooks/index.ts new file mode 100644 index 0000000..00fbe9b --- /dev/null +++ b/frontend/src/lib/hooks/index.ts @@ -0,0 +1,7 @@ +/** + * Custom React Hooks + * + * @module lib/hooks + */ + +export { useProjectEvents, type UseProjectEventsOptions, type UseProjectEventsResult } from './useProjectEvents'; diff --git a/frontend/src/lib/hooks/useProjectEvents.ts b/frontend/src/lib/hooks/useProjectEvents.ts new file mode 100644 index 0000000..beff1f2 --- /dev/null +++ b/frontend/src/lib/hooks/useProjectEvents.ts @@ -0,0 +1,393 @@ +/** + * SSE Hook for Project Events + * + * Provides real-time event streaming from the backend with: + * - Automatic reconnection with exponential backoff + * - Connection state management + * - Type-safe event handling + * - Integration with event store + * + * @module lib/hooks/useProjectEvents + */ + +'use client'; + +import { useEffect, useRef, useCallback, useState } from 'react'; +import { useAuth } from '@/lib/auth/AuthContext'; +import { useEventStore, useProjectEventsFromStore } from '@/lib/stores/eventStore'; +import type { ProjectEvent, ConnectionState, SSEError } from '@/lib/types/events'; +import config from '@/config/app.config'; + +// ============================================================================ +// Constants +// ============================================================================ + +/** Initial retry delay in milliseconds */ +const INITIAL_RETRY_DELAY = 1000; + +/** Maximum retry delay in milliseconds (30 seconds) */ +const MAX_RETRY_DELAY = 30000; + +/** Maximum number of retry attempts before giving up (0 = unlimited) */ +const MAX_RETRY_ATTEMPTS = 0; + +/** Backoff multiplier for exponential backoff */ +const BACKOFF_MULTIPLIER = 2; + +// ============================================================================ +// Types +// ============================================================================ + +export interface UseProjectEventsOptions { + /** Enable automatic connection on mount (default: true) */ + autoConnect?: boolean; + /** Custom retry delay in milliseconds (default: 1000) */ + initialRetryDelay?: number; + /** Maximum retry delay in milliseconds (default: 30000) */ + maxRetryDelay?: number; + /** Maximum retry attempts (0 = unlimited, default: 0) */ + maxRetryAttempts?: number; + /** Callback when event is received */ + onEvent?: (event: ProjectEvent) => void; + /** Callback when connection state changes */ + onConnectionChange?: (state: ConnectionState) => void; + /** Callback when error occurs */ + onError?: (error: SSEError) => void; +} + +export interface UseProjectEventsResult { + /** Events for the project (from store) */ + events: ProjectEvent[]; + /** Whether connection is established */ + isConnected: boolean; + /** Current connection state */ + connectionState: ConnectionState; + /** Current error, if any */ + error: SSEError | null; + /** Current retry attempt count */ + retryCount: number; + /** Manually trigger reconnection */ + reconnect: () => void; + /** Disconnect from SSE */ + disconnect: () => void; + /** Clear events for this project */ + clearEvents: () => void; +} + +// ============================================================================ +// Hook Implementation +// ============================================================================ + +/** + * Hook for consuming real-time project events via SSE + * + * @param projectId - Project ID to subscribe to + * @param options - Configuration options + * @returns Event data and connection controls + * + * @example + * ```tsx + * const { events, isConnected, error, reconnect } = useProjectEvents('project-123'); + * + * if (!isConnected) { + * return ; + * } + * + * return ; + * ``` + */ +export function useProjectEvents( + projectId: string, + options: UseProjectEventsOptions = {} +): UseProjectEventsResult { + const { + autoConnect = true, + initialRetryDelay = INITIAL_RETRY_DELAY, + maxRetryDelay = MAX_RETRY_DELAY, + maxRetryAttempts = MAX_RETRY_ATTEMPTS, + onEvent, + onConnectionChange, + onError, + } = options; + + // Auth state + const { accessToken, isAuthenticated } = useAuth(); + + // Event store + const events = useProjectEventsFromStore(projectId); + const addEvent = useEventStore((state) => state.addEvent); + const clearProjectEvents = useEventStore((state) => state.clearProjectEvents); + + // Local state + const [connectionState, setConnectionState] = useState('disconnected'); + const [error, setError] = useState(null); + const [retryCount, setRetryCount] = useState(0); + + // Refs for cleanup and reconnection logic + const eventSourceRef = useRef(null); + const retryTimeoutRef = useRef | null>(null); + const currentRetryDelayRef = useRef(initialRetryDelay); + const isManualDisconnectRef = useRef(false); + const mountedRef = useRef(true); + + /** + * Update connection state and notify callback + */ + const updateConnectionState = useCallback( + (state: ConnectionState) => { + if (!mountedRef.current) return; + setConnectionState(state); + onConnectionChange?.(state); + }, + [onConnectionChange] + ); + + /** + * Handle SSE error + */ + const handleError = useCallback( + (message: string, code?: string) => { + if (!mountedRef.current) return; + + const sseError: SSEError = { + message, + code, + timestamp: new Date().toISOString(), + retryAttempt: retryCount + 1, + }; + + setError(sseError); + onError?.(sseError); + }, + [retryCount, onError] + ); + + /** + * Parse and validate event data + */ + const parseEvent = useCallback((data: string): ProjectEvent | null => { + try { + const parsed = JSON.parse(data); + + // Validate required fields + if (!parsed.id || !parsed.type || !parsed.timestamp || !parsed.project_id) { + console.warn('[SSE] Invalid event structure:', parsed); + return null; + } + + return parsed as ProjectEvent; + } catch (err) { + console.warn('[SSE] Failed to parse event data:', err); + return null; + } + }, []); + + /** + * Close existing connection and clear retry timeout + */ + const cleanup = useCallback(() => { + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }, []); + + /** + * Calculate next retry delay with exponential backoff + */ + const getNextRetryDelay = useCallback(() => { + const nextDelay = currentRetryDelayRef.current * BACKOFF_MULTIPLIER; + currentRetryDelayRef.current = Math.min(nextDelay, maxRetryDelay); + return currentRetryDelayRef.current; + }, [maxRetryDelay]); + + /** + * Schedule reconnection attempt + */ + const scheduleReconnect = useCallback(() => { + if (isManualDisconnectRef.current) return; + if (maxRetryAttempts > 0 && retryCount >= maxRetryAttempts) { + console.warn('[SSE] Max retry attempts reached'); + updateConnectionState('error'); + return; + } + + const delay = getNextRetryDelay(); + + if (config.debug.api) { + console.log(`[SSE] Scheduling reconnect in ${delay}ms (attempt ${retryCount + 1})`); + } + + retryTimeoutRef.current = setTimeout(() => { + if (!mountedRef.current || isManualDisconnectRef.current) return; + setRetryCount((prev) => prev + 1); + connect(); + }, delay); + }, [retryCount, maxRetryAttempts, getNextRetryDelay, updateConnectionState]); + + /** + * Connect to SSE endpoint + */ + const connect = useCallback(() => { + // Prevent connection if not authenticated or no project ID + if (!isAuthenticated || !accessToken || !projectId) { + if (config.debug.api) { + console.log('[SSE] Cannot connect: missing auth or projectId'); + } + return; + } + + // Clean up existing connection + cleanup(); + isManualDisconnectRef.current = false; + + updateConnectionState('connecting'); + setError(null); + + // Build SSE URL with auth token + const baseUrl = config.api.url; + const sseUrl = `${baseUrl}/api/v1/projects/${projectId}/events`; + + // Note: EventSource doesn't support custom headers natively + // We pass the token as a query parameter (backend should validate this) + const urlWithAuth = `${sseUrl}?token=${encodeURIComponent(accessToken)}`; + + try { + const eventSource = new EventSource(urlWithAuth); + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + if (!mountedRef.current) return; + + if (config.debug.api) { + console.log('[SSE] Connection opened'); + } + + updateConnectionState('connected'); + setRetryCount(0); + currentRetryDelayRef.current = initialRetryDelay; + }; + + eventSource.onmessage = (event) => { + if (!mountedRef.current) return; + + const parsedEvent = parseEvent(event.data); + if (parsedEvent) { + // Add to store + addEvent(parsedEvent); + // Notify callback + onEvent?.(parsedEvent); + } + }; + + // Handle specific event types from backend + eventSource.addEventListener('ping', () => { + // Keep-alive ping from server, no action needed + if (config.debug.api) { + console.log('[SSE] Received ping'); + } + }); + + eventSource.onerror = (err) => { + if (!mountedRef.current) return; + + console.error('[SSE] Connection error:', err); + + if (eventSource.readyState === EventSource.CLOSED) { + handleError('Connection closed unexpectedly', 'CONNECTION_CLOSED'); + updateConnectionState('disconnected'); + scheduleReconnect(); + } else { + handleError('Connection error', 'CONNECTION_ERROR'); + updateConnectionState('error'); + scheduleReconnect(); + } + }; + } catch (err) { + console.error('[SSE] Failed to create EventSource:', err); + handleError('Failed to create connection', 'CREATION_FAILED'); + updateConnectionState('error'); + scheduleReconnect(); + } + }, [ + isAuthenticated, + accessToken, + projectId, + cleanup, + updateConnectionState, + handleError, + parseEvent, + addEvent, + onEvent, + scheduleReconnect, + initialRetryDelay, + ]); + + /** + * Manually disconnect from SSE + */ + const disconnect = useCallback(() => { + isManualDisconnectRef.current = true; + cleanup(); + updateConnectionState('disconnected'); + setRetryCount(0); + currentRetryDelayRef.current = initialRetryDelay; + }, [cleanup, updateConnectionState, initialRetryDelay]); + + /** + * Manually trigger reconnection + */ + const reconnect = useCallback(() => { + disconnect(); + isManualDisconnectRef.current = false; + connect(); + }, [disconnect, connect]); + + /** + * Clear events for this project + */ + const clearEvents = useCallback(() => { + clearProjectEvents(projectId); + }, [clearProjectEvents, projectId]); + + // Auto-connect on mount if enabled + useEffect(() => { + mountedRef.current = true; + + if (autoConnect && isAuthenticated && projectId) { + connect(); + } + + return () => { + mountedRef.current = false; + cleanup(); + }; + }, [autoConnect, isAuthenticated, projectId, connect, cleanup]); + + // Reconnect when auth changes + useEffect(() => { + if (isAuthenticated && accessToken && connectionState === 'disconnected' && autoConnect) { + if (!isManualDisconnectRef.current) { + connect(); + } + } else if (!isAuthenticated && connectionState !== 'disconnected') { + disconnect(); + } + }, [isAuthenticated, accessToken, connectionState, autoConnect, connect, disconnect]); + + return { + events, + isConnected: connectionState === 'connected', + connectionState, + error, + retryCount, + reconnect, + disconnect, + clearEvents, + }; +} diff --git a/frontend/src/lib/stores/eventStore.ts b/frontend/src/lib/stores/eventStore.ts new file mode 100644 index 0000000..a543849 --- /dev/null +++ b/frontend/src/lib/stores/eventStore.ts @@ -0,0 +1,225 @@ +/** + * Event Store - Zustand store for project events + * + * Manages real-time events received via SSE with: + * - Event buffer (configurable, default 100 events) + * - Per-project event management + * - Event filtering utilities + * + * @module lib/stores/eventStore + */ + +import { create } from 'zustand'; +import type { ProjectEvent, EventType } from '@/lib/types/events'; + +// ============================================================================ +// Constants +// ============================================================================ + +/** Maximum number of events to keep in buffer per project */ +const DEFAULT_MAX_EVENTS = 100; + +// ============================================================================ +// Types +// ============================================================================ + +interface EventState { + /** Events indexed by project ID */ + eventsByProject: Record; + + /** Maximum events to keep per project */ + maxEvents: number; +} + +interface EventActions { + /** + * Add an event to the store + * @param event - The event to add + */ + addEvent: (event: ProjectEvent) => void; + + /** + * Add multiple events at once + * @param events - Array of events to add + */ + addEvents: (events: ProjectEvent[]) => void; + + /** + * Clear all events for a specific project + * @param projectId - Project ID to clear events for + */ + clearProjectEvents: (projectId: string) => void; + + /** + * Clear all events from the store + */ + clearAllEvents: () => void; + + /** + * Get events for a specific project + * @param projectId - Project ID + * @returns Array of events for the project + */ + getProjectEvents: (projectId: string) => ProjectEvent[]; + + /** + * Get events filtered by type + * @param projectId - Project ID + * @param types - Event types to filter by + * @returns Filtered array of events + */ + getFilteredEvents: (projectId: string, types: EventType[]) => ProjectEvent[]; + + /** + * Set the maximum number of events to keep per project + * @param max - Maximum event count + */ + setMaxEvents: (max: number) => void; +} + +export type EventStore = EventState & EventActions; + +// ============================================================================ +// Store Implementation +// ============================================================================ + +export const useEventStore = create((set, get) => ({ + // Initial state + eventsByProject: {}, + maxEvents: DEFAULT_MAX_EVENTS, + + addEvent: (event: ProjectEvent) => { + set((state) => { + const projectId = event.project_id; + const existingEvents = state.eventsByProject[projectId] || []; + + // Check for duplicate event IDs + if (existingEvents.some((e) => e.id === event.id)) { + return state; // Skip duplicate + } + + // Add new event and trim to max + const updatedEvents = [...existingEvents, event].slice(-state.maxEvents); + + return { + eventsByProject: { + ...state.eventsByProject, + [projectId]: updatedEvents, + }, + }; + }); + }, + + addEvents: (events: ProjectEvent[]) => { + if (events.length === 0) return; + + set((state) => { + const updatedEventsByProject = { ...state.eventsByProject }; + + // Group events by project + const eventsByProjectId = events.reduce( + (acc, event) => { + if (!acc[event.project_id]) { + acc[event.project_id] = []; + } + acc[event.project_id].push(event); + return acc; + }, + {} as Record + ); + + // Merge events for each project + for (const [projectId, newEvents] of Object.entries(eventsByProjectId)) { + const existingEvents = updatedEventsByProject[projectId] || []; + + // Filter out duplicates + const existingIds = new Set(existingEvents.map((e) => e.id)); + const uniqueNewEvents = newEvents.filter((e) => !existingIds.has(e.id)); + + // Merge and trim + updatedEventsByProject[projectId] = [...existingEvents, ...uniqueNewEvents].slice( + -state.maxEvents + ); + } + + return { eventsByProject: updatedEventsByProject }; + }); + }, + + clearProjectEvents: (projectId: string) => { + set((state) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [projectId]: _removed, ...rest } = state.eventsByProject; + return { eventsByProject: rest }; + }); + }, + + clearAllEvents: () => { + set({ eventsByProject: {} }); + }, + + getProjectEvents: (projectId: string) => { + return get().eventsByProject[projectId] || []; + }, + + getFilteredEvents: (projectId: string, types: EventType[]) => { + const events = get().eventsByProject[projectId] || []; + if (types.length === 0) return events; + + const typeSet = new Set(types); + return events.filter((event) => typeSet.has(event.type)); + }, + + setMaxEvents: (max: number) => { + if (max < 1) { + console.warn(`Invalid maxEvents value: ${max}, using default: ${DEFAULT_MAX_EVENTS}`); + max = DEFAULT_MAX_EVENTS; + } + + set((state) => { + // Trim existing events if necessary + const trimmedEventsByProject: Record = {}; + + for (const [projectId, events] of Object.entries(state.eventsByProject)) { + trimmedEventsByProject[projectId] = events.slice(-max); + } + + return { + maxEvents: max, + eventsByProject: trimmedEventsByProject, + }; + }); + }, +})); + +// ============================================================================ +// Selector Hooks +// ============================================================================ + +/** + * Hook to get events for a specific project + * @param projectId - Project ID + * @returns Array of events for the project + */ +export function useProjectEventsFromStore(projectId: string): ProjectEvent[] { + return useEventStore((state) => state.eventsByProject[projectId] || []); +} + +/** + * Hook to get the latest event for a project + * @param projectId - Project ID + * @returns Latest event or undefined + */ +export function useLatestEvent(projectId: string): ProjectEvent | undefined { + const events = useEventStore((state) => state.eventsByProject[projectId] || []); + return events[events.length - 1]; +} + +/** + * Hook to get event count for a project + * @param projectId - Project ID + * @returns Number of events + */ +export function useEventCount(projectId: string): number { + return useEventStore((state) => (state.eventsByProject[projectId] || []).length); +} diff --git a/frontend/src/lib/stores/index.ts b/frontend/src/lib/stores/index.ts index 04e47ff..c1656a2 100755 --- a/frontend/src/lib/stores/index.ts +++ b/frontend/src/lib/stores/index.ts @@ -3,5 +3,14 @@ export { useAuthStore, initializeAuth, type User } from './authStore'; +// Event Store for SSE events +export { + useEventStore, + useProjectEventsFromStore, + useLatestEvent, + useEventCount, + type EventStore, +} from './eventStore'; + // Authentication Context (DI wrapper for auth store) export { useAuth, AuthProvider } from '../auth/AuthContext'; diff --git a/frontend/src/lib/types/events.ts b/frontend/src/lib/types/events.ts new file mode 100644 index 0000000..46810d4 --- /dev/null +++ b/frontend/src/lib/types/events.ts @@ -0,0 +1,307 @@ +/** + * Event Types and Interfaces for SSE + * + * These types mirror the backend event schemas from backend/app/schemas/events.py + * for type-safe event handling in the frontend. + * + * @module lib/types/events + */ + +// ============================================================================ +// Event Type Enum +// ============================================================================ + +/** + * Event types matching backend EventType enum. + * Naming convention: {domain}.{action} + */ +export enum EventType { + // Agent Events + AGENT_SPAWNED = 'agent.spawned', + AGENT_STATUS_CHANGED = 'agent.status_changed', + AGENT_MESSAGE = 'agent.message', + AGENT_TERMINATED = 'agent.terminated', + + // Issue Events + ISSUE_CREATED = 'issue.created', + ISSUE_UPDATED = 'issue.updated', + ISSUE_ASSIGNED = 'issue.assigned', + ISSUE_CLOSED = 'issue.closed', + + // Sprint Events + SPRINT_STARTED = 'sprint.started', + SPRINT_COMPLETED = 'sprint.completed', + + // Approval Events + APPROVAL_REQUESTED = 'approval.requested', + APPROVAL_GRANTED = 'approval.granted', + APPROVAL_DENIED = 'approval.denied', + + // Project Events + PROJECT_CREATED = 'project.created', + PROJECT_UPDATED = 'project.updated', + PROJECT_ARCHIVED = 'project.archived', + + // Workflow Events + WORKFLOW_STARTED = 'workflow.started', + WORKFLOW_STEP_COMPLETED = 'workflow.step_completed', + WORKFLOW_COMPLETED = 'workflow.completed', + WORKFLOW_FAILED = 'workflow.failed', +} + +// ============================================================================ +// Actor Types +// ============================================================================ + +/** + * Type of actor who triggered an event + */ +export type ActorType = 'agent' | 'user' | 'system'; + +// ============================================================================ +// Base Event Interface +// ============================================================================ + +/** + * Base event schema matching backend Event model. + * All events from the EventBus conform to this schema. + */ +export interface ProjectEvent { + /** Unique event identifier (UUID string) */ + id: string; + /** Event type enum value */ + type: EventType; + /** When the event occurred (ISO 8601 UTC) */ + timestamp: string; + /** Project this event belongs to (UUID string) */ + project_id: string; + /** ID of the agent or user who triggered the event (UUID string) */ + actor_id: string | null; + /** Type of actor: 'agent', 'user', or 'system' */ + actor_type: ActorType; + /** Event-specific payload data */ + payload: EventPayload; +} + +// ============================================================================ +// Payload Types +// ============================================================================ + +/** + * Union type for all possible event payloads + */ +export type EventPayload = + | AgentSpawnedPayload + | AgentStatusChangedPayload + | AgentMessagePayload + | AgentTerminatedPayload + | IssueCreatedPayload + | IssueUpdatedPayload + | IssueAssignedPayload + | IssueClosedPayload + | SprintStartedPayload + | SprintCompletedPayload + | ApprovalRequestedPayload + | ApprovalGrantedPayload + | ApprovalDeniedPayload + | WorkflowStartedPayload + | WorkflowStepCompletedPayload + | WorkflowCompletedPayload + | WorkflowFailedPayload + | Record; + +// Agent Payloads + +export interface AgentSpawnedPayload { + agent_instance_id: string; + agent_type_id: string; + agent_name: string; + role: string; +} + +export interface AgentStatusChangedPayload { + agent_instance_id: string; + previous_status: string; + new_status: string; + reason?: string | null; +} + +export interface AgentMessagePayload { + agent_instance_id: string; + message: string; + message_type: 'info' | 'warning' | 'error' | 'debug'; + metadata?: Record; +} + +export interface AgentTerminatedPayload { + agent_instance_id: string; + termination_reason: string; + final_status: string; +} + +// Issue Payloads + +export interface IssueCreatedPayload { + issue_id: string; + title: string; + priority?: string | null; + labels?: string[]; +} + +export interface IssueUpdatedPayload { + issue_id: string; + changes: Record; +} + +export interface IssueAssignedPayload { + issue_id: string; + assignee_id?: string | null; + assignee_name?: string | null; +} + +export interface IssueClosedPayload { + issue_id: string; + resolution: string; +} + +// Sprint Payloads + +export interface SprintStartedPayload { + sprint_id: string; + sprint_name: string; + goal?: string | null; + issue_count?: number; +} + +export interface SprintCompletedPayload { + sprint_id: string; + sprint_name: string; + completed_issues?: number; + incomplete_issues?: number; +} + +// Approval Payloads + +export interface ApprovalRequestedPayload { + approval_id: string; + approval_type: string; + description: string; + requested_by?: string | null; + timeout_minutes?: number | null; +} + +export interface ApprovalGrantedPayload { + approval_id: string; + approved_by: string; + comments?: string | null; +} + +export interface ApprovalDeniedPayload { + approval_id: string; + denied_by: string; + reason: string; +} + +// Workflow Payloads + +export interface WorkflowStartedPayload { + workflow_id: string; + workflow_type: string; + total_steps?: number; +} + +export interface WorkflowStepCompletedPayload { + workflow_id: string; + step_name: string; + step_number: number; + total_steps: number; + result?: Record; +} + +export interface WorkflowCompletedPayload { + workflow_id: string; + duration_seconds: number; + result?: Record; +} + +export interface WorkflowFailedPayload { + workflow_id: string; + error_message: string; + failed_step?: string | null; + recoverable?: boolean; +} + +// ============================================================================ +// Type Guards +// ============================================================================ + +/** + * Type guard to check if an event is a specific type + */ +export function isEventType( + event: ProjectEvent, + type: T +): event is ProjectEvent & { type: T } { + return event.type === type; +} + +/** + * Type guard for agent events + */ +export function isAgentEvent(event: ProjectEvent): boolean { + return event.type.startsWith('agent.'); +} + +/** + * Type guard for issue events + */ +export function isIssueEvent(event: ProjectEvent): boolean { + return event.type.startsWith('issue.'); +} + +/** + * Type guard for sprint events + */ +export function isSprintEvent(event: ProjectEvent): boolean { + return event.type.startsWith('sprint.'); +} + +/** + * Type guard for approval events + */ +export function isApprovalEvent(event: ProjectEvent): boolean { + return event.type.startsWith('approval.'); +} + +/** + * Type guard for workflow events + */ +export function isWorkflowEvent(event: ProjectEvent): boolean { + return event.type.startsWith('workflow.'); +} + +/** + * Type guard for project events + */ +export function isProjectEvent(event: ProjectEvent): boolean { + return event.type.startsWith('project.'); +} + +// ============================================================================ +// SSE Connection Types +// ============================================================================ + +/** + * Connection state for SSE + */ +export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'; + +/** + * SSE error information + */ +export interface SSEError { + message: string; + code?: string; + timestamp: string; + retryAttempt?: number; +} diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts new file mode 100644 index 0000000..f8fd031 --- /dev/null +++ b/frontend/src/lib/types/index.ts @@ -0,0 +1,39 @@ +/** + * Type Definitions + * + * @module lib/types + */ + +// Event types for SSE +export { + EventType, + type ActorType, + type ProjectEvent, + type EventPayload, + type AgentSpawnedPayload, + type AgentStatusChangedPayload, + type AgentMessagePayload, + type AgentTerminatedPayload, + type IssueCreatedPayload, + type IssueUpdatedPayload, + type IssueAssignedPayload, + type IssueClosedPayload, + type SprintStartedPayload, + type SprintCompletedPayload, + type ApprovalRequestedPayload, + type ApprovalGrantedPayload, + type ApprovalDeniedPayload, + type WorkflowStartedPayload, + type WorkflowStepCompletedPayload, + type WorkflowCompletedPayload, + type WorkflowFailedPayload, + type ConnectionState, + type SSEError, + isEventType, + isAgentEvent, + isIssueEvent, + isSprintEvent, + isApprovalEvent, + isWorkflowEvent, + isProjectEvent, +} from './events'; diff --git a/frontend/tests/components/events/ConnectionStatus.test.tsx b/frontend/tests/components/events/ConnectionStatus.test.tsx new file mode 100644 index 0000000..9615e38 --- /dev/null +++ b/frontend/tests/components/events/ConnectionStatus.test.tsx @@ -0,0 +1,192 @@ +/** + * Tests for ConnectionStatus Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { ConnectionStatus } from '@/components/events/ConnectionStatus'; +import type { SSEError } from '@/lib/types/events'; + +describe('ConnectionStatus', () => { + const mockOnReconnect = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('connected state', () => { + it('renders connected status', () => { + render(); + + expect(screen.getByText('Connected')).toBeInTheDocument(); + expect(screen.getByText('Receiving real-time updates')).toBeInTheDocument(); + }); + + it('does not show reconnect button when connected', () => { + render(); + + expect(screen.queryByRole('button', { name: /reconnect/i })).not.toBeInTheDocument(); + }); + + it('applies connected styling', () => { + const { container } = render(); + + expect(container.querySelector('.border-green-200')).toBeInTheDocument(); + }); + }); + + describe('connecting state', () => { + it('renders connecting status', () => { + render(); + + expect(screen.getByText('Connecting')).toBeInTheDocument(); + expect(screen.getByText('Establishing connection...')).toBeInTheDocument(); + }); + + it('shows retry count when retrying', () => { + render(); + + expect(screen.getByText('Retry 3')).toBeInTheDocument(); + }); + }); + + describe('disconnected state', () => { + it('renders disconnected status', () => { + render(); + + expect(screen.getByText('Disconnected')).toBeInTheDocument(); + expect(screen.getByText('Not connected to server')).toBeInTheDocument(); + }); + + it('shows reconnect button when disconnected', () => { + render(); + + const button = screen.getByRole('button', { name: /reconnect/i }); + expect(button).toBeInTheDocument(); + }); + + it('calls onReconnect when button is clicked', () => { + render(); + + const button = screen.getByRole('button', { name: /reconnect/i }); + fireEvent.click(button); + + expect(mockOnReconnect).toHaveBeenCalledTimes(1); + }); + }); + + describe('error state', () => { + it('renders error status', () => { + render(); + + expect(screen.getByText('Connection Error')).toBeInTheDocument(); + expect(screen.getByText('Failed to connect')).toBeInTheDocument(); + }); + + it('shows reconnect button when in error state', () => { + render(); + + const button = screen.getByRole('button', { name: /reconnect/i }); + expect(button).toBeInTheDocument(); + }); + + it('applies error styling', () => { + const { container } = render(); + + expect(container.querySelector('.border-destructive')).toBeInTheDocument(); + }); + }); + + describe('error details', () => { + const mockError: SSEError = { + message: 'Connection timeout', + code: 'TIMEOUT', + timestamp: '2024-01-15T10:30:00Z', + retryAttempt: 2, + }; + + it('shows error message when error is provided', () => { + render(); + + expect(screen.getByText(/Error: Connection timeout/)).toBeInTheDocument(); + }); + + it('shows error code when provided', () => { + render(); + + expect(screen.getByText(/Code: TIMEOUT/)).toBeInTheDocument(); + }); + + it('hides error details when showErrorDetails is false', () => { + render(); + + expect(screen.queryByText(/Error: Connection timeout/)).not.toBeInTheDocument(); + }); + }); + + describe('compact mode', () => { + it('renders compact version', () => { + const { container } = render(); + + // Compact mode should not have the full description + expect(screen.queryByText('Receiving real-time updates')).not.toBeInTheDocument(); + // Should still show the label + expect(screen.getByText('Connected')).toBeInTheDocument(); + // Should use smaller container + expect(container.querySelector('.rounded-lg')).not.toBeInTheDocument(); + }); + + it('shows compact reconnect button when disconnected', () => { + render(); + + // Should have a small reconnect button + const button = screen.getByRole('button', { name: /reconnect/i }); + expect(button).toBeInTheDocument(); + expect(button.className).toContain('h-6'); + }); + + it('shows retry count in compact mode', () => { + render(); + + expect(screen.getByText(/retry 5/i)).toBeInTheDocument(); + }); + }); + + describe('showReconnectButton prop', () => { + it('hides reconnect button when showReconnectButton is false', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: /reconnect/i })).not.toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('has role="status" for screen readers', () => { + render(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('has aria-live="polite" for status updates', () => { + render(); + + const status = screen.getByRole('status'); + expect(status).toHaveAttribute('aria-live', 'polite'); + }); + }); + + describe('className prop', () => { + it('applies custom className', () => { + const { container } = render( + + ); + + expect(container.querySelector('.custom-class')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/tests/components/events/EventList.test.tsx b/frontend/tests/components/events/EventList.test.tsx new file mode 100644 index 0000000..313112a --- /dev/null +++ b/frontend/tests/components/events/EventList.test.tsx @@ -0,0 +1,378 @@ +/** + * Tests for EventList Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { EventList } from '@/components/events/EventList'; +import { EventType, type ProjectEvent } from '@/lib/types/events'; + +/** + * Helper to create mock event + */ +function createMockEvent(overrides: Partial = {}): ProjectEvent { + return { + id: `event-${Math.random().toString(36).substr(2, 9)}`, + type: EventType.AGENT_MESSAGE, + timestamp: new Date().toISOString(), + project_id: 'project-123', + actor_id: 'agent-456', + actor_type: 'agent', + payload: { message: 'Test message' }, + ...overrides, + }; +} + +describe('EventList', () => { + const mockOnEventClick = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('empty state', () => { + it('shows empty message when no events', () => { + render(); + + expect(screen.getByText('No events yet')).toBeInTheDocument(); + }); + + it('shows custom empty message', () => { + render(); + + expect(screen.getByText('Waiting for activity...')).toBeInTheDocument(); + }); + }); + + describe('header', () => { + it('shows header by default', () => { + render(); + + expect(screen.getByText('Activity Feed')).toBeInTheDocument(); + }); + + it('shows custom title', () => { + render(); + + expect(screen.getByText('Project Events')).toBeInTheDocument(); + }); + + it('hides header when showHeader is false', () => { + render(); + + expect(screen.queryByText('Activity Feed')).not.toBeInTheDocument(); + }); + + it('shows event count in header', () => { + const events = [ + createMockEvent({ id: 'event-1' }), + createMockEvent({ id: 'event-2' }), + createMockEvent({ id: 'event-3' }), + ]; + + render(); + + expect(screen.getByText('3 events')).toBeInTheDocument(); + }); + + it('shows singular "event" for one event', () => { + const events = [createMockEvent()]; + + render(); + + expect(screen.getByText('1 event')).toBeInTheDocument(); + }); + }); + + describe('event display', () => { + it('displays agent events correctly', () => { + const events = [ + createMockEvent({ + type: EventType.AGENT_MESSAGE, + payload: { message: 'Processing task...' }, + }), + ]; + + render(); + + expect(screen.getByText('Agent Message')).toBeInTheDocument(); + expect(screen.getByText('Processing task...')).toBeInTheDocument(); + }); + + it('displays issue events correctly', () => { + const events = [ + createMockEvent({ + type: EventType.ISSUE_CREATED, + payload: { title: 'Fix login bug' }, + }), + ]; + + render(); + + expect(screen.getByText('Issue Created')).toBeInTheDocument(); + expect(screen.getByText('Fix login bug')).toBeInTheDocument(); + }); + + it('displays sprint events correctly', () => { + const events = [ + createMockEvent({ + type: EventType.SPRINT_STARTED, + payload: { sprint_name: 'Sprint 1' }, + }), + ]; + + render(); + + expect(screen.getByText('Sprint Started')).toBeInTheDocument(); + expect(screen.getByText(/Sprint "Sprint 1" started/)).toBeInTheDocument(); + }); + + it('displays approval events correctly', () => { + const events = [ + createMockEvent({ + type: EventType.APPROVAL_REQUESTED, + payload: { description: 'Need approval for deployment' }, + }), + ]; + + render(); + + expect(screen.getByText('Approval Requested')).toBeInTheDocument(); + expect(screen.getByText('Need approval for deployment')).toBeInTheDocument(); + }); + + it('displays workflow events correctly', () => { + const events = [ + createMockEvent({ + type: EventType.WORKFLOW_COMPLETED, + payload: { duration_seconds: 120 }, + }), + ]; + + render(); + + expect(screen.getByText('Workflow Completed')).toBeInTheDocument(); + expect(screen.getByText('Completed in 120s')).toBeInTheDocument(); + }); + + it('displays actor type', () => { + const events = [ + createMockEvent({ actor_type: 'agent' }), + createMockEvent({ actor_type: 'user', id: 'event-2' }), + createMockEvent({ actor_type: 'system', id: 'event-3' }), + ]; + + render(); + + expect(screen.getByText('Agent')).toBeInTheDocument(); + expect(screen.getByText('User')).toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + }); + }); + + describe('event sorting', () => { + it('sorts events by timestamp, newest first', () => { + const events = [ + createMockEvent({ + id: 'older', + timestamp: '2024-01-01T10:00:00Z', + payload: { message: 'Older event' }, + }), + createMockEvent({ + id: 'newer', + timestamp: '2024-01-01T12:00:00Z', + payload: { message: 'Newer event' }, + }), + ]; + + render(); + + const eventTexts = screen.getAllByText(/event$/i); + // The "Newer event" should appear first in the DOM + const newerIndex = eventTexts.findIndex((el) => + el.closest('[class*="flex gap-3"]')?.textContent?.includes('Newer') + ); + const olderIndex = eventTexts.findIndex((el) => + el.closest('[class*="flex gap-3"]')?.textContent?.includes('Older') + ); + + // In a sorted list, newer should have lower index + expect(newerIndex).toBeLessThan(olderIndex); + }); + }); + + describe('payload expansion', () => { + it('shows expand button when showPayloads is true', () => { + const events = [createMockEvent()]; + + render(); + + // Should have a chevron button + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('expands payload on click', async () => { + const events = [ + createMockEvent({ + payload: { custom_field: 'custom_value' }, + }), + ]; + + render(); + + const eventItem = screen.getByText('Agent Message').closest('[class*="flex gap-3"]'); + expect(eventItem).toBeInTheDocument(); + + fireEvent.click(eventItem!); + + // Should show the JSON payload + expect(screen.getByText(/"custom_field"/)).toBeInTheDocument(); + expect(screen.getByText(/"custom_value"/)).toBeInTheDocument(); + }); + }); + + describe('event click', () => { + it('calls onEventClick when event is clicked', () => { + const events = [createMockEvent({ id: 'test-event' })]; + + render(); + + const eventItem = screen.getByText('Agent Message').closest('[class*="flex gap-3"]'); + fireEvent.click(eventItem!); + + expect(mockOnEventClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'test-event' })); + }); + + it('makes event item focusable when clickable', () => { + const events = [createMockEvent()]; + + render(); + + const eventItem = screen.getByText('Agent Message').closest('[class*="flex gap-3"]'); + expect(eventItem).toHaveAttribute('tabIndex', '0'); + }); + + it('handles keyboard activation', () => { + const events = [createMockEvent({ id: 'keyboard-event' })]; + + render(); + + const eventItem = screen.getByText('Agent Message').closest('[class*="flex gap-3"]'); + fireEvent.keyDown(eventItem!, { key: 'Enter' }); + + expect(mockOnEventClick).toHaveBeenCalledWith( + expect.objectContaining({ id: 'keyboard-event' }) + ); + }); + + it('handles space key activation', () => { + const events = [createMockEvent({ id: 'space-event' })]; + + render(); + + const eventItem = screen.getByText('Agent Message').closest('[class*="flex gap-3"]'); + fireEvent.keyDown(eventItem!, { key: ' ' }); + + expect(mockOnEventClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'space-event' })); + }); + }); + + describe('scrolling', () => { + it('applies maxHeight style', () => { + const events = [createMockEvent()]; + + const { container } = render(); + + const scrollContainer = container.querySelector('.overflow-y-auto'); + expect(scrollContainer).toHaveStyle({ maxHeight: '300px' }); + }); + + it('accepts string maxHeight', () => { + const events = [createMockEvent()]; + + const { container } = render(); + + const scrollContainer = container.querySelector('.overflow-y-auto'); + expect(scrollContainer).toHaveStyle({ maxHeight: '50vh' }); + }); + }); + + describe('className prop', () => { + it('applies custom className', () => { + const { container } = render(); + + expect(container.querySelector('.custom-event-list')).toBeInTheDocument(); + }); + }); + + describe('different event types', () => { + it('handles agent spawned event', () => { + const events = [ + createMockEvent({ + type: EventType.AGENT_SPAWNED, + payload: { agent_name: 'Product Owner', role: 'po' }, + }), + ]; + + render(); + + expect(screen.getByText('Agent Spawned')).toBeInTheDocument(); + expect(screen.getByText(/Product Owner spawned as po/)).toBeInTheDocument(); + }); + + it('handles agent terminated event', () => { + const events = [ + createMockEvent({ + type: EventType.AGENT_TERMINATED, + payload: { termination_reason: 'Task completed' }, + }), + ]; + + render(); + + expect(screen.getByText('Agent Terminated')).toBeInTheDocument(); + expect(screen.getByText('Task completed')).toBeInTheDocument(); + }); + + it('handles workflow failed event', () => { + const events = [ + createMockEvent({ + type: EventType.WORKFLOW_FAILED, + payload: { error_message: 'Build failed' }, + }), + ]; + + render(); + + expect(screen.getByText('Workflow Failed')).toBeInTheDocument(); + expect(screen.getByText('Build failed')).toBeInTheDocument(); + }); + + it('handles workflow step completed event', () => { + const events = [ + createMockEvent({ + type: EventType.WORKFLOW_STEP_COMPLETED, + payload: { step_name: 'Build', step_number: 2, total_steps: 5 }, + }), + ]; + + render(); + + expect(screen.getByText('Step Completed')).toBeInTheDocument(); + expect(screen.getByText(/Step 2\/5: Build/)).toBeInTheDocument(); + }); + + it('handles approval denied event', () => { + const events = [ + createMockEvent({ + type: EventType.APPROVAL_DENIED, + payload: { reason: 'Security review needed' }, + }), + ]; + + render(); + + expect(screen.getByText('Approval Denied')).toBeInTheDocument(); + expect(screen.getByText(/Denied: Security review needed/)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/tests/lib/hooks/useProjectEvents.test.ts b/frontend/tests/lib/hooks/useProjectEvents.test.ts new file mode 100644 index 0000000..42d97e6 --- /dev/null +++ b/frontend/tests/lib/hooks/useProjectEvents.test.ts @@ -0,0 +1,475 @@ +/** + * Tests for useProjectEvents Hook + */ + +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useProjectEvents } from '@/lib/hooks/useProjectEvents'; +import { useEventStore } from '@/lib/stores/eventStore'; +import { EventType, type ProjectEvent } from '@/lib/types/events'; + +// Mock useAuth +const mockUseAuth = jest.fn(); + +jest.mock('@/lib/auth/AuthContext', () => ({ + useAuth: (selector?: (state: unknown) => unknown) => { + const state = mockUseAuth(); + return selector ? selector(state) : state; + }, +})); + +// Mock config +jest.mock('@/config/app.config', () => ({ + __esModule: true, + default: { + api: { + url: 'http://localhost:8000', + }, + debug: { + api: false, + }, + }, +})); + +// Mock EventSource +class MockEventSource { + static instances: MockEventSource[] = []; + url: string; + readyState: number = 0; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(url: string) { + this.url = url; + MockEventSource.instances.push(this); + } + + close() { + this.readyState = 2; // CLOSED + } + + addEventListener() {} + removeEventListener() {} + + // Test helpers + simulateOpen() { + this.readyState = 1; // OPEN + if (this.onopen) { + this.onopen(new Event('open')); + } + } + + simulateMessage(data: string) { + if (this.onmessage) { + this.onmessage(new MessageEvent('message', { data })); + } + } + + simulateError() { + this.readyState = 2; // CLOSED + if (this.onerror) { + this.onerror(new Event('error')); + } + } +} + +// Type augmentation for EventSource +declare global { + interface Window { + EventSource: typeof MockEventSource; + } +} + +// @ts-expect-error - Mocking global EventSource +global.EventSource = MockEventSource; + +/** + * Helper to create mock event + */ +function createMockEvent(overrides: Partial = {}): ProjectEvent { + return { + id: `event-${Math.random().toString(36).substr(2, 9)}`, + type: EventType.AGENT_MESSAGE, + timestamp: new Date().toISOString(), + project_id: 'project-123', + actor_id: 'agent-456', + actor_type: 'agent', + payload: { message: 'Test message' }, + ...overrides, + }; +} + +describe('useProjectEvents', () => { + beforeEach(() => { + jest.clearAllMocks(); + MockEventSource.instances = []; + + // Reset event store + useEventStore.setState({ + eventsByProject: {}, + maxEvents: 100, + }); + + // Default auth state - authenticated + mockUseAuth.mockReturnValue({ + isAuthenticated: true, + accessToken: 'test-access-token', + }); + }); + + describe('initialization', () => { + it('should start disconnected', () => { + const { result } = renderHook(() => + useProjectEvents('project-123', { autoConnect: false }) + ); + + expect(result.current.connectionState).toBe('disconnected'); + expect(result.current.isConnected).toBe(false); + expect(result.current.events).toEqual([]); + }); + + it('should auto-connect when enabled and authenticated', async () => { + renderHook(() => useProjectEvents('project-123')); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const eventSource = MockEventSource.instances[0]; + expect(eventSource.url).toContain('/api/v1/projects/project-123/events'); + expect(eventSource.url).toContain('token=test-access-token'); + }); + + it('should not connect when not authenticated', () => { + mockUseAuth.mockReturnValue({ + isAuthenticated: false, + accessToken: null, + }); + + renderHook(() => useProjectEvents('project-123')); + + expect(MockEventSource.instances).toHaveLength(0); + }); + + it('should not connect when no project ID', () => { + renderHook(() => useProjectEvents('')); + + expect(MockEventSource.instances).toHaveLength(0); + }); + }); + + describe('connection state', () => { + it('should update to connected on open', async () => { + const { result } = renderHook(() => useProjectEvents('project-123')); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const eventSource = MockEventSource.instances[0]; + + act(() => { + eventSource.simulateOpen(); + }); + + await waitFor(() => { + expect(result.current.connectionState).toBe('connected'); + expect(result.current.isConnected).toBe(true); + }); + }); + + it('should update to error on connection error', async () => { + const { result } = renderHook(() => + useProjectEvents('project-123', { + maxRetryAttempts: 1, + }) + ); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const eventSource = MockEventSource.instances[0]; + + act(() => { + eventSource.simulateError(); + }); + + await waitFor(() => { + expect(result.current.error).not.toBeNull(); + }); + }); + + it('should call onConnectionChange callback', async () => { + const onConnectionChange = jest.fn(); + + renderHook(() => + useProjectEvents('project-123', { + onConnectionChange, + }) + ); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + // Should have been called with 'connecting' + expect(onConnectionChange).toHaveBeenCalledWith('connecting'); + + const eventSource = MockEventSource.instances[0]; + + act(() => { + eventSource.simulateOpen(); + }); + + await waitFor(() => { + expect(onConnectionChange).toHaveBeenCalledWith('connected'); + }); + }); + }); + + describe('event handling', () => { + it('should add events to store on message', async () => { + const { result } = renderHook(() => useProjectEvents('project-123')); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const eventSource = MockEventSource.instances[0]; + + act(() => { + eventSource.simulateOpen(); + }); + + const mockEvent = createMockEvent(); + + act(() => { + eventSource.simulateMessage(JSON.stringify(mockEvent)); + }); + + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + expect(result.current.events[0].id).toBe(mockEvent.id); + }); + }); + + it('should call onEvent callback', async () => { + const onEvent = jest.fn(); + + renderHook(() => + useProjectEvents('project-123', { + onEvent, + }) + ); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const eventSource = MockEventSource.instances[0]; + + act(() => { + eventSource.simulateOpen(); + }); + + const mockEvent = createMockEvent(); + + act(() => { + eventSource.simulateMessage(JSON.stringify(mockEvent)); + }); + + await waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ id: mockEvent.id })); + }); + }); + + it('should ignore invalid JSON', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const { result } = renderHook(() => useProjectEvents('project-123')); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const eventSource = MockEventSource.instances[0]; + + act(() => { + eventSource.simulateOpen(); + eventSource.simulateMessage('not valid json'); + }); + + expect(result.current.events).toHaveLength(0); + expect(consoleWarnSpy).toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + + it('should ignore events with missing required fields', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const { result } = renderHook(() => useProjectEvents('project-123')); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const eventSource = MockEventSource.instances[0]; + + act(() => { + eventSource.simulateOpen(); + eventSource.simulateMessage(JSON.stringify({ message: 'missing fields' })); + }); + + expect(result.current.events).toHaveLength(0); + expect(consoleWarnSpy).toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('disconnect and reconnect', () => { + it('should disconnect when called', async () => { + const { result } = renderHook(() => useProjectEvents('project-123')); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const initialInstanceCount = MockEventSource.instances.length; + const eventSource = MockEventSource.instances[initialInstanceCount - 1]; + + act(() => { + eventSource.simulateOpen(); + }); + + await waitFor(() => { + expect(result.current.isConnected).toBe(true); + }); + + act(() => { + result.current.disconnect(); + }); + + // After disconnect, the connection state should be disconnected + expect(result.current.connectionState).toBe('disconnected'); + expect(result.current.isConnected).toBe(false); + }); + + it('should reconnect when called', async () => { + const { result } = renderHook(() => useProjectEvents('project-123')); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + act(() => { + result.current.disconnect(); + }); + + const instanceCountBeforeReconnect = MockEventSource.instances.length; + + act(() => { + result.current.reconnect(); + }); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(instanceCountBeforeReconnect); + }); + }); + }); + + describe('clearEvents', () => { + it('should clear events for the project', async () => { + const { result } = renderHook(() => useProjectEvents('project-123')); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const eventSource = MockEventSource.instances[0]; + + act(() => { + eventSource.simulateOpen(); + eventSource.simulateMessage(JSON.stringify(createMockEvent())); + }); + + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); + + act(() => { + result.current.clearEvents(); + }); + + expect(result.current.events).toHaveLength(0); + }); + }); + + describe('retry behavior', () => { + it('should increment retry count on error', async () => { + const { result } = renderHook(() => + useProjectEvents('project-123', { + maxRetryAttempts: 5, + initialRetryDelay: 10, // Short delay for test + }) + ); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const eventSource = MockEventSource.instances[0]; + + act(() => { + eventSource.simulateError(); + }); + + await waitFor(() => { + expect(result.current.retryCount).toBeGreaterThanOrEqual(0); + }); + }); + + it('should call onError callback', async () => { + const onError = jest.fn(); + + renderHook(() => + useProjectEvents('project-123', { + onError, + maxRetryAttempts: 1, + }) + ); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const eventSource = MockEventSource.instances[0]; + + act(() => { + eventSource.simulateError(); + }); + + await waitFor(() => { + expect(onError).toHaveBeenCalled(); + }); + }); + }); + + describe('cleanup', () => { + it('should close connection on unmount', async () => { + const { unmount } = renderHook(() => useProjectEvents('project-123')); + + await waitFor(() => { + expect(MockEventSource.instances.length).toBeGreaterThan(0); + }); + + const eventSource = MockEventSource.instances[0]; + + unmount(); + + expect(eventSource.readyState).toBe(2); // CLOSED + }); + }); +}); diff --git a/frontend/tests/lib/stores/eventStore.test.ts b/frontend/tests/lib/stores/eventStore.test.ts new file mode 100644 index 0000000..31ce7fb --- /dev/null +++ b/frontend/tests/lib/stores/eventStore.test.ts @@ -0,0 +1,255 @@ +/** + * Tests for Event Store + */ + +import { useEventStore } from '@/lib/stores/eventStore'; +import { EventType, type ProjectEvent } from '@/lib/types/events'; + +/** + * Helper to create mock event + */ +function createMockEvent(overrides: Partial = {}): ProjectEvent { + return { + id: `event-${Math.random().toString(36).substr(2, 9)}`, + type: EventType.AGENT_MESSAGE, + timestamp: new Date().toISOString(), + project_id: 'project-123', + actor_id: 'agent-456', + actor_type: 'agent', + payload: { message: 'Test message' }, + ...overrides, + }; +} + +describe('Event Store', () => { + beforeEach(() => { + // Reset store state + useEventStore.setState({ + eventsByProject: {}, + maxEvents: 100, + }); + }); + + describe('addEvent', () => { + it('should add an event to the store', () => { + const event = createMockEvent(); + + useEventStore.getState().addEvent(event); + + const events = useEventStore.getState().getProjectEvents('project-123'); + expect(events).toHaveLength(1); + expect(events[0]).toEqual(event); + }); + + it('should add events to correct project', () => { + const event1 = createMockEvent({ project_id: 'project-1' }); + const event2 = createMockEvent({ project_id: 'project-2' }); + + useEventStore.getState().addEvent(event1); + useEventStore.getState().addEvent(event2); + + expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(1); + expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(1); + }); + + it('should skip duplicate event IDs', () => { + const event = createMockEvent({ id: 'unique-id' }); + + useEventStore.getState().addEvent(event); + useEventStore.getState().addEvent(event); + + const events = useEventStore.getState().getProjectEvents('project-123'); + expect(events).toHaveLength(1); + }); + + it('should trim events to maxEvents limit', () => { + useEventStore.getState().setMaxEvents(5); + + // Add 10 events + for (let i = 0; i < 10; i++) { + useEventStore.getState().addEvent(createMockEvent({ id: `event-${i}` })); + } + + const events = useEventStore.getState().getProjectEvents('project-123'); + expect(events).toHaveLength(5); + // Should keep the last 5 events + expect(events[0].id).toBe('event-5'); + expect(events[4].id).toBe('event-9'); + }); + }); + + describe('addEvents', () => { + it('should add multiple events at once', () => { + const events = [ + createMockEvent({ id: 'event-1' }), + createMockEvent({ id: 'event-2' }), + createMockEvent({ id: 'event-3' }), + ]; + + useEventStore.getState().addEvents(events); + + const storedEvents = useEventStore.getState().getProjectEvents('project-123'); + expect(storedEvents).toHaveLength(3); + }); + + it('should handle empty array', () => { + useEventStore.getState().addEvents([]); + + const events = useEventStore.getState().getProjectEvents('project-123'); + expect(events).toHaveLength(0); + }); + + it('should filter out duplicate events', () => { + const existingEvent = createMockEvent({ id: 'existing-event' }); + useEventStore.getState().addEvent(existingEvent); + + const newEvents = [ + createMockEvent({ id: 'existing-event' }), // Duplicate + createMockEvent({ id: 'new-event-1' }), + createMockEvent({ id: 'new-event-2' }), + ]; + + useEventStore.getState().addEvents(newEvents); + + const storedEvents = useEventStore.getState().getProjectEvents('project-123'); + expect(storedEvents).toHaveLength(3); + }); + + it('should add events to multiple projects', () => { + const events = [ + createMockEvent({ id: 'event-1', project_id: 'project-1' }), + createMockEvent({ id: 'event-2', project_id: 'project-2' }), + createMockEvent({ id: 'event-3', project_id: 'project-1' }), + ]; + + useEventStore.getState().addEvents(events); + + expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(2); + expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(1); + }); + }); + + describe('clearProjectEvents', () => { + it('should clear events for a specific project', () => { + const event1 = createMockEvent({ project_id: 'project-1' }); + const event2 = createMockEvent({ project_id: 'project-2' }); + + useEventStore.getState().addEvent(event1); + useEventStore.getState().addEvent(event2); + + useEventStore.getState().clearProjectEvents('project-1'); + + expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(0); + expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(1); + }); + + it('should handle clearing non-existent project', () => { + expect(() => { + useEventStore.getState().clearProjectEvents('non-existent'); + }).not.toThrow(); + }); + }); + + describe('clearAllEvents', () => { + it('should clear all events from all projects', () => { + const event1 = createMockEvent({ project_id: 'project-1' }); + const event2 = createMockEvent({ project_id: 'project-2' }); + + useEventStore.getState().addEvent(event1); + useEventStore.getState().addEvent(event2); + + useEventStore.getState().clearAllEvents(); + + expect(useEventStore.getState().getProjectEvents('project-1')).toHaveLength(0); + expect(useEventStore.getState().getProjectEvents('project-2')).toHaveLength(0); + }); + }); + + describe('getProjectEvents', () => { + it('should return empty array for non-existent project', () => { + const events = useEventStore.getState().getProjectEvents('non-existent'); + expect(events).toEqual([]); + }); + + it('should return events for existing project', () => { + const event = createMockEvent(); + useEventStore.getState().addEvent(event); + + const events = useEventStore.getState().getProjectEvents('project-123'); + expect(events).toHaveLength(1); + expect(events[0]).toEqual(event); + }); + }); + + describe('getFilteredEvents', () => { + it('should filter events by type', () => { + const agentEvent = createMockEvent({ type: EventType.AGENT_MESSAGE }); + const issueEvent = createMockEvent({ type: EventType.ISSUE_CREATED }); + const sprintEvent = createMockEvent({ type: EventType.SPRINT_STARTED }); + + useEventStore.getState().addEvents([agentEvent, issueEvent, sprintEvent]); + + const filtered = useEventStore.getState().getFilteredEvents('project-123', [ + EventType.AGENT_MESSAGE, + EventType.ISSUE_CREATED, + ]); + + expect(filtered).toHaveLength(2); + expect(filtered.map((e) => e.type)).toContain(EventType.AGENT_MESSAGE); + expect(filtered.map((e) => e.type)).toContain(EventType.ISSUE_CREATED); + }); + + it('should return all events when types array is empty', () => { + const event1 = createMockEvent({ type: EventType.AGENT_MESSAGE }); + const event2 = createMockEvent({ type: EventType.ISSUE_CREATED }); + + useEventStore.getState().addEvents([event1, event2]); + + const filtered = useEventStore.getState().getFilteredEvents('project-123', []); + expect(filtered).toHaveLength(2); + }); + + it('should return empty array for non-existent project', () => { + const filtered = useEventStore.getState().getFilteredEvents('non-existent', [ + EventType.AGENT_MESSAGE, + ]); + expect(filtered).toEqual([]); + }); + }); + + describe('setMaxEvents', () => { + it('should update maxEvents setting', () => { + useEventStore.getState().setMaxEvents(50); + expect(useEventStore.getState().maxEvents).toBe(50); + }); + + it('should use default for invalid values', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + useEventStore.getState().setMaxEvents(0); + expect(useEventStore.getState().maxEvents).toBe(100); // Default + + useEventStore.getState().setMaxEvents(-5); + expect(useEventStore.getState().maxEvents).toBe(100); // Default + + expect(consoleWarnSpy).toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); + + it('should trim existing events when reducing maxEvents', () => { + // Add 10 events + for (let i = 0; i < 10; i++) { + useEventStore.getState().addEvent(createMockEvent({ id: `event-${i}` })); + } + + expect(useEventStore.getState().getProjectEvents('project-123')).toHaveLength(10); + + useEventStore.getState().setMaxEvents(5); + + const events = useEventStore.getState().getProjectEvents('project-123'); + expect(events).toHaveLength(5); + // Should keep the last 5 events + expect(events[0].id).toBe('event-5'); + }); + }); +}); diff --git a/frontend/tests/lib/types/events.test.ts b/frontend/tests/lib/types/events.test.ts new file mode 100644 index 0000000..defb8f5 --- /dev/null +++ b/frontend/tests/lib/types/events.test.ts @@ -0,0 +1,167 @@ +/** + * Tests for Event Types and Type Guards + */ + +import { + EventType, + type ProjectEvent, + isEventType, + isAgentEvent, + isIssueEvent, + isSprintEvent, + isApprovalEvent, + isWorkflowEvent, + isProjectEvent, +} from '@/lib/types/events'; + +/** + * Helper to create mock event + */ +function createMockEvent(overrides: Partial = {}): ProjectEvent { + return { + id: 'event-123', + type: EventType.AGENT_MESSAGE, + timestamp: new Date().toISOString(), + project_id: 'project-123', + actor_id: 'agent-456', + actor_type: 'agent', + payload: {}, + ...overrides, + }; +} + +describe('Event Types', () => { + describe('EventType enum', () => { + it('should have correct agent event values', () => { + expect(EventType.AGENT_SPAWNED).toBe('agent.spawned'); + expect(EventType.AGENT_STATUS_CHANGED).toBe('agent.status_changed'); + expect(EventType.AGENT_MESSAGE).toBe('agent.message'); + expect(EventType.AGENT_TERMINATED).toBe('agent.terminated'); + }); + + it('should have correct issue event values', () => { + expect(EventType.ISSUE_CREATED).toBe('issue.created'); + expect(EventType.ISSUE_UPDATED).toBe('issue.updated'); + expect(EventType.ISSUE_ASSIGNED).toBe('issue.assigned'); + expect(EventType.ISSUE_CLOSED).toBe('issue.closed'); + }); + + it('should have correct sprint event values', () => { + expect(EventType.SPRINT_STARTED).toBe('sprint.started'); + expect(EventType.SPRINT_COMPLETED).toBe('sprint.completed'); + }); + + it('should have correct approval event values', () => { + expect(EventType.APPROVAL_REQUESTED).toBe('approval.requested'); + expect(EventType.APPROVAL_GRANTED).toBe('approval.granted'); + expect(EventType.APPROVAL_DENIED).toBe('approval.denied'); + }); + + it('should have correct workflow event values', () => { + expect(EventType.WORKFLOW_STARTED).toBe('workflow.started'); + expect(EventType.WORKFLOW_STEP_COMPLETED).toBe('workflow.step_completed'); + expect(EventType.WORKFLOW_COMPLETED).toBe('workflow.completed'); + expect(EventType.WORKFLOW_FAILED).toBe('workflow.failed'); + }); + + it('should have correct project event values', () => { + expect(EventType.PROJECT_CREATED).toBe('project.created'); + expect(EventType.PROJECT_UPDATED).toBe('project.updated'); + expect(EventType.PROJECT_ARCHIVED).toBe('project.archived'); + }); + }); + + describe('isEventType', () => { + it('should return true for matching event type', () => { + const event = createMockEvent({ type: EventType.AGENT_MESSAGE }); + expect(isEventType(event, EventType.AGENT_MESSAGE)).toBe(true); + }); + + it('should return false for non-matching event type', () => { + const event = createMockEvent({ type: EventType.AGENT_MESSAGE }); + expect(isEventType(event, EventType.ISSUE_CREATED)).toBe(false); + }); + }); + + describe('isAgentEvent', () => { + it('should return true for agent events', () => { + expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_SPAWNED }))).toBe(true); + expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(true); + expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_STATUS_CHANGED }))).toBe(true); + expect(isAgentEvent(createMockEvent({ type: EventType.AGENT_TERMINATED }))).toBe(true); + }); + + it('should return false for non-agent events', () => { + expect(isAgentEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(false); + expect(isAgentEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(false); + }); + }); + + describe('isIssueEvent', () => { + it('should return true for issue events', () => { + expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(true); + expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_UPDATED }))).toBe(true); + expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_ASSIGNED }))).toBe(true); + expect(isIssueEvent(createMockEvent({ type: EventType.ISSUE_CLOSED }))).toBe(true); + }); + + it('should return false for non-issue events', () => { + expect(isIssueEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false); + expect(isIssueEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(false); + }); + }); + + describe('isSprintEvent', () => { + it('should return true for sprint events', () => { + expect(isSprintEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(true); + expect(isSprintEvent(createMockEvent({ type: EventType.SPRINT_COMPLETED }))).toBe(true); + }); + + it('should return false for non-sprint events', () => { + expect(isSprintEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false); + expect(isSprintEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(false); + }); + }); + + describe('isApprovalEvent', () => { + it('should return true for approval events', () => { + expect(isApprovalEvent(createMockEvent({ type: EventType.APPROVAL_REQUESTED }))).toBe(true); + expect(isApprovalEvent(createMockEvent({ type: EventType.APPROVAL_GRANTED }))).toBe(true); + expect(isApprovalEvent(createMockEvent({ type: EventType.APPROVAL_DENIED }))).toBe(true); + }); + + it('should return false for non-approval events', () => { + expect(isApprovalEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false); + expect(isApprovalEvent(createMockEvent({ type: EventType.WORKFLOW_STARTED }))).toBe(false); + }); + }); + + describe('isWorkflowEvent', () => { + it('should return true for workflow events', () => { + expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_STARTED }))).toBe(true); + expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_STEP_COMPLETED }))).toBe( + true + ); + expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_COMPLETED }))).toBe(true); + expect(isWorkflowEvent(createMockEvent({ type: EventType.WORKFLOW_FAILED }))).toBe(true); + }); + + it('should return false for non-workflow events', () => { + expect(isWorkflowEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false); + expect(isWorkflowEvent(createMockEvent({ type: EventType.SPRINT_STARTED }))).toBe(false); + }); + }); + + describe('isProjectEvent', () => { + it('should return true for project events', () => { + expect(isProjectEvent(createMockEvent({ type: EventType.PROJECT_CREATED }))).toBe(true); + expect(isProjectEvent(createMockEvent({ type: EventType.PROJECT_UPDATED }))).toBe(true); + expect(isProjectEvent(createMockEvent({ type: EventType.PROJECT_ARCHIVED }))).toBe(true); + }); + + it('should return false for non-project events', () => { + expect(isProjectEvent(createMockEvent({ type: EventType.AGENT_MESSAGE }))).toBe(false); + expect(isProjectEvent(createMockEvent({ type: EventType.ISSUE_CREATED }))).toBe(false); + }); + }); +});