test: add tests for some base components (#32265)

This commit is contained in:
Saumya Talwani 2026-02-13 11:59:04 +05:30 committed by GitHub
parent a4e03d6284
commit 98466e2d29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1985 additions and 23 deletions

View File

@ -0,0 +1,260 @@
import type { ComponentProps } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentLogDetailResponse } from '@/models/log'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast'
import { fetchAgentLogDetail } from '@/service/log'
import AgentLogDetail from './detail'
vi.mock('@/service/log', () => ({
fetchAgentLogDetail: vi.fn(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: vi.fn(selector => selector({ appDetail: { id: 'app-id' } })),
}))
vi.mock('@/app/components/workflow/run/status', () => ({
default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => (
<div data-testid="status-panel" data-status={String(status)} data-time={String(time)} data-tokens={String(tokens)}>{error ? <span>{String(error)}</span> : null}</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
<div data-testid="code-editor">
{title}
{typeof value === 'string' ? value : JSON.stringify(value)}
</div>
),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({ formatTime: (ts: number, fmt: string) => `${ts}-${fmt}` }),
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({
ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />,
}))
const createMockLog = (overrides: Partial<IChatItem> = {}): IChatItem => ({
id: 'msg-id',
content: 'output content',
isAnswer: false,
conversationId: 'conv-id',
input: 'user input',
...overrides,
})
const createMockResponse = (overrides: Partial<AgentLogDetailResponse> = {}): AgentLogDetailResponse => ({
meta: {
status: 'succeeded',
executor: 'User',
start_time: '2023-01-01',
elapsed_time: 1.0,
total_tokens: 100,
agent_mode: 'function_call',
iterations: 1,
},
iterations: [
{
created_at: '',
files: [],
thought: '',
tokens: 0,
tool_raw: { inputs: '', outputs: '' },
tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }],
},
],
files: [],
...overrides,
})
describe('AgentLogDetail', () => {
const notify = vi.fn()
const renderComponent = (props: Partial<ComponentProps<typeof AgentLogDetail>> = {}) => {
const defaultProps: ComponentProps<typeof AgentLogDetail> = {
conversationID: 'conv-id',
messageID: 'msg-id',
log: createMockLog(),
}
return render(
<ToastContext.Provider value={{ notify, close: vi.fn() } as ComponentProps<typeof ToastContext.Provider>['value']}>
<AgentLogDetail {...defaultProps} {...props} />
</ToastContext.Provider>,
)
}
const renderAndWaitForData = async (props: Partial<ComponentProps<typeof AgentLogDetail>> = {}) => {
const result = renderComponent(props)
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
return result
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should show loading indicator while fetching data', async () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
renderComponent()
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should display result panel after data loads', async () => {
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
await renderAndWaitForData()
expect(screen.getByText(/runLog.detail/i)).toBeInTheDocument()
expect(screen.getByText(/runLog.tracing/i)).toBeInTheDocument()
})
it('should call fetchAgentLogDetail with correct params', async () => {
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
await renderAndWaitForData()
expect(fetchAgentLogDetail).toHaveBeenCalledWith({
appID: 'app-id',
params: {
conversation_id: 'conv-id',
message_id: 'msg-id',
},
})
})
})
describe('Props', () => {
it('should default to DETAIL tab when activeTab is not provided', async () => {
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
await renderAndWaitForData()
const detailTab = screen.getByText(/runLog.detail/i)
expect(detailTab.getAttribute('data-active')).toBe('true')
})
it('should show TRACING tab when activeTab is TRACING', async () => {
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
await renderAndWaitForData({ activeTab: 'TRACING' })
const tracingTab = screen.getByText(/runLog.tracing/i)
expect(tracingTab.getAttribute('data-active')).toBe('true')
})
})
describe('User Interactions', () => {
it('should switch to TRACING tab when clicked', async () => {
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
await renderAndWaitForData()
fireEvent.click(screen.getByText(/runLog.tracing/i))
await waitFor(() => {
const tracingTab = screen.getByText(/runLog.tracing/i)
expect(tracingTab.getAttribute('data-active')).toBe('true')
})
const detailTab = screen.getByText(/runLog.detail/i)
expect(detailTab.getAttribute('data-active')).toBe('false')
})
it('should switch back to DETAIL tab after switching to TRACING', async () => {
vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse())
await renderAndWaitForData()
fireEvent.click(screen.getByText(/runLog.tracing/i))
await waitFor(() => {
expect(screen.getByText(/runLog.tracing/i).getAttribute('data-active')).toBe('true')
})
fireEvent.click(screen.getByText(/runLog.detail/i))
await waitFor(() => {
const detailTab = screen.getByText(/runLog.detail/i)
expect(detailTab.getAttribute('data-active')).toBe('true')
})
})
})
describe('Edge Cases', () => {
it('should notify on API error', async () => {
vi.mocked(fetchAgentLogDetail).mockRejectedValue(new Error('API Error'))
renderComponent()
await waitFor(() => {
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'Error: API Error',
})
})
})
it('should stop loading after API error', async () => {
vi.mocked(fetchAgentLogDetail).mockRejectedValue(new Error('Network failure'))
renderComponent()
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})
it('should handle response with empty iterations', async () => {
vi.mocked(fetchAgentLogDetail).mockResolvedValue(
createMockResponse({ iterations: [] }),
)
await renderAndWaitForData()
})
it('should handle response with multiple iterations and duplicate tools', async () => {
const response = createMockResponse({
iterations: [
{
created_at: '',
files: [],
thought: '',
tokens: 0,
tool_raw: { inputs: '', outputs: '' },
tool_calls: [
{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } },
{ tool_name: 'tool2', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 2' } },
],
},
{
created_at: '',
files: [],
thought: '',
tokens: 0,
tool_raw: { inputs: '', outputs: '' },
tool_calls: [
{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } },
],
},
],
})
vi.mocked(fetchAgentLogDetail).mockResolvedValue(response)
await renderAndWaitForData()
expect(screen.getByText(/runLog.detail/i)).toBeInTheDocument()
})
})
})

