import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Timeline from './Timeline';
import type { PostWithAgent } from '../../../shared/types';
const mockPosts: PostWithAgent[] = [
{
id: 1,
agent_id: 1,
content: 'First post from Agent Alpha',
timestamp: new Date('2025-06-21T10:00:00.000Z'),
agent_name: 'alpha',
display_name: 'Agent Alpha',
identity_key: 'alpha:default',
avatar_seed: 'alpha123',
},
{
id: 2,
agent_id: 2,
content: 'Post from Agent Beta',
timestamp: new Date('2025-06-21T10:15:00.000Z'),
agent_name: 'beta',
display_name: 'Agent Beta',
identity_key: 'beta:default',
avatar_seed: 'beta456',
},
{
id: 3,
agent_id: 1,
content: 'Second post from Agent Alpha',
timestamp: new Date('2025-06-21T10:30:00.000Z'),
agent_name: 'alpha',
display_name: 'Agent Alpha',
identity_key: 'alpha:default',
avatar_seed: 'alpha123',
},
];
// Mock EventSource for SSE
class MockEventSource {
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
readyState = 1; // OPEN
url: string;
constructor(url: string) {
this.url = url;
// Simulate connection opened
setTimeout(() => {
if (this.onopen) {
this.onopen(new Event('open'));
}
}, 0);
}
close() {
// Mock close implementation
}
}
// Add EventSource to global
Object.defineProperty(globalThis, 'EventSource', {
value: MockEventSource,
writable: true,
});
// Mock the custom hooks
const mockSSETimeline = {
posts: mockPosts,
isLoading: false,
error: null,
lastUpdate: new Date(),
isConnected: true,
newPostCount: 0,
autoUpdateEnabled: true,
loadMorePosts: vi.fn(),
hasMorePosts: true,
isLoadingMore: false,
refreshPosts: vi.fn(),
markAsRead: vi.fn(),
toggleAutoUpdate: vi.fn(),
};
vi.mock('../hooks/useSSETimeline', () => ({
useSSETimeline: () => mockSSETimeline,
}));
vi.mock('../hooks/useInfiniteScroll', () => ({
useInfiniteScroll: () => ({
sentinelRef: { current: null },
}),
}));
// Mock window.scrollTo
Object.defineProperty(window, 'scrollTo', {
value: vi.fn(),
writable: true,
});
describe('Timeline', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Basic Rendering', () => {
it('should render timeline header', () => {
render(<Timeline />);
expect(screen.getByText('Timeline')).toBeInTheDocument();
});
it('should render refresh button', () => {
render(<Timeline />);
expect(screen.getByText('Refresh')).toBeInTheDocument();
});
it('should render all posts', () => {
render(<Timeline />);
expect(screen.getByText('First post from Agent Alpha')).toBeInTheDocument();
expect(screen.getByText('Post from Agent Beta')).toBeInTheDocument();
expect(screen.getByText('Second post from Agent Alpha')).toBeInTheDocument();
});
});
describe('Agent Filtering', () => {
it('should show filter select when multiple agents exist', () => {
render(<Timeline />);
const selectTrigger = screen.getByRole('combobox');
expect(selectTrigger).toBeInTheDocument();
});
it('should show select component with proper structure', () => {
render(<Timeline />);
// Check for select trigger component
const selectTrigger = screen.getByRole('combobox');
expect(selectTrigger).toBeInTheDocument();
// Should have proper select structure with data-slot
expect(selectTrigger).toHaveAttribute('data-slot', 'select-trigger');
// Should be a button element for accessibility
expect(selectTrigger.tagName).toBe('BUTTON');
});
it.skip('should open filter dropdown when select trigger is clicked', async () => {
// Skipped due to Radix UI compatibility issues in test environment
// This test would verify that clicking the select opens the dropdown
const user = userEvent.setup();
render(<Timeline />);
const selectTrigger = screen.getByRole('combobox');
await user.click(selectTrigger);
await waitFor(() => {
expect(screen.getByText('All Agents')).toBeInTheDocument();
expect(screen.getByText('Agent Alpha')).toBeInTheDocument();
expect(screen.getByText('Agent Beta')).toBeInTheDocument();
});
});
it.skip('should show post counts for each agent in filter dropdown', async () => {
// Skipped due to Radix UI compatibility issues in test environment
const user = userEvent.setup();
render(<Timeline />);
const selectTrigger = screen.getByRole('combobox');
await user.click(selectTrigger);
await waitFor(() => {
// Agent Alpha has 2 posts, Agent Beta has 1 post
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
});
});
it.skip('should filter posts when agent is selected from dropdown', async () => {
// Skipped due to Radix UI compatibility issues in test environment
const user = userEvent.setup();
render(<Timeline />);
// Open select dropdown
const selectTrigger = screen.getByRole('combobox');
await user.click(selectTrigger);
// Wait for dropdown to open and click on Agent Alpha option
await waitFor(() => {
const agentAlphaOption = screen.getByRole('option', { name: /Agent Alpha/ });
return user.click(agentAlphaOption);
});
// Should show only Agent Alpha's posts
await waitFor(() => {
expect(screen.getByText('First post from Agent Alpha')).toBeInTheDocument();
expect(screen.getByText('Second post from Agent Alpha')).toBeInTheDocument();
expect(screen.queryByText('Post from Agent Beta')).not.toBeInTheDocument();
});
// Should show filtering status
expect(screen.getByText('Showing posts from alpha')).toBeInTheDocument();
});
it.skip('should reset filter when All Agents is selected', async () => {
// Skipped due to Radix UI compatibility issues in test environment
const user = userEvent.setup();
render(<Timeline />);
// First select Agent Alpha
const selectTrigger = screen.getByRole('combobox');
await user.click(selectTrigger);
await waitFor(() => {
const agentAlphaOption = screen.getByRole('option', { name: /Agent Alpha/ });
return user.click(agentAlphaOption);
});
// Verify filtering is active
await waitFor(() => {
expect(screen.queryByText('Post from Agent Beta')).not.toBeInTheDocument();
});
// Open dropdown again and select "All Agents"
await user.click(selectTrigger);
await waitFor(() => {
const allAgentsOption = screen.getByRole('option', { name: 'All Agents' });
return user.click(allAgentsOption);
});
// Should show all posts again
await waitFor(() => {
expect(screen.getByText('First post from Agent Alpha')).toBeInTheDocument();
expect(screen.getByText('Post from Agent Beta')).toBeInTheDocument();
expect(screen.getByText('Second post from Agent Alpha')).toBeInTheDocument();
});
});
});
describe('Scroll to Top Button', () => {
it('should show scroll to top button when scrolled down', async () => {
render(<Timeline />);
// Mock scroll position
Object.defineProperty(window, 'scrollY', {
value: 1000,
writable: true,
});
Object.defineProperty(window, 'innerHeight', {
value: 800,
writable: true,
});
// Trigger scroll event
fireEvent.scroll(window);
await waitFor(() => {
const scrollButton = screen.getByLabelText('Scroll to top');
expect(scrollButton).toBeInTheDocument();
});
});
it('should hide scroll to top button when at top', async () => {
render(<Timeline />);
// Mock scroll position at top
Object.defineProperty(window, 'scrollY', {
value: 0,
writable: true,
});
// Trigger scroll event
fireEvent.scroll(window);
await waitFor(() => {
const scrollButton = screen.queryByLabelText('Scroll to top');
expect(scrollButton).not.toBeInTheDocument();
});
});
it('should scroll to top when scroll button is clicked', async () => {
render(<Timeline />);
// Mock scroll position
Object.defineProperty(window, 'scrollY', {
value: 1000,
writable: true,
});
Object.defineProperty(window, 'innerHeight', {
value: 800,
writable: true,
});
// Trigger scroll event to show button
fireEvent.scroll(window);
await waitFor(() => {
const scrollButton = screen.getByLabelText('Scroll to top');
fireEvent.click(scrollButton);
});
expect(window.scrollTo).toHaveBeenCalledWith({
top: 0,
behavior: 'smooth',
});
});
});
describe('Tooltip Functionality', () => {
it('should have tooltip trigger attribute on refresh button', () => {
render(<Timeline />);
const refreshButton = screen.getByText('Refresh');
expect(refreshButton).toHaveAttribute('data-slot', 'tooltip-trigger');
});
it('should have tooltip trigger attribute on scroll to top button when visible', async () => {
render(<Timeline />);
// Mock scroll position to show the button
Object.defineProperty(window, 'scrollY', {
value: 1000,
writable: true,
});
Object.defineProperty(window, 'innerHeight', {
value: 800,
writable: true,
});
// Trigger scroll event
fireEvent.scroll(window);
await waitFor(() => {
const scrollButton = screen.getByLabelText('Scroll to top');
expect(scrollButton).toHaveAttribute('data-slot', 'tooltip-trigger');
});
});
it('should have proper accessibility attributes', () => {
render(<Timeline />);
const refreshButton = screen.getByText('Refresh');
// Should be wrapped in tooltip trigger (this is the button element itself)
expect(refreshButton).toHaveAttribute('data-slot', 'tooltip-trigger');
// Should have proper button structure
expect(refreshButton.tagName).toBe('BUTTON');
});
});
describe('Component Structure', () => {
it('should use shadcn/ui components with proper data attributes', () => {
const { container } = render(<Timeline />);
// Should have select component
const select = container.querySelector('[data-slot="select-trigger"]');
expect(select).toBeInTheDocument();
// Should have button components (refresh button)
const buttons = container.querySelectorAll('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('should render Alert component structure', () => {
// Test that the Alert component uses proper shadcn/ui structure
// by checking if the component can be rendered and has proper data attributes
render(<Timeline />);
// Verify main timeline structure exists
expect(screen.getByText('Timeline')).toBeInTheDocument();
expect(screen.getByText('Refresh')).toBeInTheDocument();
// Should render all posts
expect(screen.getByText('First post from Agent Alpha')).toBeInTheDocument();
expect(screen.getByText('Post from Agent Beta')).toBeInTheDocument();
expect(screen.getByText('Second post from Agent Alpha')).toBeInTheDocument();
});
it('should have shadcn/ui component structure for cards', () => {
const { container } = render(<Timeline />);
// Should have Card components for each post
const cards = container.querySelectorAll('[data-slot="card"]');
expect(cards.length).toBeGreaterThan(0);
// Should have card headers and content
const cardHeaders = container.querySelectorAll('[data-slot="card-header"]');
expect(cardHeaders.length).toBeGreaterThan(0);
const cardContents = container.querySelectorAll('[data-slot="card-content"]');
expect(cardContents.length).toBeGreaterThan(0);
});
});
});