feat(frontend): Implement client-side SSE handling (#35)

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 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 01:34:41 +01:00
parent d6db6af964
commit fcda8f0f96
14 changed files with 3138 additions and 0 deletions

View File

@@ -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
* <ConnectionStatus
* state={connectionState}
* error={error}
* retryCount={retryCount}
* onReconnect={reconnect}
* />
* ```
*/
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 (
<div className={cn('flex items-center gap-2', className)}>
<Icon className={cn('h-4 w-4', iconClassName)} aria-hidden="true" />
<Badge variant={variant} className="text-xs">
{label}
{isRetrying && ` (retry ${retryCount})`}
</Badge>
{showReconnectButton && canReconnect && onReconnect && (
<Button
variant="ghost"
size="sm"
onClick={onReconnect}
className="h-6 w-6 p-0"
aria-label="Reconnect"
>
<RefreshCw className="h-3 w-3" />
</Button>
)}
</div>
);
}
return (
<div
className={cn(
'flex flex-col gap-3 rounded-lg border p-4',
state === 'error' && 'border-destructive bg-destructive/5',
state === 'connected' && 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950',
className
)}
role="status"
aria-live="polite"
>
{/* Header with icon and status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full',
state === 'connected' && 'bg-green-100 dark:bg-green-900',
state === 'connecting' && 'bg-muted',
state === 'disconnected' && 'bg-muted',
state === 'error' && 'bg-destructive/10'
)}
>
<Icon className={cn('h-5 w-5', iconClassName)} aria-hidden="true" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{label}</span>
{isRetrying && (
<Badge variant="secondary" className="text-xs">
Retry {retryCount}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
{/* Reconnect button */}
{showReconnectButton && canReconnect && onReconnect && (
<Button variant="outline" size="sm" onClick={onReconnect} className="gap-2">
<RefreshCw className="h-4 w-4" />
Reconnect
</Button>
)}
</div>
{/* Error details */}
{showErrorDetails && error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm">
<p className="font-medium text-destructive">Error: {error.message}</p>
{error.code && (
<p className="mt-1 text-muted-foreground">
Code: {error.code}
</p>
)}
<p className="mt-1 text-xs text-muted-foreground">
{new Date(error.timestamp).toLocaleTimeString()}
</p>
</div>
)}
</div>
);
}

View File

@@ -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<string, unknown>;
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 (
<div
className={cn(
'flex gap-3 border-b border-border/50 p-3 last:border-b-0',
(showPayload || onClick) && 'cursor-pointer hover:bg-muted/50 transition-colors'
)}
onClick={handleClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={
onClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}
: undefined
}
>
{/* Icon */}
<div
className={cn(
'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full',
config.bgColor
)}
>
<Icon className={cn('h-4 w-4', config.color)} aria-hidden="true" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{config.label}
</Badge>
<span className="text-xs text-muted-foreground">{actor}</span>
</div>
<p className="mt-1 text-sm truncate">{summary}</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<span className="text-xs text-muted-foreground whitespace-nowrap">{timestamp}</span>
{showPayload && (
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
{/* Expanded payload */}
{showPayload && isExpanded && (
<div className="mt-2 rounded-md bg-muted/50 p-2">
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words">
{JSON.stringify(event.payload, null, 2)}
</pre>
</div>
)}
</div>
</div>
);
}
// ============================================================================
// 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
* <EventList
* events={events}
* maxHeight={500}
* showPayloads
* onEventClick={(e) => 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 (
<Card className={className}>
{showHeader && (
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-base">
<span>{title}</span>
{events.length > 0 && (
<Badge variant="secondary" className="text-xs">
{events.length} event{events.length !== 1 ? 's' : ''}
</Badge>
)}
</CardTitle>
</CardHeader>
)}
<CardContent className={showHeader ? 'pt-0' : ''}>
{sortedEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<FileText className="h-8 w-8 mb-2" />
<p className="text-sm">{emptyMessage}</p>
</div>
) : (
<div
className="overflow-y-auto"
style={{ maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight }}
>
<div className="divide-y divide-border/50">
{sortedEvents.map((event) => (
<EventItem
key={event.id}
event={event}
showPayload={showPayloads}
onClick={onEventClick}
/>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -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';

View File

@@ -0,0 +1,7 @@
/**
* Custom React Hooks
*
* @module lib/hooks
*/
export { useProjectEvents, type UseProjectEventsOptions, type UseProjectEventsResult } from './useProjectEvents';

View File

@@ -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 <ConnectionStatus state={connectionState} onReconnect={reconnect} />;
* }
*
* return <EventList events={events} />;
* ```
*/
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<ConnectionState>('disconnected');
const [error, setError] = useState<SSEError | null>(null);
const [retryCount, setRetryCount] = useState(0);
// Refs for cleanup and reconnection logic
const eventSourceRef = useRef<EventSource | null>(null);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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,
};
}

