/** * Scroll behavior tests for AgentBuffer. * * The AgentBuffer component uses a useEffect that calls * messagesEndRef.current?.scrollIntoView({ behavior }) where behavior * is 'instant' during streaming and 'smooth' otherwise. * * We also test the pure helper `systemMessageClass` which classifies * system message content for CSS styling. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { isRetryableError } from '../core/services/retry' // --------------------------------------------------------------------------- // 1. systemMessageClass — pure function extracted from AgentBuffer.tsx // --------------------------------------------------------------------------- // The function is defined inline in AgentBuffer.tsx (not exported), so we // replicate it here for direct unit testing. If it ever gets extracted and // exported, swap this for a direct import. function systemMessageClass(content: string): string { const lower = content.toLowerCase() if ( lower.includes('error') || lower.includes('failed') || lower.includes('not ready') || lower.includes('not found') || lower.includes('denied') || lower.includes('authentication') || lower.includes('not configured') || lower.includes('no api key') ) { return 'system-error' } if (lower.includes('retrying')) { return 'system-retry' } return '' } describe('systemMessageClass', () => { it('returns "system-error" for messages containing "error"', () => { expect(systemMessageClass('Something went wrong: error')).toBe('system-error') }) it('returns "system-error" for "failed"', () => { expect(systemMessageClass('Request failed with status 500')).toBe('system-error') }) it('returns "system-error" for "not ready"', () => { expect(systemMessageClass('Agent is not ready')).toBe('system-error') }) it('returns "system-error" for "not found"', () => { expect(systemMessageClass('Model not found')).toBe('system-error') }) it('returns "system-error" for "denied"', () => { expect(systemMessageClass('Permission denied')).toBe('system-error') }) it('returns "system-error" for "authentication"', () => { expect(systemMessageClass('Authentication failed')).toBe('system-error') }) it('returns "system-error" for "not configured"', () => { expect(systemMessageClass('Provider not configured')).toBe('system-error') }) it('returns "system-error" for "no api key"', () => { expect(systemMessageClass('No API key set')).toBe('system-error') }) it('returns "system-retry" for "retrying"', () => { expect(systemMessageClass('Retrying... (attempt 2/3)')).toBe('system-retry') }) it('returns empty string for neutral system messages', () => { expect(systemMessageClass('Session started')).toBe('') }) it('is case-insensitive', () => { expect(systemMessageClass('ERROR: something broke')).toBe('system-error') expect(systemMessageClass('RETRYING connection')).toBe('system-retry') }) it('prioritizes error over retry when both present', () => { // "error" check comes first in the if-chain expect(systemMessageClass('Error: retrying...')).toBe('system-error') }) }) // --------------------------------------------------------------------------- // 2. Scroll behavior logic — unit test of the scroll decision // --------------------------------------------------------------------------- // The actual useEffect in AgentBuffer does: // messagesEndRef.current?.scrollIntoView({ // behavior: state.isStreaming ? 'instant' : 'smooth' // }) // // We test the logic that determines scroll behavior separately from the // React component to keep these tests fast and deterministic. describe('scroll behavior decision', () => { function resolveScrollBehavior(isStreaming: boolean): ScrollBehavior { return isStreaming ? 'instant' : 'smooth' } it('uses instant scroll during streaming', () => { expect(resolveScrollBehavior(true)).toBe('instant') }) it('uses smooth scroll after streaming ends', () => { expect(resolveScrollBehavior(false)).toBe('smooth') }) }) // --------------------------------------------------------------------------- // 3. Scroll trigger conditions — the effect depends on specific state // --------------------------------------------------------------------------- describe('scroll trigger conditions', () => { // The useEffect dependency array is: // [state.messages.length, state.streamingBlocks.length, state.isStreaming] // We simulate state transitions and verify the effect would fire. interface ScrollState { messagesLength: number streamingBlocksLength: number isStreaming: boolean } function shouldScroll(prev: ScrollState, next: ScrollState): boolean { // The effect fires when any dep changes return ( prev.messagesLength !== next.messagesLength || prev.streamingBlocksLength !== next.streamingBlocksLength || prev.isStreaming !== next.isStreaming ) } it('triggers scroll when a new message is added', () => { const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 0, isStreaming: false } const next: ScrollState = { messagesLength: 3, streamingBlocksLength: 0, isStreaming: false } expect(shouldScroll(prev, next)).toBe(true) }) it('triggers scroll when streaming blocks update', () => { const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 1, isStreaming: true } const next: ScrollState = { messagesLength: 2, streamingBlocksLength: 2, isStreaming: true } expect(shouldScroll(prev, next)).toBe(true) }) it('triggers scroll when streaming state changes', () => { const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 3, isStreaming: true } const next: ScrollState = { messagesLength: 2, streamingBlocksLength: 3, isStreaming: false } expect(shouldScroll(prev, next)).toBe(true) }) it('does not trigger scroll when nothing changes', () => { const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 0, isStreaming: false } const next: ScrollState = { messagesLength: 2, streamingBlocksLength: 0, isStreaming: false } expect(shouldScroll(prev, next)).toBe(false) }) }) // --------------------------------------------------------------------------- // 4. Session switch — empty session hero text // --------------------------------------------------------------------------- describe('empty session detection', () => { // In AgentBuffer, the hero text is shown when: // const hasContent = state.messages.length > 0 || state.streamingBlocks.length > 0 // {!hasContent ?