View File

@ -89,6 +89,7 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary',
currentTab === 'DETAIL' && '!border-[rgb(21,94,239)] text-text-secondary',
)}
data-active={currentTab === 'DETAIL'}
onClick={() => switchTab('DETAIL')}
>
{t('detail', { ns: 'runLog' })}
@ -98,6 +99,7 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary',
currentTab === 'TRACING' && '!border-[rgb(21,94,239)] text-text-secondary',
)}
data-active={currentTab === 'TRACING'}
onClick={() => switchTab('TRACING')}
>
{t('tracing', { ns: 'runLog' })}

View File

@ -0,0 +1,142 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useClickAway } from 'ahooks'
import { ToastContext } from '@/app/components/base/toast'
import { fetchAgentLogDetail } from '@/service/log'
import AgentLogModal from './index'
vi.mock('@/service/log', () => ({
fetchAgentLogDetail: vi.fn(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: vi.fn(selector => selector({ appDetail: { id: 'app-id' } })),
}))
vi.mock('@/app/components/workflow/run/status', () => ({
default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => (
<div data-testid="status-panel" data-status={String(status)} data-time={String(time)} data-tokens={String(tokens)}>{error ? <span>{String(error)}</span> : null}</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
<div data-testid="code-editor">
{title}
{typeof value === 'string' ? value : JSON.stringify(value)}
</div>
),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({ formatTime: (ts: number, fmt: string) => `${ts}-${fmt}` }),
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({
ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />,
}))
vi.mock('ahooks', () => ({
useClickAway: vi.fn(),
}))
const mockLog = {
id: 'msg-id',
conversationId: 'conv-id',
content: 'content',
isAnswer: false,
input: 'test input',
} as IChatItem
const mockProps = {
currentLogItem: mockLog,
width: 1000,
onCancel: vi.fn(),
}
describe('AgentLogModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(fetchAgentLogDetail).mockResolvedValue({
meta: {
status: 'succeeded',
executor: 'User',
start_time: '2023-01-01',
elapsed_time: 1.0,
total_tokens: 100,
agent_mode: 'function_call',
iterations: 1,
},
iterations: [{
created_at: '',
files: [],
thought: '',
tokens: 0,
tool_raw: { inputs: '', outputs: '' },
tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }],
}],
files: [],
})
})
it('should return null if no currentLogItem', () => {
const { container } = render(<AgentLogModal {...mockProps} currentLogItem={undefined} />)
expect(container.firstChild).toBeNull()
})
it('should return null if no conversationId', () => {
const { container } = render(<AgentLogModal {...mockProps} currentLogItem={{ id: '1' } as unknown as IChatItem} />)
expect(container.firstChild).toBeNull()
})
it('should render correctly when log item is provided', async () => {
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}>
<AgentLogModal {...mockProps} />
</ToastContext.Provider>,
)
expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText(/runLog.detail/i)).toBeInTheDocument()
})
})
it('should call onCancel when close button is clicked', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}>
<AgentLogModal {...mockProps} />
</ToastContext.Provider>,
)
const closeBtn = screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling!
fireEvent.click(closeBtn)
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when clicking away', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
let clickAwayHandler!: (event: Event) => void
vi.mocked(useClickAway).mockImplementation((callback) => {
clickAwayHandler = callback
})
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}>
<AgentLogModal {...mockProps} />
</ToastContext.Provider>,
)
clickAwayHandler(new Event('click'))
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,57 @@
import type { AgentIteration } from '@/models/log'
import { render, screen } from '@testing-library/react'
import Iteration from './iteration'
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
<div data-testid="code-editor">
<div data-testid="code-editor-title">{title}</div>
<div data-testid="code-editor-value">{JSON.stringify(value)}</div>
</div>
),
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
const mockIterationInfo: AgentIteration = {
created_at: '2023-01-01',
files: [],
thought: 'Test thought',
tokens: 100,
tool_calls: [
{
status: 'success',
tool_name: 'test_tool',
tool_label: { en: 'Test Tool' },
tool_icon: null,
},
],
tool_raw: {
inputs: '{}',
outputs: 'test output',
},
}
describe('Iteration', () => {
it('should render final processing when isFinal is true', () => {
render(<Iteration iterationInfo={mockIterationInfo} isFinal={true} index={1} />)
expect(screen.getByText(/appLog.agentLogDetail.finalProcessing/i)).toBeInTheDocument()
expect(screen.queryByText(/appLog.agentLogDetail.iteration/i)).not.toBeInTheDocument()
})
it('should render iteration index when isFinal is false', () => {
render(<Iteration iterationInfo={mockIterationInfo} isFinal={false} index={2} />)
expect(screen.getByText(/APPLOG.AGENTLOGDETAIL.ITERATION 2/i)).toBeInTheDocument()
expect(screen.queryByText(/appLog.agentLogDetail.finalProcessing/i)).not.toBeInTheDocument()
})
it('should render LLM tool call and subsequent tool calls', () => {
render(<Iteration iterationInfo={mockIterationInfo} isFinal={false} index={1} />)
expect(screen.getByTitle('LLM')).toBeInTheDocument()
expect(screen.getByText('Test Tool')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,85 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import ResultPanel from './result'
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
<div data-testid="code-editor">
<div data-testid="code-editor-title">{title}</div>
<div data-testid="code-editor-value">{JSON.stringify(value)}</div>
</div>
),
}))
vi.mock('@/app/components/workflow/run/status', () => ({
default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => (
<div data-testid="status-panel">
<span>{status}</span>
<span>{time}</span>
<span>{tokens}</span>
<span>{error}</span>
</div>
),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: vi.fn((ts, _format) => `formatted-${ts}`),
}),
}))
const mockProps = {
status: 'succeeded',
elapsed_time: 1.23456,
total_tokens: 150,
error: '',
inputs: { query: 'input' },
outputs: { answer: 'output' },
created_by: 'User Name',
created_at: '2023-01-01T00:00:00Z',
agentMode: 'function_call',
tools: ['tool1', 'tool2'],
iterations: 3,
}
describe('ResultPanel', () => {
it('should render status panel and code editors', () => {
render(<ResultPanel {...mockProps} />)
expect(screen.getByTestId('status-panel')).toBeInTheDocument()
const editors = screen.getAllByTestId('code-editor')
expect(editors).toHaveLength(2)
expect(screen.getByText('INPUT')).toBeInTheDocument()
expect(screen.getByText('OUTPUT')).toBeInTheDocument()
expect(screen.getByText(JSON.stringify(mockProps.inputs))).toBeInTheDocument()
expect(screen.getByText(JSON.stringify(mockProps.outputs))).toBeInTheDocument()
})
it('should display correct metadata', () => {
render(<ResultPanel {...mockProps} />)
expect(screen.getByText('User Name')).toBeInTheDocument()
expect(screen.getByText('1.235s')).toBeInTheDocument() // toFixed(3)
expect(screen.getByText('150 Tokens')).toBeInTheDocument()
expect(screen.getByText('appDebug.agent.agentModeType.functionCall')).toBeInTheDocument()
expect(screen.getByText('tool1, tool2')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
// Check formatted time
expect(screen.getByText(/formatted-/)).toBeInTheDocument()
})
it('should handle missing created_by and tools', () => {
render(<ResultPanel {...mockProps} created_by={undefined} tools={[]} />)
expect(screen.getByText('N/A')).toBeInTheDocument()
expect(screen.getByText('Null')).toBeInTheDocument()
})
it('should display ReACT mode correctly', () => {
render(<ResultPanel {...mockProps} agentMode="react" />)
expect(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,126 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import ToolCallItem from './tool-call'
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
<div data-testid="code-editor">
<div data-testid="code-editor-title">{title}</div>
<div data-testid="code-editor-value">{JSON.stringify(value)}</div>
</div>
),
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: ({ type }: { type: BlockEnum }) => <div data-testid="block-icon" data-type={type} />,
}))
const mockToolCall = {
status: 'success',
error: null,
tool_name: 'test_tool',
tool_label: { en: 'Test Tool Label' },
tool_icon: 'icon',
time_cost: 1.5,
tool_input: { query: 'hello' },
tool_output: { result: 'world' },
}
describe('ToolCallItem', () => {
it('should render tool name correctly for LLM', () => {
render(<ToolCallItem toolCall={mockToolCall} isLLM={true} />)
expect(screen.getByText('LLM')).toBeInTheDocument()
expect(screen.getByTestId('block-icon')).toHaveAttribute('data-type', BlockEnum.LLM)
})
it('should render tool name from label for non-LLM', () => {
render(<ToolCallItem toolCall={mockToolCall} isLLM={false} />)
expect(screen.getByText('Test Tool Label')).toBeInTheDocument()
expect(screen.getByTestId('block-icon')).toHaveAttribute('data-type', BlockEnum.Tool)
})
it('should format time correctly', () => {
render(<ToolCallItem toolCall={mockToolCall} isLLM={false} />)
expect(screen.getByText('1.500 s')).toBeInTheDocument()
// Test ms format
render(<ToolCallItem toolCall={{ ...mockToolCall, time_cost: 0.5 }} isLLM={false} />)
expect(screen.getByText('500.000 ms')).toBeInTheDocument()
// Test minute format
render(<ToolCallItem toolCall={{ ...mockToolCall, time_cost: 65 }} isLLM={false} />)
expect(screen.getByText('1 m 5.000 s')).toBeInTheDocument()
})
it('should format token count correctly', () => {
render(<ToolCallItem toolCall={mockToolCall} isLLM={true} tokens={1200} />)
expect(screen.getByText('1.2K tokens')).toBeInTheDocument()
render(<ToolCallItem toolCall={mockToolCall} isLLM={true} tokens={800} />)
expect(screen.getByText('800 tokens')).toBeInTheDocument()
render(<ToolCallItem toolCall={mockToolCall} isLLM={true} tokens={1200000} />)
expect(screen.getByText('1.2M tokens')).toBeInTheDocument()
})
it('should handle collapse/expand', () => {
render(<ToolCallItem toolCall={mockToolCall} isLLM={false} />)
expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
fireEvent.click(screen.getByText(/Test Tool Label/i))
expect(screen.getAllByTestId('code-editor')).toHaveLength(2)
})
it('should display error message when status is error', () => {
const errorToolCall = {
...mockToolCall,
status: 'error',
error: 'Something went wrong',
}
render(<ToolCallItem toolCall={errorToolCall} isLLM={false} />)
fireEvent.click(screen.getByText(/Test Tool Label/i))
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})
it('should display LLM specific fields when expanded', () => {
render(
<ToolCallItem
toolCall={mockToolCall}
isLLM={true}
observation="test observation"
finalAnswer="test final answer"
isFinal={true}
/>,
)
fireEvent.click(screen.getByText('LLM'))
const titles = screen.getAllByTestId('code-editor-title')
const titleTexts = titles.map(t => t.textContent)
expect(titleTexts).toContain('INPUT')
expect(titleTexts).toContain('OUTPUT')
expect(titleTexts).toContain('OBSERVATION')
expect(titleTexts).toContain('FINAL ANSWER')
})
it('should display THOUGHT instead of FINAL ANSWER when isFinal is false', () => {
render(
<ToolCallItem
toolCall={mockToolCall}
isLLM={true}
observation="test observation"
finalAnswer="test thought"
isFinal={false}
/>,
)
fireEvent.click(screen.getByText('LLM'))
expect(screen.getByText('THOUGHT')).toBeInTheDocument()
expect(screen.queryByText('FINAL ANSWER')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,50 @@
import type { AgentIteration } from '@/models/log'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import TracingPanel from './tracing'
vi.mock('@/app/components/workflow/block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({
ChevronRight: (props: { className?: string }) => <div data-testid="chevron-right" className={props.className} />,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
<div data-testid="code-editor">
{title}
{typeof value === 'string' ? value : JSON.stringify(value)}
</div>
),
}))
const createIteration = (thought: string, tokens: number): AgentIteration => ({
created_at: '',
files: [],
thought,
tokens,
tool_calls: [{ tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }],
tool_raw: { inputs: '', outputs: '' },
})
const mockList: AgentIteration[] = [
createIteration('Thought 1', 10),
createIteration('Thought 2', 20),
createIteration('Thought 3', 30),
]
describe('TracingPanel', () => {
it('should render all iterations in the list', () => {
render(<TracingPanel list={mockList} />)
expect(screen.getByText(/finalProcessing/i)).toBeInTheDocument()
expect(screen.getAllByText(/ITERATION/i).length).toBe(2)
})
it('should render empty list correctly', () => {
const { container } = render(<TracingPanel list={[]} />)
expect(container.querySelector('.bg-background-section')?.children.length).toBe(0)
})
})

View File

@ -0,0 +1,195 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CheckboxList from '.'
vi.mock('next/image', () => ({
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
describe('checkbox list component', () => {
const options = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
{ label: 'Apple', value: 'apple' },
]
it('renders with title, description and options', () => {
render(
<CheckboxList
title="Test Title"
description="Test Description"
options={options}
/>,
)
expect(screen.getByText('Test Title')).toBeInTheDocument()
expect(screen.getByText('Test Description')).toBeInTheDocument()
options.forEach((option) => {
expect(screen.getByText(option.label)).toBeInTheDocument()
})
})
it('filters options by label', async () => {
render(<CheckboxList options={options} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'app')
expect(screen.getByText('Apple')).toBeInTheDocument()
expect(screen.queryByText('Option 2')).not.toBeInTheDocument()
expect(screen.queryByText('Option 3')).not.toBeInTheDocument()
})
it('renders select-all checkbox', () => {
render(<CheckboxList options={options} showSelectAll />)
const checkboxes = screen.getByTestId('checkbox-selectAll')
expect(checkboxes).toBeInTheDocument()
})
it('selects all options when select-all is clicked', async () => {
const onChange = vi.fn()
render(
<CheckboxList
options={options}
value={[]}
onChange={onChange}
showSelectAll
/>,
)
const selectAll = screen.getByTestId('checkbox-selectAll')
await userEvent.click(selectAll)
expect(onChange).toHaveBeenCalledWith(['option1', 'option2', 'option3', 'apple'])
})
it('does not select all options when select-all is clicked when disabled', async () => {
const onChange = vi.fn()
render(
<CheckboxList
options={options}
value={[]}
disabled
showSelectAll
onChange={onChange}
/>,
)
const selectAll = screen.getByTestId('checkbox-selectAll')
await userEvent.click(selectAll)
expect(onChange).not.toHaveBeenCalled()
})
it('deselects all options when select-all is clicked', async () => {
const onChange = vi.fn()
render(
<CheckboxList
options={options}
value={['option1', 'option2', 'option3', 'apple']}
onChange={onChange}
showSelectAll
/>,
)
const selectAll = screen.getByTestId('checkbox-selectAll')
await userEvent.click(selectAll)
expect(onChange).toHaveBeenCalledWith([])
})
it('selects select-all when all options are clicked', async () => {
const onChange = vi.fn()
render(
<CheckboxList
options={options}
value={['option1', 'option2', 'option3', 'apple']}
onChange={onChange}
showSelectAll
/>,
)
const selectAll = screen.getByTestId('checkbox-selectAll')
expect(selectAll.querySelector('[data-testid="check-icon-selectAll"]')).toBeInTheDocument()
})
it('hides select-all checkbox when searching', async () => {
render(<CheckboxList options={options} />)
await userEvent.type(screen.getByRole('textbox'), 'app')
expect(screen.queryByTestId('checkbox-selectAll')).not.toBeInTheDocument()
})
it('selects options when checkbox is clicked', async () => {
const onChange = vi.fn()
render(
<CheckboxList
options={options}
value={[]}
onChange={onChange}
showSelectAll={false}
/>,
)
const selectOption = screen.getByTestId('checkbox-option1')
await userEvent.click(selectOption)
expect(onChange).toHaveBeenCalledWith(['option1'])
})
it('deselects options when checkbox is clicked when selected', async () => {
const onChange = vi.fn()
render(
<CheckboxList
options={options}
value={['option1']}
onChange={onChange}
showSelectAll={false}
/>,
)
const selectOption = screen.getByTestId('checkbox-option1')
await userEvent.click(selectOption)
expect(onChange).toHaveBeenCalledWith([])
})
it('does not select options when checkbox is clicked', async () => {
const onChange = vi.fn()
render(
<CheckboxList
options={options}
value={[]}
onChange={onChange}
disabled
/>,
)
const selectOption = screen.getByTestId('checkbox-option1')
await userEvent.click(selectOption)
expect(onChange).not.toHaveBeenCalled()
})
it('Reset button works', async () => {
const onChange = vi.fn()
render(
<CheckboxList
options={options}
value={[]}
onChange={onChange}
/>,
)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'ban')
await userEvent.click(screen.getByText('common.operation.resetKeywords'))
expect(input).toHaveValue('')
})
})

View File

@ -101,12 +101,12 @@ const CheckboxList: FC<CheckboxListProps> = ({
return (
<div className={cn('flex w-full flex-col gap-1', containerClassName)}>
{label && (
<div className="system-sm-medium text-text-secondary">
<div className="text-text-secondary system-sm-medium">
{label}
</div>
)}
{description && (
<div className="body-xs-regular text-text-tertiary">
<div className="text-text-tertiary body-xs-regular">
{description}
</div>
)}
@ -120,13 +120,14 @@ const CheckboxList: FC<CheckboxListProps> = ({
indeterminate={isIndeterminate}
onCheck={handleSelectAll}
disabled={disabled}
id="selectAll"
/>
)}
{!searchQuery
? (
<div className="flex min-w-0 flex-1 items-center gap-1">
{title && (
<span className="system-xs-semibold-uppercase truncate leading-5 text-text-secondary">
<span className="truncate leading-5 text-text-secondary system-xs-semibold-uppercase">
{title}
</span>
)}
@ -138,7 +139,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
</div>
)
: (
<div className="system-sm-medium-uppercase flex-1 leading-6 text-text-secondary">
<div className="flex-1 leading-6 text-text-secondary system-sm-medium-uppercase">
{
filteredOptions.length > 0
? t('operation.searchCount', { ns: 'common', count: filteredOptions.length, content: title })
@ -168,7 +169,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
? (
<div className="flex flex-col items-center justify-center gap-2">
<Image alt="search menu" src={SearchMenu} width={32} />
<span className="system-sm-regular text-text-secondary">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
<span className="text-text-secondary system-sm-regular">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
</div>
)
@ -198,9 +199,10 @@ const CheckboxList: FC<CheckboxListProps> = ({
handleToggleOption(option.value)
}}
disabled={option.disabled || disabled}
id={option.value}
/>
<div
className="system-sm-medium flex-1 truncate text-text-secondary"
className="flex-1 truncate text-text-secondary system-sm-medium"
title={option.label}
>
{option.label}

View File

@ -0,0 +1,117 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import Confirm from '.'
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
return {
...actual,
createPortal: (children: React.ReactNode) => children,
}
})
const onCancel = vi.fn()
const onConfirm = vi.fn()
describe('Confirm Component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('renders confirm correctly', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
expect(screen.getByText('test title')).toBeInTheDocument()
})
it('does not render on isShow false', () => {
const { container } = render(<Confirm isShow={false} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
expect(container.firstChild).toBeNull()
})
it('hides after delay when isShow changes to false', () => {
vi.useFakeTimers()
const { rerender } = render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
expect(screen.getByText('test title')).toBeInTheDocument()
rerender(<Confirm isShow={false} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('test title')).not.toBeInTheDocument()
vi.useRealTimers()
})
it('renders content when provided', () => {
render(<Confirm isShow={true} title="title" content="some description" onCancel={onCancel} onConfirm={onConfirm} />)
expect(screen.getByText('some description')).toBeInTheDocument()
})
})
describe('Props', () => {
it('showCancel prop works', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} showCancel={false} />)
expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument()
})
it('showConfirm prop works', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} showConfirm={false} />)
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.confirm' })).not.toBeInTheDocument()
})
it('renders custom confirm and cancel text', () => {
render(<Confirm isShow={true} title="title" confirmText="Yes" cancelText="No" onCancel={onCancel} onConfirm={onConfirm} />)
expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
})
it('disables confirm button when isDisabled is true', () => {
render(<Confirm isShow={true} title="title" isDisabled={true} onCancel={onCancel} onConfirm={onConfirm} />)
expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeDisabled()
})
})
describe('User Interactions', () => {
it('clickAway is handled properly', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
const overlay = screen.getByTestId('confirm-overlay') as HTMLElement
expect(overlay).toBeTruthy()
fireEvent.mouseDown(overlay)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('overlay click stops propagation', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
const overlay = screen.getByTestId('confirm-overlay') as HTMLElement
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation')
overlay.dispatchEvent(clickEvent)
expect(preventDefaultSpy).toHaveBeenCalled()
expect(stopPropagationSpy).toHaveBeenCalled()
})
it('does not close on click away when maskClosable is false', () => {
render(<Confirm isShow={true} title="test title" maskClosable={false} onCancel={onCancel} onConfirm={onConfirm} />)
const overlay = screen.getByTestId('confirm-overlay') as HTMLElement
fireEvent.mouseDown(overlay)
expect(onCancel).not.toHaveBeenCalled()
})
it('escape keyboard event works', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onConfirm).not.toHaveBeenCalled()
})
it('Enter keyboard event works', () => {
render(<Confirm isShow={true} title="test title" onCancel={onCancel} onConfirm={onConfirm} />)
fireEvent.keyDown(document, { key: 'Enter' })
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onCancel).not.toHaveBeenCalled()
})
})
})