View File

@@ -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<string, ProjectEvent[]>;
/** 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<EventStore>((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<string, ProjectEvent[]>
);
// 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<string, ProjectEvent[]> = {};
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);
}

View File

@@ -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';

View File

@@ -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<string, unknown>;
// 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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
}
export interface WorkflowCompletedPayload {
workflow_id: string;
duration_seconds: number;
result?: Record<string, unknown>;
}
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<T extends EventType>(
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;
}

View File

@@ -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';

View File

@@ -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(<ConnectionStatus state="connected" />);
expect(screen.getByText('Connected')).toBeInTheDocument();
expect(screen.getByText('Receiving real-time updates')).toBeInTheDocument();
});
it('does not show reconnect button when connected', () => {
render(<ConnectionStatus state="connected" onReconnect={mockOnReconnect} />);
expect(screen.queryByRole('button', { name: /reconnect/i })).not.toBeInTheDocument();
});
it('applies connected styling', () => {
const { container } = render(<ConnectionStatus state="connected" />);
expect(container.querySelector('.border-green-200')).toBeInTheDocument();
});
});
describe('connecting state', () => {
it('renders connecting status', () => {
render(<ConnectionStatus state="connecting" />);
expect(screen.getByText('Connecting')).toBeInTheDocument();
expect(screen.getByText('Establishing connection...')).toBeInTheDocument();
});
it('shows retry count when retrying', () => {
render(<ConnectionStatus state="connecting" retryCount={3} />);
expect(screen.getByText('Retry 3')).toBeInTheDocument();
});
});
describe('disconnected state', () => {
it('renders disconnected status', () => {
render(<ConnectionStatus state="disconnected" />);
expect(screen.getByText('Disconnected')).toBeInTheDocument();
expect(screen.getByText('Not connected to server')).toBeInTheDocument();
});
it('shows reconnect button when disconnected', () => {
render(<ConnectionStatus state="disconnected" onReconnect={mockOnReconnect} />);
const button = screen.getByRole('button', { name: /reconnect/i });
expect(button).toBeInTheDocument();
});
it('calls onReconnect when button is clicked', () => {
render(<ConnectionStatus state="disconnected" onReconnect={mockOnReconnect} />);
const button = screen.getByRole('button', { name: /reconnect/i });
fireEvent.click(button);
expect(mockOnReconnect).toHaveBeenCalledTimes(1);
});
});
describe('error state', () => {
it('renders error status', () => {
render(<ConnectionStatus state="error" />);
expect(screen.getByText('Connection Error')).toBeInTheDocument();
expect(screen.getByText('Failed to connect')).toBeInTheDocument();
});
it('shows reconnect button when in error state', () => {
render(<ConnectionStatus state="error" onReconnect={mockOnReconnect} />);
const button = screen.getByRole('button', { name: /reconnect/i });
expect(button).toBeInTheDocument();
});
it('applies error styling', () => {
const { container } = render(<ConnectionStatus state="error" />);
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(<ConnectionStatus state="error" error={mockError} />);
expect(screen.getByText(/Error: Connection timeout/)).toBeInTheDocument();
});
it('shows error code when provided', () => {
render(<ConnectionStatus state="error" error={mockError} />);
expect(screen.getByText(/Code: TIMEOUT/)).toBeInTheDocument();
});
it('hides error details when showErrorDetails is false', () => {
render(<ConnectionStatus state="error" error={mockError} showErrorDetails={false} />);
expect(screen.queryByText(/Error: Connection timeout/)).not.toBeInTheDocument();
});
});
describe('compact mode', () => {
it('renders compact version', () => {
const { container } = render(<ConnectionStatus state="connected" compact />);
// 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(<ConnectionStatus state="disconnected" onReconnect={mockOnReconnect} compact />);
// 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(<ConnectionStatus state="connecting" retryCount={5} compact />);
expect(screen.getByText(/retry 5/i)).toBeInTheDocument();
});
});
describe('showReconnectButton prop', () => {
it('hides reconnect button when showReconnectButton is false', () => {
render(
<ConnectionStatus
state="disconnected"
onReconnect={mockOnReconnect}
showReconnectButton={false}
/>
);
expect(screen.queryByRole('button', { name: /reconnect/i })).not.toBeInTheDocument();
});
});
describe('accessibility', () => {
it('has role="status" for screen readers', () => {
render(<ConnectionStatus state="connected" />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('has aria-live="polite" for status updates', () => {
render(<ConnectionStatus state="connected" />);
const status = screen.getByRole('status');
expect(status).toHaveAttribute('aria-live', 'polite');
});
});
describe('className prop', () => {
it('applies custom className', () => {
const { container } = render(
<ConnectionStatus state="connected" className="custom-class" />
);
expect(container.querySelector('.custom-class')).toBeInTheDocument();
});
});
});

View File

@@ -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> = {}): 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(<EventList events={[]} />);
expect(screen.getByText('No events yet')).toBeInTheDocument();
});
it('shows custom empty message', () => {
render(<EventList events={[]} emptyMessage="Waiting for activity..." />);
expect(screen.getByText('Waiting for activity...')).toBeInTheDocument();
});
});
describe('header', () => {
it('shows header by default', () => {
render(<EventList events={[]} />);
expect(screen.getByText('Activity Feed')).toBeInTheDocument();
});
it('shows custom title', () => {
render(<EventList events={[]} title="Project Events" />);
expect(screen.getByText('Project Events')).toBeInTheDocument();
});
it('hides header when showHeader is false', () => {
render(<EventList events={[]} showHeader={false} />);
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(<EventList events={events} />);
expect(screen.getByText('3 events')).toBeInTheDocument();
});
it('shows singular "event" for one event', () => {
const events = [createMockEvent()];
render(<EventList events={events} />);
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(<EventList events={events} />);
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(<EventList events={events} />);
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(<EventList events={events} />);
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(<EventList events={events} />);
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(<EventList events={events} />);
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(<EventList events={events} />);
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(<EventList events={events} />);
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(<EventList events={events} showPayloads />);
// 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(<EventList events={events} showPayloads />);
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(<EventList events={events} onEventClick={mockOnEventClick} />);
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(<EventList events={events} onEventClick={mockOnEventClick} />);
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(<EventList events={events} onEventClick={mockOnEventClick} />);
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(<EventList events={events} onEventClick={mockOnEventClick} />);
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(<EventList events={events} maxHeight={300} />);
const scrollContainer = container.querySelector('.overflow-y-auto');
expect(scrollContainer).toHaveStyle({ maxHeight: '300px' });
});
it('accepts string maxHeight', () => {
const events = [createMockEvent()];
const { container } = render(<EventList events={events} maxHeight="50vh" />);
const scrollContainer = container.querySelector('.overflow-y-auto');
expect(scrollContainer).toHaveStyle({ maxHeight: '50vh' });
});
});
describe('className prop', () => {
it('applies custom className', () => {
const { container } = render(<EventList events={[]} className="custom-event-list" />);
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(<EventList events={events} />);
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(<EventList events={events} />);
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(<EventList events={events} />);
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(<EventList events={events} />);
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(<EventList events={events} />);
expect(screen.getByText('Approval Denied')).toBeInTheDocument();
expect(screen.getByText(/Denied: Security review needed/)).toBeInTheDocument();
});
});
});

View File

@@ -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> = {}): 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
});
});
});

View File

@@ -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> = {}): 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');
});
});
});

View File

@@ -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> = {}): 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);
});
});
});