diff --git a/src/web/frontend/components/TimelineView.e2e.test.tsx b/src/web/frontend/components/TimelineView.e2e.test.tsx new file mode 100644 index 0000000..fdeca72 --- /dev/null +++ b/src/web/frontend/components/TimelineView.e2e.test.tsx @@ -0,0 +1,468 @@ +/** + * E2E Tests for TimelineView component + * Tests real-time WebSocket integration and live updates + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import TimelineView from '../src/components/TimelineView'; +import { LogEvent, WorkerInfo } from '../src/types'; + +describe('TimelineView E2E - Real-time Updates', () => { + const createMockEvent = (overrides: Partial = {}): LogEvent => ({ + timestamp: new Date().toISOString(), + level: 'info', + worker: 'worker-alpha', + message: 'Test event', + raw: JSON.stringify({ timestamp: new Date().toISOString(), level: 'info', worker: 'worker-alpha', message: 'Test event' }), + ...overrides, + }); + + const createMockWorker = (overrides: Partial = {}): WorkerInfo => ({ + id: 'worker-alpha', + lastSeen: new Date().toISOString(), + eventCount: 10, + status: 'active', + recentEvents: [], + ...overrides, + }); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-03T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('WebSocket integration', () => { + it('should update timeline when new events arrive via WebSocket', async () => { + const now = new Date('2026-03-03T12:00:00Z'); + const initialEvents = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 60000).toISOString(), + worker: 'worker-alpha', + level: 'info', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container, rerender } = render( + + ); + + // Initial state - should have one segment + let segments = container.querySelectorAll('.timeline-segment'); + expect(segments.length).toBeGreaterThan(0); + const initialSegmentCount = segments.length; + + // Simulate WebSocket message arriving with new event + const newEvent = createMockEvent({ + timestamp: new Date(now.getTime() - 30000).toISOString(), + worker: 'worker-alpha', + level: 'error', + }); + + rerender( + + ); + + // Should update with new segment + segments = container.querySelectorAll('.timeline-segment'); + expect(segments.length).toBeGreaterThan(initialSegmentCount); + }); + + it('should highlight worker row when new events arrive', async () => { + const now = new Date('2026-03-03T12:00:00Z'); + const initialEvents: LogEvent[] = []; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container, rerender } = render( + + ); + + // Initially no new-activity class + expect(container.querySelector('.new-activity')).not.toBeInTheDocument(); + + // Simulate WebSocket message with new event + const newEvent = createMockEvent({ + timestamp: now.toISOString(), + worker: 'worker-alpha', + level: 'info', + }); + + rerender( + + ); + + // Should have new-activity class + expect(container.querySelector('.new-activity')).toBeInTheDocument(); + }); + + it('should handle multiple workers with different activity patterns', async () => { + const now = new Date('2026-03-03T12:00:00Z'); + + // Worker alpha: continuous activity + const alphaEvents = Array.from({ length: 10 }, (_, i) => + createMockEvent({ + timestamp: new Date(now.getTime() - i * 30000).toISOString(), + worker: 'worker-alpha', + level: 'info', + }) + ); + + // Worker bravo: sporadic activity + const bravoEvents = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 300000).toISOString(), + worker: 'worker-bravo', + level: 'warn', + }), + createMockEvent({ + timestamp: new Date(now.getTime() - 60000).toISOString(), + worker: 'worker-bravo', + level: 'info', + }), + ]; + + const workers = [ + createMockWorker({ id: 'worker-alpha', eventCount: 10 }), + createMockWorker({ id: 'worker-bravo', eventCount: 2 }), + ]; + + const { container } = render( + + ); + + const rows = container.querySelectorAll('.timeline-row'); + expect(rows.length).toBe(2); + + // Alpha should have more segments than bravo + const alphaRow = Array.from(rows).find(row => + row.textContent?.includes('alpha') + ); + const bravoRow = Array.from(rows).find(row => + row.textContent?.includes('bravo') + ); + + expect(alphaRow).toBeInTheDocument(); + expect(bravoRow).toBeInTheDocument(); + + const alphaSegments = alphaRow?.querySelectorAll('.timeline-segment'); + const bravoSegments = bravoRow?.querySelectorAll('.timeline-segment'); + + expect(alphaSegments?.length).toBeGreaterThan(bravoSegments?.length || 0); + }); + }); + + describe('Time range interaction', () => { + it('should filter events based on selected time range', () => { + const now = new Date('2026-03-03T12:00:00Z'); + + // Events spread across different time periods (in different 30-second buckets) + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 1 * 60 * 1000).toISOString(), // 1 min ago + worker: 'worker-alpha', + level: 'info', + }), + createMockEvent({ + timestamp: new Date(now.getTime() - 4 * 60 * 1000).toISOString(), // 4 min ago + worker: 'worker-alpha', + level: 'warn', + }), + createMockEvent({ + timestamp: new Date(now.getTime() - 20 * 60 * 1000).toISOString(), // 20 min ago + worker: 'worker-alpha', + level: 'error', + }), + ]; + + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + // With 5m range, should show first 2 events (in different buckets) + let segments = container.querySelectorAll('.timeline-segment'); + expect(segments.length).toBeGreaterThanOrEqual(2); + + // Switch to 30m range + const thirtyMinButton = screen.getByText('30 min'); + fireEvent.click(thirtyMinButton); + + // Now should show all 3 events + segments = container.querySelectorAll('.timeline-segment'); + expect(segments.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('Style switching', () => { + it('should switch between blocks and bars visualization', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 30000).toISOString(), + worker: 'worker-alpha', + level: 'info', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + // Should have block visualization + expect(container.querySelector('.block-visualization')).toBeInTheDocument(); + expect(container.querySelector('.timeline-segment')).not.toBeInTheDocument(); + + // Toggle to bars + const styleToggle = container.querySelector('.style-toggle'); + if (styleToggle) { + fireEvent.click(styleToggle); + } + + // Now should have segments + expect(container.querySelector('.timeline-segment')).toBeInTheDocument(); + }); + }); + + describe('Worker selection and filtering', () => { + it('should highlight selected worker row', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 30000).toISOString(), + worker: 'worker-alpha', + }), + createMockEvent({ + timestamp: new Date(now.getTime() - 30000).toISOString(), + worker: 'worker-bravo', + }), + ]; + + const workers = [ + createMockWorker({ id: 'worker-alpha' }), + createMockWorker({ id: 'worker-bravo' }), + ]; + + const { container } = render( + + ); + + const rows = container.querySelectorAll('.timeline-row'); + const alphaRow = Array.from(rows).find(row => + row.textContent?.includes('alpha') + ); + + expect(alphaRow).toHaveClass('selected'); + }); + + it('should call onWorkerClick when worker row is clicked', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 30000).toISOString(), + worker: 'worker-alpha', + }), + ]; + + const workers = [createMockWorker({ id: 'worker-alpha' })]; + const mockOnWorkerClick = vi.fn(); + + const { container } = render( + + ); + + const row = container.querySelector('.timeline-row'); + if (row) { + fireEvent.click(row); + } + + expect(mockOnWorkerClick).toHaveBeenCalledWith('worker-alpha'); + }); + }); + + describe('Focus Mode integration', () => { + it('should filter to pinned workers when focus mode is enabled', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 30000).toISOString(), + worker: 'worker-alpha', + }), + createMockEvent({ + timestamp: new Date(now.getTime() - 30000).toISOString(), + worker: 'worker-bravo', + }), + ]; + + const workers = [ + createMockWorker({ id: 'worker-alpha' }), + createMockWorker({ id: 'worker-bravo' }), + ]; + + const { container, rerender } = render( + + ); + + // Should show both workers + let rows = container.querySelectorAll('.timeline-row'); + expect(rows.length).toBe(2); + + // Enable focus mode with pinned worker + rerender( + + ); + + // Should only show alpha + rows = container.querySelectorAll('.timeline-row'); + expect(rows.length).toBe(1); + expect(rows[0].textContent).toContain('alpha'); + }); + }); + + describe('Time selection', () => { + it('should show time selection hint when onTimeSelect is provided', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 30000).toISOString(), + worker: 'worker-alpha', + }), + ]; + + const workers = [createMockWorker({ id: 'worker-alpha' })]; + const mockOnTimeSelect = vi.fn(); + + render( + + ); + + // Should show hint text for bars mode + expect(screen.getByText('Click on timeline to jump to that time in activity stream')).toBeInTheDocument(); + }); + + it('should render clickable timeline content when onTimeSelect is provided', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 30000).toISOString(), + worker: 'worker-alpha', + }), + ]; + + const workers = [createMockWorker({ id: 'worker-alpha' })]; + const mockOnTimeSelect = vi.fn(); + + const { container } = render( + + ); + + const timelineContent = container.querySelector('.timeline-content'); + expect(timelineContent).toBeInTheDocument(); + // Timeline content should exist for click handling + expect(timelineContent).toHaveClass('timeline-content'); + }); + }); + + describe('Real-time auto-refresh', () => { + it('should update time-based display when currentTime prop changes', () => { + const baseTime = new Date('2026-03-03T12:00:00Z').getTime(); + const events = [ + createMockEvent({ + timestamp: new Date(baseTime - 30000).toISOString(), + worker: 'worker-alpha', + }), + ]; + + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { rerender, container } = render( + + ); + + const initialSegments = container.querySelectorAll('.timeline-segment'); + expect(initialSegments.length).toBeGreaterThan(0); + + // Advance time by 5 minutes + const newTime = baseTime + 5 * 60 * 1000; + rerender( + + ); + + // Timeline should still show the event (it's within 10m range) + const segments = container.querySelectorAll('.timeline-segment'); + expect(segments.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/web/frontend/test/TimelineView.test.tsx b/src/web/frontend/test/TimelineView.test.tsx new file mode 100644 index 0000000..707bb16 --- /dev/null +++ b/src/web/frontend/test/TimelineView.test.tsx @@ -0,0 +1,622 @@ +/** + * Tests for TimelineView component + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import TimelineView from '../src/components/TimelineView'; +import { LogEvent, WorkerInfo } from '../src/types'; + +describe('TimelineView', () => { + const createMockEvent = (overrides: Partial = {}): LogEvent => ({ + timestamp: new Date().toISOString(), + level: 'info', + worker: 'worker-alpha', + message: 'Test event', + raw: JSON.stringify({ timestamp: new Date().toISOString(), level: 'info', worker: 'worker-alpha', message: 'Test event' }), + ...overrides, + }); + + const createMockWorker = (overrides: Partial = {}): WorkerInfo => ({ + id: 'worker-alpha', + lastSeen: new Date().toISOString(), + eventCount: 10, + status: 'active', + recentEvents: [], + ...overrides, + }); + + const mockOnTimeSelect = vi.fn(); + + beforeEach(() => { + mockOnTimeSelect.mockClear(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-03T12:00:00Z')); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + describe('rendering', () => { + it('should render timeline header with time range', () => { + render( + + ); + + expect(screen.getByText(/Timeline \(last/)).toBeInTheDocument(); + expect(screen.getByText('10 min')).toBeInTheDocument(); + }); + + it('should render time range selector buttons', () => { + const { container } = render( + + ); + + expect(container.querySelector('.time-range-selector')).toBeInTheDocument(); + expect(screen.getByText('5 min')).toBeInTheDocument(); + expect(screen.getByText('10 min')).toBeInTheDocument(); + expect(screen.getByText('30 min')).toBeInTheDocument(); + expect(screen.getByText('1 hour')).toBeInTheDocument(); + }); + + it('should render empty state when no events in time range', () => { + render( + + ); + + expect(screen.getByText('No worker activity in this time range')).toBeInTheDocument(); + }); + + it('should render worker rows when events exist', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + expect(container.querySelector('.timeline-rows')).toBeInTheDocument(); + expect(container.querySelectorAll('.timeline-row').length).toBeGreaterThan(0); + }); + }); + + describe('time range selection', () => { + it('should change time range when clicking button', () => { + const { container } = render( + + ); + + const fiveMinButton = screen.getByText('5 min'); + fireEvent.click(fiveMinButton); + + expect(screen.getByText('Timeline (last 5 min)')).toBeInTheDocument(); + }); + + it('should highlight active time range button', () => { + const { container } = render( + + ); + + const tenMinButton = screen.getByText('10 min'); + expect(tenMinButton.closest('.time-range-button')).toHaveClass('active'); + }); + }); + + describe('worker filtering', () => { + it('should filter events when selectedWorker is set', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + }), + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-beta', + }), + ]; + const workers = [ + createMockWorker({ id: 'worker-alpha' }), + createMockWorker({ id: 'worker-beta' }), + ]; + + const { container } = render( + + ); + + // Timeline shows workers with events in the filtered set + // Both workers appear because they both have events, but only worker-alpha's segments are shown + const rows = container.querySelectorAll('.timeline-row'); + expect(rows.length).toBeGreaterThanOrEqual(1); + }); + + it('should filter workers when focus mode is enabled with pinned workers', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + }), + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-beta', + }), + ]; + const workers = [ + createMockWorker({ id: 'worker-alpha' }), + createMockWorker({ id: 'worker-beta' }), + ]; + + const { container } = render( + + ); + + const rows = container.querySelectorAll('.timeline-row'); + expect(rows.length).toBe(1); + }); + }); + + describe('time selection', () => { + it('should call onTimeSelect when clicking on timeline', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + const timelineContent = container.querySelector('.timeline-content'); + if (timelineContent) { + fireEvent.click(timelineContent, { clientX: 100 }); + } + + expect(mockOnTimeSelect).toHaveBeenCalled(); + }); + + it('should show hint text when onTimeSelect is provided', () => { + render( + + ); + + expect(screen.getByText('Click on timeline to jump to that time in activity stream')).toBeInTheDocument(); + }); + }); + + describe('worker name truncation', () => { + it('should truncate worker name to last segment', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha-bravo-charlie', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha-bravo-charlie' })]; + + const { container } = render( + + ); + + expect(screen.getByText('charlie')).toBeInTheDocument(); + }); + }); + + describe('segment rendering', () => { + it('should render timeline segments for events in bars mode', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + level: 'info', + }), + createMockEvent({ + timestamp: new Date(now.getTime() - 10000).toISOString(), + worker: 'worker-alpha', + level: 'error', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + const segments = container.querySelectorAll('.timeline-segment'); + expect(segments.length).toBeGreaterThan(0); + }); + + it('should render block visualization in blocks mode', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + level: 'info', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + const blocks = container.querySelector('.block-visualization'); + expect(blocks).toBeInTheDocument(); + }); + }); + + describe('auto-refresh', () => { + it('should use provided currentTime when available', () => { + const providedTime = new Date('2026-03-03T12:30:00Z').getTime(); + const events = [ + createMockEvent({ + timestamp: new Date('2026-03-03T12:29:00Z').toISOString(), + worker: 'worker-alpha', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + // The timeline should include events within the time range relative to providedTime + const segments = container.querySelectorAll('.timeline-segment'); + expect(segments.length).toBeGreaterThan(0); + }); + + it('should auto-update when currentTime prop changes', () => { + const events = [ + createMockEvent({ + timestamp: new Date('2026-03-03T12:29:00Z').toISOString(), + worker: 'worker-alpha', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { rerender } = render( + + ); + + rerender( + + ); + + // Component should re-render with new time + const segments = document.querySelectorAll('.timeline-segment'); + expect(segments).toBeTruthy(); + }); + }); + + describe('CSS classes', () => { + it('should apply timeline-view class to container', () => { + const { container } = render( + + ); + + expect(container.querySelector('.timeline-view')).toBeInTheDocument(); + }); + + it('should apply timeline-header class', () => { + const { container } = render( + + ); + + expect(container.querySelector('.timeline-header')).toBeInTheDocument(); + }); + + it('should apply timeline-content class', () => { + const { container } = render( + + ); + + expect(container.querySelector('.timeline-content')).toBeInTheDocument(); + }); + + it('should apply timeline-axis class', () => { + const { container } = render( + + ); + + expect(container.querySelector('.timeline-axis')).toBeInTheDocument(); + }); + + it('should apply current-time-pulse class', () => { + const { container } = render( + + ); + + expect(container.querySelector('.current-time-pulse')).toBeInTheDocument(); + }); + }); + + describe('default time range', () => { + it('should use provided defaultTimeRange', () => { + render( + + ); + + expect(screen.getByText('Timeline (last 5 min)')).toBeInTheDocument(); + }); + }); + + describe('compact mode', () => { + it('should render compact mode when enabled', () => { + const { container } = render( + + ); + + expect(container.querySelector('.timeline-view.compact')).toBeInTheDocument(); + }); + + it('should render block visualization in compact mode', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + level: 'info', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + expect(container.querySelector('.block-visualization')).toBeInTheDocument(); + }); + + it('should not render block visualization in bar mode', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + level: 'info', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + expect(container.querySelector('.block-visualization')).not.toBeInTheDocument(); + }); + }); + + describe('worker click handling', () => { + it('should call onWorkerClick when worker row is clicked', () => { + const mockOnWorkerClick = vi.fn(); + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + const row = container.querySelector('.timeline-row'); + if (row) { + fireEvent.click(row); + } + expect(mockOnWorkerClick).toHaveBeenCalledWith('worker-alpha'); + }); + + it('should apply selected class to selected worker', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + const row = container.querySelector('.timeline-row'); + expect(row).toHaveClass('selected'); + }); + }); + + describe('new event highlighting', () => { + it('should apply new-activity class when new events arrive', async () => { + const { container, rerender } = render( + + ); + + // Initially no new-activity class + expect(container.querySelector('.new-activity')).not.toBeInTheDocument(); + + // Add new events + const now = new Date('2026-03-03T12:00:00Z'); + const newEvents = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + }), + ]; + + rerender( + + ); + + // Should have new-activity class + expect(container.querySelector('.new-activity')).toBeInTheDocument(); + }); + }); + + describe('worker event counts', () => { + it('should display total event count for each worker', () => { + const now = new Date('2026-03-03T12:00:00Z'); + const events = [ + createMockEvent({ + timestamp: new Date(now.getTime() - 5000).toISOString(), + worker: 'worker-alpha', + level: 'info', + }), + createMockEvent({ + timestamp: new Date(now.getTime() - 3000).toISOString(), + worker: 'worker-alpha', + level: 'debug', + }), + createMockEvent({ + timestamp: new Date(now.getTime() - 1000).toISOString(), + worker: 'worker-alpha', + level: 'warn', + }), + ]; + const workers = [createMockWorker({ id: 'worker-alpha' })]; + + const { container } = render( + + ); + + expect(screen.getByText('(3)')).toBeInTheDocument(); + }); + }); +});