diff --git a/web/app/components/base/agent-log-modal/detail.spec.tsx b/web/app/components/base/agent-log-modal/detail.spec.tsx new file mode 100644 index 0000000000..dd663ac892 --- /dev/null +++ b/web/app/components/base/agent-log-modal/detail.spec.tsx @@ -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 }) => ( +
{error ? {String(error)} : null}
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( +
+ {title} + {typeof value === 'string' ? value : JSON.stringify(value)} +
+ ), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ formatTime: (ts: number, fmt: string) => `${ts}-${fmt}` }), +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ + ChevronRight: (props: { className?: string }) =>
, +})) + +const createMockLog = (overrides: Partial = {}): IChatItem => ({ + id: 'msg-id', + content: 'output content', + isAnswer: false, + conversationId: 'conv-id', + input: 'user input', + ...overrides, +}) + +const createMockResponse = (overrides: Partial = {}): 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> = {}) => { + const defaultProps: ComponentProps = { + conversationID: 'conv-id', + messageID: 'msg-id', + log: createMockLog(), + } + return render( + ['value']}> + + , + ) + } + + const renderAndWaitForData = async (props: Partial> = {}) => { + 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() + }) + }) +}) diff --git a/web/app/components/base/agent-log-modal/detail.tsx b/web/app/components/base/agent-log-modal/detail.tsx index a82a3207b1..36b502e9a5 100644 --- a/web/app/components/base/agent-log-modal/detail.tsx +++ b/web/app/components/base/agent-log-modal/detail.tsx @@ -89,6 +89,7 @@ const AgentLogDetail: FC = ({ '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 = ({ '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' })} diff --git a/web/app/components/base/agent-log-modal/index.spec.tsx b/web/app/components/base/agent-log-modal/index.spec.tsx new file mode 100644 index 0000000000..17c9bc8cf1 --- /dev/null +++ b/web/app/components/base/agent-log-modal/index.spec.tsx @@ -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 }) => ( +
{error ? {String(error)} : null}
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( +
+ {title} + {typeof value === 'string' ? value : JSON.stringify(value)} +
+ ), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ formatTime: (ts: number, fmt: string) => `${ts}-${fmt}` }), +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ + ChevronRight: (props: { className?: string }) =>
, +})) + +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() + expect(container.firstChild).toBeNull() + }) + + it('should return null if no conversationId', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render correctly when log item is provided', async () => { + render( + ['value']}> + + , + ) + + 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( + ['value']}> + + , + ) + + 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( + ['value']}> + + , + ) + clickAwayHandler(new Event('click')) + + expect(mockProps.onCancel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/base/agent-log-modal/iteration.spec.tsx b/web/app/components/base/agent-log-modal/iteration.spec.tsx new file mode 100644 index 0000000000..15d5b815fb --- /dev/null +++ b/web/app/components/base/agent-log-modal/iteration.spec.tsx @@ -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 }) => ( +
+
{title}
+
{JSON.stringify(value)}
+
+ ), +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () =>
, +})) + +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() + + 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() + + 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() + expect(screen.getByTitle('LLM')).toBeInTheDocument() + expect(screen.getByText('Test Tool')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/agent-log-modal/result.spec.tsx b/web/app/components/base/agent-log-modal/result.spec.tsx new file mode 100644 index 0000000000..846d433cab --- /dev/null +++ b/web/app/components/base/agent-log-modal/result.spec.tsx @@ -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 }) => ( +
+
{title}
+
{JSON.stringify(value)}
+
+ ), +})) + +vi.mock('@/app/components/workflow/run/status', () => ({ + default: ({ status, time, tokens, error }: { status: string, time?: number, tokens?: number, error?: string }) => ( +
+ {status} + {time} + {tokens} + {error} +
+ ), +})) + +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() + + 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() + + 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() + + expect(screen.getByText('N/A')).toBeInTheDocument() + expect(screen.getByText('Null')).toBeInTheDocument() + }) + + it('should display ReACT mode correctly', () => { + render() + expect(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/agent-log-modal/tool-call.spec.tsx b/web/app/components/base/agent-log-modal/tool-call.spec.tsx new file mode 100644 index 0000000000..496049a8a8 --- /dev/null +++ b/web/app/components/base/agent-log-modal/tool-call.spec.tsx @@ -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 }) => ( +
+
{title}
+
{JSON.stringify(value)}
+
+ ), +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: ({ type }: { type: BlockEnum }) =>
, +})) + +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() + 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() + expect(screen.getByText('Test Tool Label')).toBeInTheDocument() + expect(screen.getByTestId('block-icon')).toHaveAttribute('data-type', BlockEnum.Tool) + }) + + it('should format time correctly', () => { + render() + expect(screen.getByText('1.500 s')).toBeInTheDocument() + + // Test ms format + render() + expect(screen.getByText('500.000 ms')).toBeInTheDocument() + + // Test minute format + render() + expect(screen.getByText('1 m 5.000 s')).toBeInTheDocument() + }) + + it('should format token count correctly', () => { + render() + expect(screen.getByText('1.2K tokens')).toBeInTheDocument() + + render() + expect(screen.getByText('800 tokens')).toBeInTheDocument() + + render() + expect(screen.getByText('1.2M tokens')).toBeInTheDocument() + }) + + it('should handle collapse/expand', () => { + render() + + 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() + + fireEvent.click(screen.getByText(/Test Tool Label/i)) + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('should display LLM specific fields when expanded', () => { + render( + , + ) + + 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( + , + ) + + fireEvent.click(screen.getByText('LLM')) + expect(screen.getByText('THOUGHT')).toBeInTheDocument() + expect(screen.queryByText('FINAL ANSWER')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/agent-log-modal/tracing.spec.tsx b/web/app/components/base/agent-log-modal/tracing.spec.tsx new file mode 100644 index 0000000000..e0f4a81f99 --- /dev/null +++ b/web/app/components/base/agent-log-modal/tracing.spec.tsx @@ -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: () =>
, +})) + +vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ + ChevronRight: (props: { className?: string }) =>
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ title, value }: { title: React.ReactNode, value: string | object }) => ( +
+ {title} + {typeof value === 'string' ? value : JSON.stringify(value)} +
+ ), +})) + +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() + + expect(screen.getByText(/finalProcessing/i)).toBeInTheDocument() + expect(screen.getAllByText(/ITERATION/i).length).toBe(2) + }) + + it('should render empty list correctly', () => { + const { container } = render() + expect(container.querySelector('.bg-background-section')?.children.length).toBe(0) + }) +}) diff --git a/web/app/components/base/checkbox-list/index.spec.tsx b/web/app/components/base/checkbox-list/index.spec.tsx new file mode 100644 index 0000000000..59ddfb69fc --- /dev/null +++ b/web/app/components/base/checkbox-list/index.spec.tsx @@ -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) => , +})) + +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( + , + ) + 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() + + 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() + const checkboxes = screen.getByTestId('checkbox-selectAll') + expect(checkboxes).toBeInTheDocument() + }) + + it('selects all options when select-all is clicked', async () => { + const onChange = vi.fn() + + render( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + const selectAll = screen.getByTestId('checkbox-selectAll') + expect(selectAll.querySelector('[data-testid="check-icon-selectAll"]')).toBeInTheDocument() + }) + + it('hides select-all checkbox when searching', async () => { + render() + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + const selectOption = screen.getByTestId('checkbox-option1') + await userEvent.click(selectOption) + expect(onChange).not.toHaveBeenCalled() + }) + + it('Reset button works', async () => { + const onChange = vi.fn() + + render( + , + ) + + const input = screen.getByRole('textbox') + await userEvent.type(input, 'ban') + await userEvent.click(screen.getByText('common.operation.resetKeywords')) + expect(input).toHaveValue('') + }) +}) diff --git a/web/app/components/base/checkbox-list/index.tsx b/web/app/components/base/checkbox-list/index.tsx index 9200724e79..b83f46960b 100644 --- a/web/app/components/base/checkbox-list/index.tsx +++ b/web/app/components/base/checkbox-list/index.tsx @@ -101,12 +101,12 @@ const CheckboxList: FC = ({ return (
{label && ( -
+
{label}
)} {description && ( -
+
{description}
)} @@ -120,13 +120,14 @@ const CheckboxList: FC = ({ indeterminate={isIndeterminate} onCheck={handleSelectAll} disabled={disabled} + id="selectAll" /> )} {!searchQuery ? (
{title && ( - + {title} )} @@ -138,7 +139,7 @@ const CheckboxList: FC = ({
) : ( -
+
{ filteredOptions.length > 0 ? t('operation.searchCount', { ns: 'common', count: filteredOptions.length, content: title }) @@ -168,7 +169,7 @@ const CheckboxList: FC = ({ ? (
search menu - {t('operation.noSearchResults', { ns: 'common', content: title })} + {t('operation.noSearchResults', { ns: 'common', content: title })}
) @@ -198,9 +199,10 @@ const CheckboxList: FC = ({ handleToggleOption(option.value) }} disabled={option.disabled || disabled} + id={option.value} />
{option.label} diff --git a/web/app/components/base/confirm/index.spec.tsx b/web/app/components/base/confirm/index.spec.tsx new file mode 100644 index 0000000000..c2f67cc35e --- /dev/null +++ b/web/app/components/base/confirm/index.spec.tsx @@ -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('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() + expect(screen.getByText('test title')).toBeInTheDocument() + }) + + it('does not render on isShow false', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('hides after delay when isShow changes to false', () => { + vi.useFakeTimers() + const { rerender } = render() + expect(screen.getByText('test title')).toBeInTheDocument() + + rerender() + act(() => { + vi.advanceTimersByTime(200) + }) + expect(screen.queryByText('test title')).not.toBeInTheDocument() + vi.useRealTimers() + }) + + it('renders content when provided', () => { + render() + expect(screen.getByText('some description')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('showCancel prop works', () => { + render() + expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument() + }) + + it('showConfirm prop works', () => { + render() + 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() + expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument() + }) + + it('disables confirm button when isDisabled is true', () => { + render() + expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeDisabled() + }) + }) + + describe('User Interactions', () => { + it('clickAway is handled properly', () => { + render() + const overlay = screen.getByTestId('confirm-overlay') as HTMLElement + expect(overlay).toBeTruthy() + fireEvent.mouseDown(overlay) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('overlay click stops propagation', () => { + render() + 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() + const overlay = screen.getByTestId('confirm-overlay') as HTMLElement + fireEvent.mouseDown(overlay) + expect(onCancel).not.toHaveBeenCalled() + }) + + it('escape keyboard event works', () => { + render() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('Enter keyboard event works', () => { + render() + fireEvent.keyDown(document, { key: 'Enter' }) + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onCancel).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/base/confirm/index.tsx b/web/app/components/base/confirm/index.tsx index 6ac1c93a80..caca67f977 100644 --- a/web/app/components/base/confirm/index.tsx +++ b/web/app/components/base/confirm/index.tsx @@ -101,6 +101,7 @@ function Confirm({ e.preventDefault() e.stopPropagation() }} + data-testid="confirm-overlay" >
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": {