View File

@ -101,6 +101,7 @@ function Confirm({
e.preventDefault()
e.stopPropagation()
}}
data-testid="confirm-overlay"
>
<div ref={dialogRef} className="relative w-full max-w-[480px] overflow-hidden">
<div className="shadows-shadow-lg flex max-w-full flex-col items-start rounded-2xl border-[0.5px] border-solid border-components-panel-border bg-components-panel-bg">

View File

@ -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(<CopyFeedback content="test content" />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('renders the copied icon when copied is true', () => {
mockCopied = true
render(<CopyFeedback content="test content" />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('calls copy with content when clicked', () => {
render(<CopyFeedback content="test content" />)
const button = screen.getByRole('button')
fireEvent.click(button.firstChild as Element)
expect(mockCopy).toHaveBeenCalledWith('test content')
})
it('calls reset on mouse leave', () => {
render(<CopyFeedback content="test content" />)
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(<CopyFeedbackNew content="test content" />)
expect(container.querySelector('.cursor-pointer')).toBeInTheDocument()
})
it('applies copied CSS class when copied is true', () => {
mockCopied = true
const { container } = render(<CopyFeedbackNew content="test content" />)
const feedbackIcon = container.firstChild?.firstChild as Element
expect(feedbackIcon).toHaveClass(/_copied_.*/)
})
it('does not apply copied CSS class when not copied', () => {
const { container } = render(<CopyFeedbackNew content="test content" />)
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(<CopyFeedbackNew content="test content" />)
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(<CopyFeedbackNew content="test content" />)
const clickableArea = container.querySelector('.cursor-pointer')!.firstChild as HTMLElement
fireEvent.mouseLeave(clickableArea)
expect(mockReset).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -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(<EmojiPickerInner onSelect={mockOnSelect} />)
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(<EmojiPickerInner onSelect={mockOnSelect} />)
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(<EmojiPickerInner onSelect={mockOnSelect} />)
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(<EmojiPickerInner onSelect={mockOnSelect} />)
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(<EmojiPickerInner onSelect={mockOnSelect} />)
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(<EmojiPickerInner onSelect={mockOnSelect} />)
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(<EmojiPickerInner onSelect={mockOnSelect} />)
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(<EmojiPickerInner onSelect={mockOnSelect} />)
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()
})
})
})

