forked from cardosofelipe/fast-next-template
Merge branch 'feature/35-client-side-sse' into dev
This commit is contained in:
214
frontend/src/components/events/ConnectionStatus.tsx
Normal file
214
frontend/src/components/events/ConnectionStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
467
frontend/src/components/events/EventList.tsx
Normal file
467
frontend/src/components/events/EventList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/events/index.ts
Normal file
10
frontend/src/components/events/index.ts
Normal 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';
|
||||
7
frontend/src/lib/hooks/index.ts
Normal file
7
frontend/src/lib/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Custom React Hooks
|
||||
*
|
||||
* @module lib/hooks
|
||||
*/
|
||||
|
||||
export { useProjectEvents, type UseProjectEventsOptions, type UseProjectEventsResult } from './useProjectEvents';
|
||||
393
frontend/src/lib/hooks/useProjectEvents.ts
Normal file
393
frontend/src/lib/hooks/useProjectEvents.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
225
frontend/src/lib/stores/eventStore.ts
Normal file
225
frontend/src/lib/stores/eventStore.ts
Normal 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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
307
frontend/src/lib/types/events.ts
Normal file
307
frontend/src/lib/types/events.ts
Normal 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;
|
||||
}
|
||||
39
frontend/src/lib/types/index.ts
Normal file
39
frontend/src/lib/types/index.ts
Normal 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';
|
||||
192
frontend/tests/components/events/ConnectionStatus.test.tsx
Normal file
192
frontend/tests/components/events/ConnectionStatus.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
378
frontend/tests/components/events/EventList.test.tsx
Normal file
378
frontend/tests/components/events/EventList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
475
frontend/tests/lib/hooks/useProjectEvents.test.ts
Normal file
475
frontend/tests/lib/hooks/useProjectEvents.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
255
frontend/tests/lib/stores/eventStore.test.ts
Normal file
255
frontend/tests/lib/stores/eventStore.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
167
frontend/tests/lib/types/events.test.ts
Normal file
167
frontend/tests/lib/types/events.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user