diff --git a/web/app/components/base/avatar/index.spec.tsx b/web/app/components/base/avatar/index.spec.tsx
new file mode 100644
index 0000000000..e85690880b
--- /dev/null
+++ b/web/app/components/base/avatar/index.spec.tsx
@@ -0,0 +1,308 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import Avatar from './index'
+
+describe('Avatar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering tests - verify component renders correctly in different states
+ describe('Rendering', () => {
+ it('should render img element with correct alt and src when avatar URL is provided', () => {
+ const avatarUrl = 'https://example.com/avatar.jpg'
+ const props = { name: 'John Doe', avatar: avatarUrl }
+
+ render()
+
+ const img = screen.getByRole('img', { name: 'John Doe' })
+ expect(img).toBeInTheDocument()
+ expect(img).toHaveAttribute('src', avatarUrl)
+ })
+
+ it('should render fallback div with uppercase initial when avatar is null', () => {
+ const props = { name: 'alice', avatar: null }
+
+ render()
+
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ })
+ })
+
+ // Props tests - verify all props are applied correctly
+ describe('Props', () => {
+ describe('size prop', () => {
+ it.each([
+ { size: undefined, expected: '30px', label: 'default (30px)' },
+ { size: 50, expected: '50px', label: 'custom (50px)' },
+ ])('should apply $label size to img element', ({ size, expected }) => {
+ const props = { name: 'Test', avatar: 'https://example.com/avatar.jpg', size }
+
+ render()
+
+ expect(screen.getByRole('img')).toHaveStyle({
+ width: expected,
+ height: expected,
+ fontSize: expected,
+ lineHeight: expected,
+ })
+ })
+
+ it('should apply size to fallback div when avatar is null', () => {
+ const props = { name: 'Test', avatar: null, size: 40 }
+
+ render()
+
+ const textElement = screen.getByText('T')
+ const outerDiv = textElement.parentElement as HTMLElement
+ expect(outerDiv).toHaveStyle({ width: '40px', height: '40px' })
+ })
+ })
+
+ describe('className prop', () => {
+ it('should merge className with default avatar classes on img', () => {
+ const props = {
+ name: 'Test',
+ avatar: 'https://example.com/avatar.jpg',
+ className: 'custom-class',
+ }
+
+ render()
+
+ const img = screen.getByRole('img')
+ expect(img).toHaveClass('custom-class')
+ expect(img).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
+ })
+
+ it('should merge className with default avatar classes on fallback div', () => {
+ const props = {
+ name: 'Test',
+ avatar: null,
+ className: 'my-custom-class',
+ }
+
+ render()
+
+ const textElement = screen.getByText('T')
+ const outerDiv = textElement.parentElement as HTMLElement
+ expect(outerDiv).toHaveClass('my-custom-class')
+ expect(outerDiv).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
+ })
+ })
+
+ describe('textClassName prop', () => {
+ it('should apply textClassName to the initial text element', () => {
+ const props = {
+ name: 'Test',
+ avatar: null,
+ textClassName: 'custom-text-class',
+ }
+
+ render()
+
+ const textElement = screen.getByText('T')
+ expect(textElement).toHaveClass('custom-text-class')
+ expect(textElement).toHaveClass('scale-[0.4]', 'text-center', 'text-white')
+ })
+ })
+ })
+
+ // State Management tests - verify useState and useEffect behavior
+ describe('State Management', () => {
+ it('should switch to fallback when image fails to load', async () => {
+ const props = { name: 'John', avatar: 'https://example.com/broken.jpg' }
+ render()
+ const img = screen.getByRole('img')
+
+ fireEvent.error(img)
+
+ await waitFor(() => {
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ })
+ expect(screen.getByText('J')).toBeInTheDocument()
+ })
+
+ it('should reset error state when avatar URL changes', async () => {
+ const initialProps = { name: 'John', avatar: 'https://example.com/broken.jpg' }
+ const { rerender } = render()
+ const img = screen.getByRole('img')
+
+ // First, trigger error
+ fireEvent.error(img)
+ await waitFor(() => {
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ })
+ expect(screen.getByText('J')).toBeInTheDocument()
+
+ rerender()
+
+ await waitFor(() => {
+ expect(screen.getByRole('img')).toBeInTheDocument()
+ })
+ expect(screen.queryByText('J')).not.toBeInTheDocument()
+ })
+
+ it('should not reset error state if avatar becomes null', async () => {
+ const initialProps = { name: 'John', avatar: 'https://example.com/broken.jpg' }
+ const { rerender } = render()
+
+ // Trigger error
+ fireEvent.error(screen.getByRole('img'))
+ await waitFor(() => {
+ expect(screen.getByText('J')).toBeInTheDocument()
+ })
+
+ rerender()
+
+ await waitFor(() => {
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ })
+ expect(screen.getByText('J')).toBeInTheDocument()
+ })
+ })
+
+ // Event Handlers tests - verify onError callback behavior
+ describe('Event Handlers', () => {
+ it('should call onError with true when image fails to load', () => {
+ const onErrorMock = vi.fn()
+ const props = {
+ name: 'John',
+ avatar: 'https://example.com/broken.jpg',
+ onError: onErrorMock,
+ }
+ render()
+
+ fireEvent.error(screen.getByRole('img'))
+
+ expect(onErrorMock).toHaveBeenCalledTimes(1)
+ expect(onErrorMock).toHaveBeenCalledWith(true)
+ })
+
+ it('should call onError with false when image loads successfully', () => {
+ const onErrorMock = vi.fn()
+ const props = {
+ name: 'John',
+ avatar: 'https://example.com/avatar.jpg',
+ onError: onErrorMock,
+ }
+ render()
+
+ fireEvent.load(screen.getByRole('img'))
+
+ expect(onErrorMock).toHaveBeenCalledTimes(1)
+ expect(onErrorMock).toHaveBeenCalledWith(false)
+ })
+
+ it('should not throw when onError is not provided', async () => {
+ const props = { name: 'John', avatar: 'https://example.com/broken.jpg' }
+ render()
+
+ expect(() => fireEvent.error(screen.getByRole('img'))).not.toThrow()
+ await waitFor(() => {
+ expect(screen.getByText('J')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // Edge Cases tests - verify handling of unusual inputs
+ describe('Edge Cases', () => {
+ it('should handle empty string name gracefully', () => {
+ const props = { name: '', avatar: null }
+
+ const { container } = render()
+
+ // Note: Using querySelector here because empty name produces no visible text,
+ // making semantic queries (getByRole, getByText) impossible
+ const textElement = container.querySelector('.text-white') as HTMLElement
+ expect(textElement).toBeInTheDocument()
+ expect(textElement.textContent).toBe('')
+ })
+
+ it.each([
+ { name: '中文名', expected: '中', label: 'Chinese characters' },
+ { name: '123User', expected: '1', label: 'number' },
+ ])('should display first character when name starts with $label', ({ name, expected }) => {
+ const props = { name, avatar: null }
+
+ render()
+
+ expect(screen.getByText(expected)).toBeInTheDocument()
+ })
+
+ it('should handle empty string avatar as falsy value', () => {
+ const props = { name: 'Test', avatar: '' as string | null }
+
+ render()
+
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
+ expect(screen.getByText('T')).toBeInTheDocument()
+ })
+
+ it('should handle undefined className and textClassName', () => {
+ const props = { name: 'Test', avatar: null }
+
+ render()
+
+ const textElement = screen.getByText('T')
+ const outerDiv = textElement.parentElement as HTMLElement
+ expect(outerDiv).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
+ })
+
+ it.each([
+ { size: 0, expected: '0px', label: 'zero' },
+ { size: 1000, expected: '1000px', label: 'very large' },
+ ])('should handle $label size value', ({ size, expected }) => {
+ const props = { name: 'Test', avatar: null, size }
+
+ render()
+
+ const textElement = screen.getByText('T')
+ const outerDiv = textElement.parentElement as HTMLElement
+ expect(outerDiv).toHaveStyle({ width: expected, height: expected })
+ })
+ })
+
+ // Combined props tests - verify props work together correctly
+ describe('Combined Props', () => {
+ it('should apply all props correctly when used together', () => {
+ const onErrorMock = vi.fn()
+ const props = {
+ name: 'Test User',
+ avatar: 'https://example.com/avatar.jpg',
+ size: 64,
+ className: 'custom-avatar',
+ onError: onErrorMock,
+ }
+
+ render()
+
+ const img = screen.getByRole('img')
+ expect(img).toHaveAttribute('alt', 'Test User')
+ expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg')
+ expect(img).toHaveStyle({ width: '64px', height: '64px' })
+ expect(img).toHaveClass('custom-avatar')
+
+ // Trigger load to verify onError callback
+ fireEvent.load(img)
+ expect(onErrorMock).toHaveBeenCalledWith(false)
+ })
+
+ it('should apply all fallback props correctly when used together', () => {
+ const props = {
+ name: 'Fallback User',
+ avatar: null,
+ size: 48,
+ className: 'fallback-custom',
+ textClassName: 'custom-text-style',
+ }
+
+ render()
+
+ const textElement = screen.getByText('F')
+ const outerDiv = textElement.parentElement as HTMLElement
+ expect(outerDiv).toHaveClass('fallback-custom')
+ expect(outerDiv).toHaveStyle({ width: '48px', height: '48px' })
+ expect(textElement).toHaveClass('custom-text-style')
+ })
+ })
+})