View File

@ -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<IEmojiPickerInnerProps> = ({
{isSearching && (
<>
<div key="category-search" className="flex flex-col">
<p className="system-xs-medium-uppercase mb-1 text-text-primary">Search</p>
<p className="mb-1 text-text-primary system-xs-medium-uppercase">Search</p>
<div className="grid h-full w-full grid-cols-8 gap-1">
{searchedEmojis.map((emoji: string, index: number) => {
return (
@ -108,7 +106,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
setSelectedEmoji(emoji)
}}
>
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1">
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1" data-testid={`emoji-search-result-${emoji}`}>
<em-emoji id={emoji} />
</div>
</div>
@ -122,7 +120,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
{categories.map((category, index: number) => {
return (
<div key={`category-${index}`} className="flex flex-col">
<p className="system-xs-medium-uppercase mb-1 text-text-primary">{category.id}</p>
<p className="mb-1 text-text-primary system-xs-medium-uppercase">{category.id}</p>
<div className="grid h-full w-full grid-cols-8 gap-1">
{category.emojis.map((emoji, index: number) => {
return (
@ -133,7 +131,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
setSelectedEmoji(emoji)
}}
>
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1">
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1" data-testid={`emoji-container-${emoji}`}>
<em-emoji id={emoji} />
</div>
</div>
@ -148,10 +146,10 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
{/* Color Select */}
<div className={cn('flex items-center justify-between p-3 pb-0')}>
<p className="system-xs-medium-uppercase mb-2 text-text-primary">Choose Style</p>
<p className="mb-2 text-text-primary system-xs-medium-uppercase">Choose Style</p>
{showStyleColors
? <ChevronDownIcon className="h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} />
: <ChevronUpIcon className="h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} />}
? <span className="i-heroicons-chevron-down h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} data-testid="toggle-colors" />
: <span className="i-heroicons-chevron-up h-4 w-4 cursor-pointer text-text-quaternary" onClick={() => setShowStyleColors(!showStyleColors)} data-testid="toggle-colors" />}
</div>
{showStyleColors && (
<div className="grid w-full grid-cols-8 gap-1 px-3">

View File

@ -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(
<EmojiPicker isModal={false} />,
)
expect(container.firstChild).toBeNull()
})
it('renders modal when isModal is true', async () => {
await act(async () => {
render(
<EmojiPicker isModal={true} />,
)
})
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(
<EmojiPicker />,
)
})
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(
<EmojiPicker className={customClass} />,
)
})
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(
<EmojiPicker onSelect={mockOnSelect} />,
)
})
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(
<EmojiPicker onClose={mockOnClose} />,
)
})
const cancelButton = screen.getByText(/Cancel/i)
await act(async () => {
fireEvent.click(cancelButton)
})
expect(mockOnClose).toHaveBeenCalled()
})
})
})

