// @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' import { render, screen, fireEvent } from '@testing-library/react' import { vi, describe, it, expect, beforeEach } from 'vitest' import * as parseModule from '@/lib/parse-agent-output' import { AgentOutputViewer } from './AgentOutputViewer' vi.mock('@/lib/trpc', () => ({ trpc: { getAgentOutput: { useQuery: vi.fn(() => ({ data: [], isLoading: false })), }, onAgentOutput: { useSubscription: vi.fn(), }, }, })) vi.mock('@/hooks', () => ({ useSubscriptionWithErrorHandling: vi.fn(() => ({ error: null, isConnecting: false, })), })) function makeToolResultMessage(content: string) { return { type: 'tool_result' as const, content, timestamp: new Date('2024-01-01T00:00:00Z'), } } function makeSystemMessage(content: string) { return { type: 'system' as const, content, timestamp: new Date('2024-01-01T00:00:00Z'), } } function makeTextMessage(content: string) { return { type: 'text' as const, content, timestamp: new Date('2024-01-01T00:00:00Z'), } } function makeToolCallMessage(content: string, toolName: string) { return { type: 'tool_call' as const, content, timestamp: new Date('2024-01-01T00:00:00Z'), meta: { toolName }, } } function makeTodoWriteMessage(todos: Array<{ content: string; status: string; activeForm: string }>) { return { type: 'tool_call' as const, content: 'TodoWrite(...)', timestamp: new Date('2024-01-01T00:00:00Z'), meta: { toolName: 'TodoWrite', toolInput: { todos }, }, } } function makeToolResultMessageWithMeta( content: string, meta: { toolName?: string; toolInput?: unknown } ) { return { type: 'tool_result' as const, content, timestamp: new Date('2024-01-01T00:00:00Z'), meta, } } function makeErrorMessage(content: string) { return { type: 'error' as const, content, timestamp: new Date('2024-01-01T00:00:00Z'), } } function makeSessionEndMessage(content: string) { return { type: 'session_end' as const, content, timestamp: new Date('2024-01-01T00:00:00Z'), } } describe('AgentOutputViewer', () => { beforeEach(() => { vi.clearAllMocks() // Default: no messages vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([]) }) // Test 1: tool_result renders collapsed by default it('renders tool_result collapsed by default', () => { const content = 'file content here and more stuff' vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(content)]) render() // ChevronRight should be present (collapsed state) // We check that the SVG for ChevronRight is in the document // lucide-react renders SVGs — we look for the collapsed container containing the preview text expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument() // "Result" badge should NOT be visible (collapsed) expect(screen.queryByText('Result')).not.toBeInTheDocument() // The container should NOT show a "Result" badge // ChevronRight is rendered — verify no ChevronDown const svgs = document.querySelectorAll('svg') // We look for the collapsed state by absence of "Result" text expect(screen.queryByText('Result')).toBeNull() }) // Test 2: Clicking collapsed result expands it it('expands tool_result on click', () => { const longContent = 'a'.repeat(100) vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(longContent)]) render() // Initially collapsed — click it const collapsedContainer = screen.getByText(longContent.substring(0, 80)).closest('div')! fireEvent.click(collapsedContainer) // After click: "Result" badge should be visible expect(screen.getByText('Result')).toBeInTheDocument() // Full content should be visible in whitespace-pre-wrap element const preWrap = document.querySelector('.whitespace-pre-wrap') expect(preWrap).toBeInTheDocument() expect(preWrap).toHaveTextContent(longContent) // ChevronRight should no longer be visible; ChevronDown should be present expect(screen.queryByText('Result')).toBeInTheDocument() }) // Test 3: Clicking expanded result collapses it again it('collapses tool_result on second click', () => { const longContent = 'b'.repeat(100) vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(longContent)]) render() // Click once to expand const container = screen.getByText(longContent.substring(0, 80)).closest('div')! fireEvent.click(container) expect(screen.getByText('Result')).toBeInTheDocument() // Click again to collapse // After expansion, the container still exists — click the expanded container // The clickable container is the border-l-2 div const expandedContainer = screen.getByText('Result').closest('.border-l-2')! fireEvent.click(expandedContainer) // Should be collapsed again expect(screen.queryByText('Result')).not.toBeInTheDocument() expect(screen.getByText(longContent.substring(0, 80))).toBeInTheDocument() }) // Test 4: system message renders as single dim line, no badge, no border-l it('renders system message as a single dim line without badge or border', () => { const content = 'Session started: abc-123' vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeSystemMessage(content)]) render() // Content is visible expect(screen.getByText(content)).toBeInTheDocument() // No "System" badge text expect(screen.queryByText('System')).not.toBeInTheDocument() // The rendered element should NOT have border-l class const el = screen.getByText(content) expect(el.className).not.toContain('border-l') expect(el.closest('[class*="border-l"]')).toBeNull() }) // Test 5: agentId prop change resets expanded results it('resets expanded results when agentId changes', () => { const content = 'c'.repeat(100) vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(content)]) const { rerender } = render() // Expand the result const collapsedContainer = screen.getByText(content.substring(0, 80)).closest('div')! fireEvent.click(collapsedContainer) expect(screen.getByText('Result')).toBeInTheDocument() // Change agentId — should reset expandedResults rerender() // After agentId change, result should be collapsed again expect(screen.queryByText('Result')).not.toBeInTheDocument() // Preview text should be visible (collapsed state) expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument() }) // Test 6: Other message types remain always-expanded (unaffected) it('always renders text messages fully', () => { const content = 'This is a text message' vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeTextMessage(content)]) render() expect(screen.getByText(content)).toBeInTheDocument() // No chevron icons for text messages const svgCount = document.querySelectorAll('svg').length // Only the header bar icons (Pause) should be present, no expand/collapse chevrons expect(screen.queryByText('Result')).not.toBeInTheDocument() expect(screen.queryByText('System')).not.toBeInTheDocument() }) it('always renders tool_call messages fully', () => { const content = 'Read(file.txt)' vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolCallMessage(content, 'Read')]) render() expect(screen.getByText(content)).toBeInTheDocument() // The tool name badge should be visible expect(screen.getByText('Read')).toBeInTheDocument() }) it('always renders error messages with Error badge', () => { const content = 'Something went wrong' vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeErrorMessage(content)]) render() expect(screen.getByText('Error')).toBeInTheDocument() expect(screen.getByText(content)).toBeInTheDocument() }) it('always renders session_end messages with session completed text', () => { vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeSessionEndMessage('Session completed')]) render() expect(screen.getByText('Session completed')).toBeInTheDocument() }) describe('Todo strip', () => { it('is absent when no TodoWrite tool_call exists', () => { vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ makeTextMessage('some output'), makeToolCallMessage('Read(file.txt)', 'Read'), ]) render() expect(screen.queryByText('TASKS')).not.toBeInTheDocument() }) it('is present with TASKS label and all todos when a TodoWrite tool_call exists', () => { vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ makeTodoWriteMessage([ { content: 'Fix bug', status: 'completed', activeForm: 'Fixing bug' }, { content: 'Add tests', status: 'in_progress', activeForm: 'Adding tests' }, { content: 'Update docs', status: 'pending', activeForm: 'Updating docs' }, ]), ]) render() expect(screen.getByText('TASKS')).toBeInTheDocument() expect(screen.getByText('Fix bug')).toBeInTheDocument() expect(screen.getByText('Add tests')).toBeInTheDocument() expect(screen.getByText('Update docs')).toBeInTheDocument() }) it('shows only the most recent TodoWrite todos', () => { vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ makeTodoWriteMessage([ { content: 'Old task', status: 'pending', activeForm: 'Old task' }, ]), makeTodoWriteMessage([ { content: 'New task', status: 'in_progress', activeForm: 'New task' }, ]), ]) render() expect(screen.getByText('New task')).toBeInTheDocument() expect(screen.queryByText('Old task')).not.toBeInTheDocument() }) it('renders Loader2 with animate-spin for in_progress todo', () => { vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ makeTodoWriteMessage([ { content: 'Work', status: 'in_progress', activeForm: 'Working' }, ]), ]) render() // Find SVG with animate-spin class (Loader2) const spinningIcon = document.querySelector('svg.animate-spin') expect(spinningIcon).toBeInTheDocument() }) it('renders CheckCircle2 and strikethrough text for completed todo', () => { vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ makeTodoWriteMessage([ { content: 'Done', status: 'completed', activeForm: 'Done' }, ]), ]) render() const doneText = screen.getByText('Done') expect(doneText.className).toContain('line-through') }) it('renders Circle and muted text without strikethrough for pending todo', () => { vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ makeTodoWriteMessage([ { content: 'Later', status: 'pending', activeForm: 'Later' }, ]), ]) render() const laterText = screen.getByText('Later') expect(laterText.className).not.toContain('line-through') }) it('renders at most 5 todo rows and shows overflow count when there are 7 todos', () => { const todos = Array.from({ length: 7 }, (_, i) => ({ content: `Task ${i + 1}`, status: 'pending', activeForm: `Task ${i + 1}`, })) vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ makeTodoWriteMessage(todos), ]) render() // Only first 5 are rendered expect(screen.getByText('Task 1')).toBeInTheDocument() expect(screen.getByText('Task 5')).toBeInTheDocument() expect(screen.queryByText('Task 6')).not.toBeInTheDocument() expect(screen.queryByText('Task 7')).not.toBeInTheDocument() // Overflow indicator expect(screen.getByText('+ 2 more')).toBeInTheDocument() }) }) describe('Task result collapsed preview', () => { it('shows subagent_type result label when meta.toolName is Task with subagent_type', () => { vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ makeToolResultMessageWithMeta('raw subagent output content '.repeat(5), { toolName: 'Task', toolInput: { subagent_type: 'Explore', description: 'desc', prompt: 'prompt' }, }), ]) render() expect(screen.getByText('Explore result')).toBeInTheDocument() }) it('shows Subagent result when meta.toolName is Task but no subagent_type', () => { vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ makeToolResultMessageWithMeta('raw subagent output content '.repeat(5), { toolName: 'Task', toolInput: { description: 'desc', prompt: 'prompt' }, }), ]) render() expect(screen.getByText('Subagent result')).toBeInTheDocument() }) it('shows first 80 chars of content for non-Task tool results', () => { const content = 'some file content that is longer than 80 characters '.repeat(3) vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([ makeToolResultMessageWithMeta(content, { toolName: 'Read', }), ]) render() expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument() }) }) })