diff --git a/web/app/components/base/copy-feedback/index.spec.tsx b/web/app/components/base/copy-feedback/index.spec.tsx
new file mode 100644
index 0000000000..f89331c1bb
--- /dev/null
+++ b/web/app/components/base/copy-feedback/index.spec.tsx
@@ -0,0 +1,93 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import CopyFeedback, { CopyFeedbackNew } from '.'
+
+const mockCopy = vi.fn()
+const mockReset = vi.fn()
+let mockCopied = false
+
+vi.mock('foxact/use-clipboard', () => ({
+ useClipboard: () => ({
+ copy: mockCopy,
+ reset: mockReset,
+ copied: mockCopied,
+ }),
+}))
+
+describe('CopyFeedback', () => {
+ beforeEach(() => {
+ mockCopied = false
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('renders the action button with copy icon', () => {
+ render(
)
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('renders the copied icon when copied is true', () => {
+ mockCopied = true
+ render(
)
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('calls copy with content when clicked', () => {
+ render(
)
+ const button = screen.getByRole('button')
+ fireEvent.click(button.firstChild as Element)
+ expect(mockCopy).toHaveBeenCalledWith('test content')
+ })
+
+ it('calls reset on mouse leave', () => {
+ render(
)
+ const button = screen.getByRole('button')
+ fireEvent.mouseLeave(button.firstChild as Element)
+ expect(mockReset).toHaveBeenCalledTimes(1)
+ })
+ })
+})
+
+describe('CopyFeedbackNew', () => {
+ beforeEach(() => {
+ mockCopied = false
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('renders the component', () => {
+ const { container } = render(
)
+ expect(container.querySelector('.cursor-pointer')).toBeInTheDocument()
+ })
+
+ it('applies copied CSS class when copied is true', () => {
+ mockCopied = true
+ const { container } = render(
)
+ const feedbackIcon = container.firstChild?.firstChild as Element
+ expect(feedbackIcon).toHaveClass(/_copied_.*/)
+ })
+
+ it('does not apply copied CSS class when not copied', () => {
+ const { container } = render(
)
+ const feedbackIcon = container.firstChild?.firstChild as Element
+ expect(feedbackIcon).not.toHaveClass(/_copied_.*/)
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('calls copy with content when clicked', () => {
+ const { container } = render(
)
+ const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement
+ fireEvent.click(clickableArea)
+ expect(mockCopy).toHaveBeenCalledWith('test content')
+ })
+
+ it('calls reset on mouse leave', () => {
+ const { container } = render(
)
+ const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement
+ fireEvent.mouseLeave(clickableArea)
+ expect(mockReset).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/web/app/components/base/emoji-picker/Inner.spec.tsx b/web/app/components/base/emoji-picker/Inner.spec.tsx
new file mode 100644
index 0000000000..cd993af9e8
--- /dev/null
+++ b/web/app/components/base/emoji-picker/Inner.spec.tsx
@@ -0,0 +1,169 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import EmojiPickerInner from './Inner'
+
+vi.mock('@emoji-mart/data', () => ({
+ default: {
+ categories: [
+ {
+ id: 'nature',
+ emojis: ['rabbit', 'bear'],
+ },
+ {
+ id: 'food',
+ emojis: ['apple', 'orange'],
+ },
+ ],
+ },
+}))
+
+vi.mock('emoji-mart', () => ({
+ init: vi.fn(),
+}))
+
+vi.mock('@/utils/emoji', () => ({
+ searchEmoji: vi.fn().mockResolvedValue(['dog', 'cat']),
+}))
+
+describe('EmojiPickerInner', () => {
+ const mockOnSelect = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // Define the custom element to avoid "Unknown custom element" warnings
+ if (!customElements.get('em-emoji')) {
+ customElements.define('em-emoji', class extends HTMLElement {
+ static get observedAttributes() { return ['id'] }
+ })
+ }
+ })
+
+ describe('Rendering', () => {
+ it('renders initial categories and emojis correctly', () => {
+ render(
)
+
+ expect(screen.getByText('nature')).toBeInTheDocument()
+ expect(screen.getByText('food')).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('calls searchEmoji and displays results when typing in search input', async () => {
+ render(
)
+ const searchInput = screen.getByPlaceholderText('Search emojis...')
+
+ await act(async () => {
+ fireEvent.change(searchInput, { target: { value: 'anim' } })
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Search')).toBeInTheDocument()
+ })
+
+ const searchSection = screen.getByText('Search').parentElement
+ expect(searchSection?.querySelectorAll('em-emoji').length).toBe(2)
+ })
+
+ it('updates selected emoji and calls onSelect when an emoji is clicked', async () => {
+ render(
)
+ const emojiContainers = screen.getAllByTestId(/^emoji-container-/)
+
+ await act(async () => {
+ fireEvent.click(emojiContainers[0])
+ })
+
+ expect(mockOnSelect).toHaveBeenCalledWith('rabbit', expect.any(String))
+ })
+
+ it('toggles style colors display when clicking the chevron', async () => {
+ render(
)
+
+ expect(screen.queryByText('#FFEAD5')).not.toBeInTheDocument()
+
+ const toggleButton = screen.getByTestId('toggle-colors')
+ expect(toggleButton).toBeInTheDocument()
+
+ await act(async () => {
+ fireEvent.click(toggleButton!)
+ })
+
+ expect(screen.getByText('Choose Style')).toBeInTheDocument()
+ const colorOptions = document.querySelectorAll('[style^="background:"]')
+ expect(colorOptions.length).toBeGreaterThan(0)
+ })
+
+ it('updates background color and calls onSelect when a color is clicked', async () => {
+ render(
)
+
+ const toggleButton = screen.getByTestId('toggle-colors')
+ await act(async () => {
+ fireEvent.click(toggleButton!)
+ })
+
+ const emojiContainers = screen.getAllByTestId(/^emoji-container-/)
+ await act(async () => {
+ fireEvent.click(emojiContainers[0])
+ })
+
+ mockOnSelect.mockClear()
+
+ const colorOptions = document.querySelectorAll('[style^="background:"]')
+ await act(async () => {
+ fireEvent.click(colorOptions[1].parentElement!)
+ })
+
+ expect(mockOnSelect).toHaveBeenCalledWith('rabbit', '#E4FBCC')
+ })
+
+ it('updates selected emoji when clicking a search result', async () => {
+ render(
)
+ const searchInput = screen.getByPlaceholderText('Search emojis...')
+
+ await act(async () => {
+ fireEvent.change(searchInput, { target: { value: 'anim' } })
+ })
+
+ await screen.findByText('Search')
+
+ const searchEmojis = screen.getAllByTestId(/^emoji-search-result-/)
+ await act(async () => {
+ fireEvent.click(searchEmojis![0])
+ })
+
+ expect(mockOnSelect).toHaveBeenCalledWith('dog', expect.any(String))
+ })
+
+ it('toggles style colors display back and forth', async () => {
+ render(
)
+
+ const toggleButton = screen.getByTestId('toggle-colors')
+
+ await act(async () => {
+ fireEvent.click(toggleButton!)
+ })
+ expect(screen.getByText('Choose Style')).toBeInTheDocument()
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('toggle-colors')!) // It should be the other icon now
+ })
+ expect(screen.queryByText('#FFEAD5')).not.toBeInTheDocument()
+ })
+
+ it('clears search results when input is cleared', async () => {
+ render(
)
+ const searchInput = screen.getByPlaceholderText('Search emojis...')
+
+ await act(async () => {
+ fireEvent.change(searchInput, { target: { value: 'anim' } })
+ })
+
+ await screen.findByText('Search')
+
+ await act(async () => {
+ fireEvent.change(searchInput, { target: { value: '' } })
+ })
+
+ expect(screen.queryByText('Search')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/base/emoji-picker/Inner.tsx b/web/app/components/base/emoji-picker/Inner.tsx
index f125cfa63b..4f249cd2e8 100644
--- a/web/app/components/base/emoji-picker/Inner.tsx
+++ b/web/app/components/base/emoji-picker/Inner.tsx
@@ -3,8 +3,6 @@ import type { EmojiMartData } from '@emoji-mart/data'
import type { ChangeEvent, FC } from 'react'
import data from '@emoji-mart/data'
import {
- ChevronDownIcon,
- ChevronUpIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline'
import { init } from 'emoji-mart'
@@ -97,7 +95,7 @@ const EmojiPickerInner: FC
= ({
{isSearching && (
<>
-
Search
+
Search
{searchedEmojis.map((emoji: string, index: number) => {
return (
@@ -108,7 +106,7 @@ const EmojiPickerInner: FC
= ({
setSelectedEmoji(emoji)
}}
>
-
@@ -122,7 +120,7 @@ const EmojiPickerInner: FC = ({
{categories.map((category, index: number) => {
return (
-
{category.id}
+
{category.id}
{category.emojis.map((emoji, index: number) => {
return (
@@ -133,7 +131,7 @@ const EmojiPickerInner: FC
= ({
setSelectedEmoji(emoji)
}}
>
-
@@ -148,10 +146,10 @@ const EmojiPickerInner: FC = ({
{/* Color Select */}
-
Choose Style
+
Choose Style
{showStyleColors
- ?
setShowStyleColors(!showStyleColors)} />
- : setShowStyleColors(!showStyleColors)} />}
+ ? setShowStyleColors(!showStyleColors)} data-testid="toggle-colors" />
+ : setShowStyleColors(!showStyleColors)} data-testid="toggle-colors" />}
{showStyleColors && (
diff --git a/web/app/components/base/emoji-picker/index.spec.tsx b/web/app/components/base/emoji-picker/index.spec.tsx
new file mode 100644
index 0000000000..f554549cee
--- /dev/null
+++ b/web/app/components/base/emoji-picker/index.spec.tsx
@@ -0,0 +1,115 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import EmojiPicker from './index'
+
+vi.mock('@emoji-mart/data', () => ({
+ default: {
+ categories: [
+ {
+ id: 'category1',
+ name: 'Category 1',
+ emojis: ['emoji1', 'emoji2'],
+ },
+ ],
+ },
+}))
+
+vi.mock('emoji-mart', () => ({
+ init: vi.fn(),
+ SearchIndex: {
+ search: vi.fn().mockResolvedValue([{ skins: [{ native: '🔍' }] }]),
+ },
+}))
+
+vi.mock('@/utils/emoji', () => ({
+ searchEmoji: vi.fn().mockResolvedValue(['🔍']),
+}))
+
+describe('EmojiPicker', () => {
+ const mockOnSelect = vi.fn()
+ const mockOnClose = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('renders nothing when isModal is false', () => {
+ const { container } = render(
+
,
+ )
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('renders modal when isModal is true', async () => {
+ await act(async () => {
+ render(
+
,
+ )
+ })
+ expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
+ expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
+ expect(screen.getByText(/OK/i)).toBeInTheDocument()
+ })
+
+ it('OK button is disabled initially', async () => {
+ await act(async () => {
+ render(
+
,
+ )
+ })
+ const okButton = screen.getByText(/OK/i).closest('button')
+ expect(okButton).toBeDisabled()
+ })
+
+ it('applies custom className to modal wrapper', async () => {
+ const customClass = 'custom-wrapper-class'
+ await act(async () => {
+ render(
+
,
+ )
+ })
+ const dialog = screen.getByRole('dialog')
+ expect(dialog).toHaveClass(customClass)
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('calls onSelect with selected emoji and background when OK is clicked', async () => {
+ await act(async () => {
+ render(
+
,
+ )
+ })
+
+ const emojiWrappers = screen.getAllByTestId(/^emoji-container-/)
+ expect(emojiWrappers.length).toBeGreaterThan(0)
+ await act(async () => {
+ fireEvent.click(emojiWrappers[0])
+ })
+
+ const okButton = screen.getByText(/OK/i)
+ expect(okButton.closest('button')).not.toBeDisabled()
+
+ await act(async () => {
+ fireEvent.click(okButton)
+ })
+
+ expect(mockOnSelect).toHaveBeenCalledWith(expect.any(String), expect.any(String))
+ })
+
+ it('calls onClose when Cancel is clicked', async () => {
+ await act(async () => {
+ render(
+
,
+ )
+ })
+
+ const cancelButton = screen.getByText(/Cancel/i)
+ await act(async () => {
+ fireEvent.click(cancelButton)
+ })
+
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/base/file-thumb/image-render.spec.tsx b/web/app/components/base/file-thumb/image-render.spec.tsx
new file mode 100644
index 0000000000..cef41b912c
--- /dev/null
+++ b/web/app/components/base/file-thumb/image-render.spec.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from '@testing-library/react'
+import ImageRender from './image-render'
+
+describe('ImageRender Component', () => {
+ const mockProps = {
+ sourceUrl: 'https://example.com/image.jpg',
+ name: 'test-image.jpg',
+ }
+
+ describe('Render', () => {
+ it('renders image with correct src and alt', () => {
+ render(
)
+
+ const img = screen.getByRole('img')
+ expect(img).toBeInTheDocument()
+ expect(img).toHaveAttribute('src', mockProps.sourceUrl)
+ expect(img).toHaveAttribute('alt', mockProps.name)
+ })
+ })
+})
diff --git a/web/app/components/base/file-thumb/index.spec.tsx b/web/app/components/base/file-thumb/index.spec.tsx
new file mode 100644
index 0000000000..205e6f8d6f
--- /dev/null
+++ b/web/app/components/base/file-thumb/index.spec.tsx
@@ -0,0 +1,74 @@
+/* eslint-disable next/no-img-element */
+import type { ImgHTMLAttributes } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import FileThumb from './index'
+
+vi.mock('next/image', () => ({
+ __esModule: true,
+ default: (props: ImgHTMLAttributes
) =>
,
+}))
+
+describe('FileThumb Component', () => {
+ const mockImageFile = {
+ name: 'test-image.jpg',
+ mimeType: 'image/jpeg',
+ extension: '.jpg',
+ size: 1024,
+ sourceUrl: 'https://example.com/test-image.jpg',
+ }
+
+ const mockNonImageFile = {
+ name: 'test.pdf',
+ mimeType: 'application/pdf',
+ extension: '.pdf',
+ size: 2048,
+ sourceUrl: 'https://example.com/test.pdf',
+ }
+
+ describe('Render', () => {
+ it('renders image thumbnail correctly', () => {
+ render()
+
+ const img = screen.getByAltText(mockImageFile.name)
+ expect(img).toBeInTheDocument()
+ expect(img).toHaveAttribute('src', mockImageFile.sourceUrl)
+ })
+
+ it('renders file type icon for non-image files', () => {
+ const { container } = render()
+
+ expect(screen.queryByAltText(mockNonImageFile.name)).not.toBeInTheDocument()
+ const svgIcon = container.querySelector('svg')
+ expect(svgIcon).toBeInTheDocument()
+ })
+
+ it('wraps content inside tooltip', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const trigger = screen.getByAltText(mockImageFile.name)
+ expect(trigger).toBeInTheDocument()
+
+ await user.hover(trigger)
+
+ const tooltipContent = await screen.findByText(mockImageFile.name)
+ expect(tooltipContent).toBeInTheDocument()
+ })
+ })
+
+ describe('Interaction', () => {
+ it('calls onClick with file when clicked', () => {
+ const onClick = vi.fn()
+
+ render()
+
+ const clickable = screen.getByAltText(mockImageFile.name).closest('div') as HTMLElement
+
+ fireEvent.click(clickable)
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ expect(onClick).toHaveBeenCalledWith(mockImageFile)
+ })
+ })
+})
diff --git a/web/app/components/base/linked-apps-panel/index.spec.tsx b/web/app/components/base/linked-apps-panel/index.spec.tsx
new file mode 100644
index 0000000000..fb7e2e7e2b
--- /dev/null
+++ b/web/app/components/base/linked-apps-panel/index.spec.tsx
@@ -0,0 +1,93 @@
+import { render, screen } from '@testing-library/react'
+import * as React from 'react'
+import { vi } from 'vitest'
+import { AppModeEnum } from '@/types/app'
+import LinkedAppsPanel from './index'
+
+vi.mock('next/link', () => ({
+ default: ({ children, href, className }: { children: React.ReactNode, href: string, className: string }) => (
+
+ {children}
+
+ ),
+}))
+
+describe('LinkedAppsPanel Component', () => {
+ const mockRelatedApps = [
+ {
+ id: 'app-1',
+ name: 'Chatbot App',
+ mode: AppModeEnum.CHAT,
+ icon_type: 'emoji' as const,
+ icon: '🤖',
+ icon_background: '#FFEAD5',
+ icon_url: '',
+ },
+ {
+ id: 'app-2',
+ name: 'Workflow App',
+ mode: AppModeEnum.WORKFLOW,
+ icon_type: 'image' as const,
+ icon: 'file-id',
+ icon_background: '#E4FBCC',
+ icon_url: 'https://example.com/icon.png',
+ },
+ {
+ id: 'app-3',
+ name: '',
+ mode: AppModeEnum.AGENT_CHAT,
+ icon_type: 'emoji' as const,
+ icon: '🕵️',
+ icon_background: '#D3F8DF',
+ icon_url: '',
+ },
+ ]
+
+ describe('Render', () => {
+ it('renders correctly with multiple apps', () => {
+ render()
+
+ const items = screen.getAllByTestId('link-item')
+ expect(items).toHaveLength(3)
+
+ expect(screen.getByText('Chatbot App')).toBeInTheDocument()
+ expect(screen.getByText('Workflow App')).toBeInTheDocument()
+ expect(screen.getByText('--')).toBeInTheDocument()
+ })
+
+ it('displays correct app mode labels', () => {
+ render()
+
+ expect(screen.getByText('Chatbot')).toBeInTheDocument()
+ expect(screen.getByText('Workflow')).toBeInTheDocument()
+ expect(screen.getByText('Agent')).toBeInTheDocument()
+ })
+
+ it('hides app name and centers content in mobile mode', () => {
+ render()
+
+ expect(screen.queryByText('Chatbot App')).not.toBeInTheDocument()
+ expect(screen.queryByText('Workflow App')).not.toBeInTheDocument()
+
+ const items = screen.getAllByTestId('link-item')
+ expect(items[0]).toHaveClass('justify-center')
+ })
+
+ it('handles empty relatedApps list gracefully', () => {
+ const { container } = render()
+ const items = screen.queryAllByTestId('link-item')
+ expect(items).toHaveLength(0)
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ describe('Interaction', () => {
+ it('renders correct links for each app', () => {
+ render()
+
+ const items = screen.getAllByTestId('link-item')
+ expect(items[0]).toHaveAttribute('href', '/app/app-1/overview')
+ expect(items[1]).toHaveAttribute('href', '/app/app-2/overview')
+ })
+ })
+})
diff --git a/web/app/components/base/list-empty/horizontal-line.spec.tsx b/web/app/components/base/list-empty/horizontal-line.spec.tsx
new file mode 100644
index 0000000000..934183f1d3
--- /dev/null
+++ b/web/app/components/base/list-empty/horizontal-line.spec.tsx
@@ -0,0 +1,33 @@
+import { render } from '@testing-library/react'
+import * as React from 'react'
+import HorizontalLine from './horizontal-line'
+
+describe('HorizontalLine', () => {
+ describe('Render', () => {
+ it('renders correctly', () => {
+ const { container } = render()
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ expect(svg).toHaveAttribute('width', '240')
+ expect(svg).toHaveAttribute('height', '2')
+ })
+
+ it('renders linear gradient definition', () => {
+ const { container } = render()
+ const defs = container.querySelector('defs')
+ const linearGradient = container.querySelector('linearGradient')
+ expect(defs).toBeInTheDocument()
+ expect(linearGradient).toBeInTheDocument()
+ expect(linearGradient).toHaveAttribute('id', 'paint0_linear_8619_59125')
+ })
+ })
+
+ describe('Style', () => {
+ it('applies custom className', () => {
+ const testClass = 'custom-test-class'
+ const { container } = render()
+ const svg = container.querySelector('svg')
+ expect(svg).toHaveClass(testClass)
+ })
+ })
+})
diff --git a/web/app/components/base/list-empty/index.spec.tsx b/web/app/components/base/list-empty/index.spec.tsx
new file mode 100644
index 0000000000..aac1480a60
--- /dev/null
+++ b/web/app/components/base/list-empty/index.spec.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react'
+import * as React from 'react'
+import ListEmpty from './index'
+
+describe('ListEmpty Component', () => {
+ describe('Render', () => {
+ it('renders default icon when no icon is provided', () => {
+ const { container } = render()
+ expect(container.querySelector('[data-icon="Variable02"]')).toBeInTheDocument()
+ })
+
+ it('renders custom icon when provided', () => {
+ const { container } = render(} />)
+ expect(container.querySelector('[data-icon="Variable02"]')).not.toBeInTheDocument()
+ expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
+ })
+
+ it('renders design lines', () => {
+ const { container } = render()
+ const svgs = container.querySelectorAll('svg')
+ expect(svgs).toHaveLength(5)
+ })
+ })
+
+ describe('Props', () => {
+ it('renders title and description correctly', () => {
+ const testTitle = 'Empty List'
+ const testDescription = No items found
+
+ render()
+
+ expect(screen.getByText(testTitle)).toBeInTheDocument()
+ expect(screen.getByTestId('desc')).toBeInTheDocument()
+ expect(screen.getByText('No items found')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/base/list-empty/vertical-line.spec.tsx b/web/app/components/base/list-empty/vertical-line.spec.tsx
new file mode 100644
index 0000000000..47e071d7fa
--- /dev/null
+++ b/web/app/components/base/list-empty/vertical-line.spec.tsx
@@ -0,0 +1,33 @@
+import { render } from '@testing-library/react'
+import * as React from 'react'
+import VerticalLine from './vertical-line'
+
+describe('VerticalLine', () => {
+ describe('Render', () => {
+ it('renders correctly', () => {
+ const { container } = render()
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ expect(svg).toHaveAttribute('width', '2')
+ expect(svg).toHaveAttribute('height', '132')
+ })
+
+ it('renders linear gradient definition', () => {
+ const { container } = render()
+ const defs = container.querySelector('defs')
+ const linearGradient = container.querySelector('linearGradient')
+ expect(defs).toBeInTheDocument()
+ expect(linearGradient).toBeInTheDocument()
+ expect(linearGradient).toHaveAttribute('id', 'paint0_linear_8619_59128')
+ })
+ })
+
+ describe('Style', () => {
+ it('applies custom className', () => {
+ const testClass = 'custom-test-class'
+ const { container } = render()
+ const svg = container.querySelector('svg')
+ expect(svg).toHaveClass(testClass)
+ })
+ })
+})
diff --git a/web/app/components/base/logo/dify-logo.spec.tsx b/web/app/components/base/logo/dify-logo.spec.tsx
new file mode 100644
index 0000000000..834fb8f28e
--- /dev/null
+++ b/web/app/components/base/logo/dify-logo.spec.tsx
@@ -0,0 +1,94 @@
+import { render, screen } from '@testing-library/react'
+import useTheme from '@/hooks/use-theme'
+import { Theme } from '@/types/app'
+import DifyLogo from './dify-logo'
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: vi.fn(),
+}))
+
+vi.mock('@/utils/var', () => ({
+ basePath: '/test-base-path',
+}))
+
+describe('DifyLogo', () => {
+ const mockUseTheme = {
+ theme: Theme.light,
+ themes: ['light', 'dark'],
+ setTheme: vi.fn(),
+ resolvedTheme: Theme.light,
+ systemTheme: Theme.light,
+ forcedTheme: undefined,
+ }
+
+ beforeEach(() => {
+ vi.mocked(useTheme).mockReturnValue(mockUseTheme as ReturnType)
+ })
+
+ describe('Render', () => {
+ it('renders correctly with default props', () => {
+ render()
+ const img = screen.getByRole('img', { name: /dify logo/i })
+ expect(img).toBeInTheDocument()
+ expect(img).toHaveAttribute('src', '/test-base-path/logo/logo.svg')
+ })
+ })
+
+ describe('Props', () => {
+ it('applies custom size correctly', () => {
+ const { rerender } = render()
+ let img = screen.getByRole('img', { name: /dify logo/i })
+ expect(img).toHaveClass('w-16')
+ expect(img).toHaveClass('h-7')
+
+ rerender()
+ img = screen.getByRole('img', { name: /dify logo/i })
+ expect(img).toHaveClass('w-9')
+ expect(img).toHaveClass('h-4')
+ })
+
+ it('applies custom style correctly', () => {
+ render()
+ const img = screen.getByRole('img', { name: /dify logo/i })
+ expect(img).toHaveAttribute('src', '/test-base-path/logo/logo-monochrome-white.svg')
+ })
+
+ it('applies custom className', () => {
+ render()
+ const img = screen.getByRole('img', { name: /dify logo/i })
+ expect(img).toHaveClass('custom-test-class')
+ })
+ })
+
+ describe('Theme behavior', () => {
+ it('uses monochromeWhite logo in dark theme when style is default', () => {
+ vi.mocked(useTheme).mockReturnValue({
+ ...mockUseTheme,
+ theme: Theme.dark,
+ } as ReturnType)
+ render()
+ const img = screen.getByRole('img', { name: /dify logo/i })
+ expect(img).toHaveAttribute('src', '/test-base-path/logo/logo-monochrome-white.svg')
+ })
+
+ it('uses monochromeWhite logo in dark theme when style is monochromeWhite', () => {
+ vi.mocked(useTheme).mockReturnValue({
+ ...mockUseTheme,
+ theme: Theme.dark,
+ } as ReturnType)
+ render()
+ const img = screen.getByRole('img', { name: /dify logo/i })
+ expect(img).toHaveAttribute('src', '/test-base-path/logo/logo-monochrome-white.svg')
+ })
+
+ it('uses default logo in light theme when style is default', () => {
+ vi.mocked(useTheme).mockReturnValue({
+ ...mockUseTheme,
+ theme: Theme.light,
+ } as ReturnType)
+ render()
+ const img = screen.getByRole('img', { name: /dify logo/i })
+ expect(img).toHaveAttribute('src', '/test-base-path/logo/logo.svg')
+ })
+ })
+})
diff --git a/web/app/components/base/logo/logo-embedded-chat-avatar.spec.tsx b/web/app/components/base/logo/logo-embedded-chat-avatar.spec.tsx
new file mode 100644
index 0000000000..f3c374dbd9
--- /dev/null
+++ b/web/app/components/base/logo/logo-embedded-chat-avatar.spec.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@testing-library/react'
+import LogoEmbeddedChatAvatar from './logo-embedded-chat-avatar'
+
+vi.mock('@/utils/var', () => ({
+ basePath: '/test-base-path',
+}))
+
+describe('LogoEmbeddedChatAvatar', () => {
+ describe('Render', () => {
+ it('renders correctly with default props', () => {
+ render()
+ const img = screen.getByRole('img', { name: /logo/i })
+ expect(img).toBeInTheDocument()
+ expect(img).toHaveAttribute('src', '/test-base-path/logo/logo-embedded-chat-avatar.png')
+ })
+ })
+
+ describe('Props', () => {
+ it('applies custom className correctly', () => {
+ const customClass = 'custom-avatar-class'
+ render()
+ const img = screen.getByRole('img', { name: /logo/i })
+ expect(img).toHaveClass(customClass)
+ })
+
+ it('has valid alt text', () => {
+ render()
+ const img = screen.getByRole('img', { name: /logo/i })
+ expect(img).toHaveAttribute('alt', 'logo')
+ })
+ })
+})
diff --git a/web/app/components/base/logo/logo-embedded-chat-header.spec.tsx b/web/app/components/base/logo/logo-embedded-chat-header.spec.tsx
new file mode 100644
index 0000000000..74247036d3
--- /dev/null
+++ b/web/app/components/base/logo/logo-embedded-chat-header.spec.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@testing-library/react'
+import LogoEmbeddedChatHeader from './logo-embedded-chat-header'
+
+vi.mock('@/utils/var', () => ({
+ basePath: '/test-base-path',
+}))
+
+describe('LogoEmbeddedChatHeader', () => {
+ it('renders correctly with default props', () => {
+ const { container } = render()
+ const img = screen.getByRole('img', { name: /logo/i })
+ expect(img).toBeInTheDocument()
+ expect(img).toHaveAttribute('src', '/test-base-path/logo/logo-embedded-chat-header.png')
+
+ const sources = container.querySelectorAll('source')
+ expect(sources).toHaveLength(3)
+ expect(sources[0]).toHaveAttribute('srcSet', '/logo/logo-embedded-chat-header.png')
+ expect(sources[1]).toHaveAttribute('srcSet', '/logo/logo-embedded-chat-header@2x.png')
+ expect(sources[2]).toHaveAttribute('srcSet', '/logo/logo-embedded-chat-header@3x.png')
+ })
+
+ it('applies custom className correctly', () => {
+ const customClass = 'custom-header-class'
+ render()
+ const img = screen.getByRole('img', { name: /logo/i })
+ expect(img).toHaveClass(customClass)
+ expect(img).toHaveClass('h-6')
+ })
+})
diff --git a/web/app/components/base/logo/logo-site.spec.tsx b/web/app/components/base/logo/logo-site.spec.tsx
new file mode 100644
index 0000000000..956485305b
--- /dev/null
+++ b/web/app/components/base/logo/logo-site.spec.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from '@testing-library/react'
+import LogoSite from './logo-site'
+
+vi.mock('@/utils/var', () => ({
+ basePath: '/test-base-path',
+}))
+
+describe('LogoSite', () => {
+ it('renders correctly with default props', () => {
+ render()
+ const img = screen.getByRole('img', { name: /logo/i })
+ expect(img).toBeInTheDocument()
+ expect(img).toHaveAttribute('src', '/test-base-path/logo/logo.png')
+ })
+
+ it('applies custom className correctly', () => {
+ const customClass = 'custom-site-class'
+ render()
+ const img = screen.getByRole('img', { name: /logo/i })
+ expect(img).toHaveClass(customClass)
+ })
+})
diff --git a/web/app/components/base/search-input/index.spec.tsx b/web/app/components/base/search-input/index.spec.tsx
new file mode 100644
index 0000000000..db70087d85
--- /dev/null
+++ b/web/app/components/base/search-input/index.spec.tsx
@@ -0,0 +1,91 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import SearchInput from '.'
+
+describe('SearchInput', () => {
+ describe('Render', () => {
+ it('renders correctly with default props', () => {
+ render( {}} />)
+ const input = screen.getByPlaceholderText('common.operation.search')
+ expect(input).toBeInTheDocument()
+ expect(input).toHaveValue('')
+ })
+
+ it('renders custom placeholder', () => {
+ render( {}} placeholder="Custom Placeholder" />)
+ expect(screen.getByPlaceholderText('Custom Placeholder')).toBeInTheDocument()
+ })
+
+ it('shows clear button when value is present', () => {
+ const onChange = vi.fn()
+ render()
+
+ const clearButton = screen.getByLabelText('common.operation.clear')
+ expect(clearButton).toBeInTheDocument()
+ })
+ })
+
+ describe('Interaction', () => {
+ it('calls onChange when typing', () => {
+ const onChange = vi.fn()
+ render()
+ const input = screen.getByPlaceholderText('common.operation.search')
+
+ fireEvent.change(input, { target: { value: 'test' } })
+ expect(onChange).toHaveBeenCalledWith('test')
+ })
+
+ it('handles composition events', () => {
+ const onChange = vi.fn()
+ render()
+ const input = screen.getByPlaceholderText('common.operation.search')
+
+ // Start composition
+ fireEvent.compositionStart(input)
+ fireEvent.change(input, { target: { value: 'final' } })
+
+ // While composing, onChange should NOT be called
+ expect(onChange).not.toHaveBeenCalled()
+ expect(input).toHaveValue('final')
+
+ // End composition
+ fireEvent.compositionEnd(input)
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenCalledWith('final')
+ })
+
+ it('calls onChange with empty string when clear button is clicked', () => {
+ const onChange = vi.fn()
+ render()
+
+ const clearButton = screen.getByLabelText('common.operation.clear')
+ fireEvent.click(clearButton)
+ expect(onChange).toHaveBeenCalledWith('')
+ })
+
+ it('updates focus state on focus/blur', () => {
+ const { container } = render( {}} />)
+ const wrapper = container.firstChild as HTMLElement
+ const input = screen.getByPlaceholderText('common.operation.search')
+
+ fireEvent.focus(input)
+ expect(wrapper).toHaveClass(/bg-components-input-bg-active/)
+
+ fireEvent.blur(input)
+ expect(wrapper).not.toHaveClass(/bg-components-input-bg-active/)
+ })
+ })
+
+ describe('Style', () => {
+ it('applies white style', () => {
+ const { container } = render( {}} white />)
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('!bg-white')
+ })
+
+ it('applies custom className', () => {
+ const { container } = render( {}} className="custom-test" />)
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-test')
+ })
+ })
+})
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json
index f55a49c564..5997abac8e 100644
--- a/web/eslint-suppressions.json
+++ b/web/eslint-suppressions.json
@@ -1724,11 +1724,6 @@
"count": 10
}
},
- "app/components/base/checkbox-list/index.tsx": {
- "tailwindcss/enforce-consistent-class-order": {
- "count": 6
- }
- },
"app/components/base/checkbox/index.stories.tsx": {
"no-console": {
"count": 1
@@ -1858,9 +1853,6 @@
"app/components/base/emoji-picker/Inner.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
- },
- "tailwindcss/enforce-consistent-class-order": {
- "count": 3
}
},
"app/components/base/encrypted-bottom/index.tsx": {