View File

@ -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(<ImageRender {...mockProps} />)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', mockProps.sourceUrl)
expect(img).toHaveAttribute('alt', mockProps.name)
})
})
})

View File

@ -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<HTMLImageElement>) => <img {...props} />,
}))
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(<FileThumb file={mockImageFile} />)
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(<FileThumb file={mockNonImageFile} />)
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(<FileThumb file={mockImageFile} />)
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(<FileThumb file={mockImageFile} onClick={onClick} />)
const clickable = screen.getByAltText(mockImageFile.name).closest('div') as HTMLElement
fireEvent.click(clickable)
expect(onClick).toHaveBeenCalledTimes(1)
expect(onClick).toHaveBeenCalledWith(mockImageFile)
})
})
})

View File

@ -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 }) => (
<a href={href} className={className} data-testid="link-item">
{children}
</a>
),
}))
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(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={false} />)
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(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={false} />)
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(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={true} />)
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(<LinkedAppsPanel relatedApps={[]} isMobile={false} />)
const items = screen.queryAllByTestId('link-item')
expect(items).toHaveLength(0)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Interaction', () => {
it('renders correct links for each app', () => {
render(<LinkedAppsPanel relatedApps={mockRelatedApps} isMobile={false} />)
const items = screen.getAllByTestId('link-item')
expect(items[0]).toHaveAttribute('href', '/app/app-1/overview')
expect(items[1]).toHaveAttribute('href', '/app/app-2/overview')
})
})
})

