feat(frontend): Implement client-side SSE handling (#35)
Implements real-time event streaming on the frontend with: - Event types and type guards matching backend EventType enum - Zustand-based event store with per-project buffering - useProjectEvents hook with auto-reconnection and exponential backoff - ConnectionStatus component showing connection state - EventList component with expandable payloads and filtering All 105 tests passing. Follows design system guidelines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
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