Files
syndarix/frontend/tests/components/events/EventList.test.tsx
Felipe Cardoso fcda8f0f96 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>
2025-12-30 01:34:41 +01:00

379 lines
12 KiB
TypeScript

/**
* 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();
});
});
});