View File

@ -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(<HorizontalLine />)
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(<HorizontalLine />)
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(<HorizontalLine className={testClass} />)
const svg = container.querySelector('svg')
expect(svg).toHaveClass(testClass)
})
})
})

View File

@ -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(<ListEmpty />)
expect(container.querySelector('[data-icon="Variable02"]')).toBeInTheDocument()
})
it('renders custom icon when provided', () => {
const { container } = render(<ListEmpty icon={<div data-testid="custom-icon" />} />)
expect(container.querySelector('[data-icon="Variable02"]')).not.toBeInTheDocument()
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('renders design lines', () => {
const { container } = render(<ListEmpty />)
const svgs = container.querySelectorAll('svg')
expect(svgs).toHaveLength(5)
})
})
describe('Props', () => {
it('renders title and description correctly', () => {
const testTitle = 'Empty List'
const testDescription = <span data-testid="desc">No items found</span>
render(<ListEmpty title={testTitle} description={testDescription} />)
expect(screen.getByText(testTitle)).toBeInTheDocument()
expect(screen.getByTestId('desc')).toBeInTheDocument()
expect(screen.getByText('No items found')).toBeInTheDocument()
})
})
})

View File

@ -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(<VerticalLine />)
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(<VerticalLine />)
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(<VerticalLine className={testClass} />)
const svg = container.querySelector('svg')
expect(svg).toHaveClass(testClass)
})
})
})

