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';
|
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)
|
// Authentication Context (DI wrapper for auth store)
|
||||||
export { useAuth, AuthProvider } from '../auth/AuthContext';
|
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