View File

@ -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<typeof useTheme>)
})
describe('Render', () => {
it('renders correctly with default props', () => {
render(<DifyLogo />)
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(<DifyLogo size="large" />)
let img = screen.getByRole('img', { name: /dify logo/i })
expect(img).toHaveClass('w-16')
expect(img).toHaveClass('h-7')
rerender(<DifyLogo size="small" />)
img = screen.getByRole('img', { name: /dify logo/i })
expect(img).toHaveClass('w-9')
expect(img).toHaveClass('h-4')
})
it('applies custom style correctly', () => {
render(<DifyLogo style="monochromeWhite" />)
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(<DifyLogo className="custom-test-class" />)
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<typeof useTheme>)
render(<DifyLogo style="default" />)
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<typeof useTheme>)
render(<DifyLogo style="monochromeWhite" />)
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<typeof useTheme>)
render(<DifyLogo style="default" />)
const img = screen.getByRole('img', { name: /dify logo/i })
expect(img).toHaveAttribute('src', '/test-base-path/logo/logo.svg')
})
})
})

View File

@ -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(<LogoEmbeddedChatAvatar />)
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(<LogoEmbeddedChatAvatar className={customClass} />)
const img = screen.getByRole('img', { name: /logo/i })
expect(img).toHaveClass(customClass)
})
it('has valid alt text', () => {
render(<LogoEmbeddedChatAvatar />)
const img = screen.getByRole('img', { name: /logo/i })
expect(img).toHaveAttribute('alt', 'logo')
})
})
})

View File

@ -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(<LogoEmbeddedChatHeader />)
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(<LogoEmbeddedChatHeader className={customClass} />)
const img = screen.getByRole('img', { name: /logo/i })
expect(img).toHaveClass(customClass)
expect(img).toHaveClass('h-6')
})
})

View File

@ -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(<LogoSite />)
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(<LogoSite className={customClass} />)
const img = screen.getByRole('img', { name: /logo/i })
expect(img).toHaveClass(customClass)
})
})

View File

@ -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(<SearchInput value="" onChange={() => {}} />)
const input = screen.getByPlaceholderText('common.operation.search')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('')
})
it('renders custom placeholder', () => {
render(<SearchInput value="" onChange={() => {}} placeholder="Custom Placeholder" />)
expect(screen.getByPlaceholderText('Custom Placeholder')).toBeInTheDocument()
})
it('shows clear button when value is present', () => {
const onChange = vi.fn()
render(<SearchInput value="has value" onChange={onChange} />)
const clearButton = screen.getByLabelText('common.operation.clear')
expect(clearButton).toBeInTheDocument()
})
})
describe('Interaction', () => {
it('calls onChange when typing', () => {
const onChange = vi.fn()
render(<SearchInput value="" onChange={onChange} />)
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(<SearchInput value="initial" onChange={onChange} />)
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(<SearchInput value="has value" onChange={onChange} />)
const clearButton = screen.getByLabelText('common.operation.clear')
fireEvent.click(clearButton)
expect(onChange).toHaveBeenCalledWith('')
})
it('updates focus state on focus/blur', () => {
const { container } = render(<SearchInput value="" onChange={() => {}} />)
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(<SearchInput value="" onChange={() => {}} white />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('!bg-white')
})
it('applies custom className', () => {
const { container } = render(<SearchInput value="" onChange={() => {}} className="custom-test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-test')
})
})
})

View File

@ -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": {