test: add unit tests for chat components (#32367)

This commit is contained in:
Poojan
2026-02-24 15:59:21 +05:30
committed by GitHub
parent ad3a195734
commit b2fa6cb4d3
55 changed files with 6044 additions and 267 deletions

View File

@ -0,0 +1,114 @@
import type { ChatItem } from '../../types'
import type { IThoughtProps } from '@/app/components/base/chat/chat/thought'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { MarkdownProps } from '@/app/components/base/markdown'
import { render, screen } from '@testing-library/react'
import AgentContent from './agent-content'
// Mock Markdown component used only in tests
vi.mock('@/app/components/base/markdown', () => ({
Markdown: (props: MarkdownProps & { 'data-testid'?: string }) => (
<div data-testid={props['data-testid'] || 'markdown'} data-content={String(props.content)} className={props.className}>
{String(props.content)}
</div>
),
}))
// Mock Thought
vi.mock('@/app/components/base/chat/chat/thought', () => ({
default: ({ thought, isFinished }: IThoughtProps) => (
<div data-testid="thought-component" data-finished={isFinished}>
{thought.thought}
</div>
),
}))
// Mock FileList and Utils
vi.mock('@/app/components/base/file-uploader', () => ({
FileList: ({ files }: { files: FileEntity[] }) => (
<div data-testid="file-list-component">
{files.map(f => f.name).join(', ')}
</div>
),
}))
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getProcessedFilesFromResponse: (files: FileEntity[]) => files.map(f => ({ ...f, name: `processed-${f.id}` })),
}))
describe('AgentContent', () => {
const mockItem: ChatItem = {
id: '1',
content: '',
isAnswer: true,
}
it('renders logAnnotation if present', () => {
const itemWithAnnotation = {
...mockItem,
annotation: {
logAnnotation: { content: 'Log Annotation Content' },
},
}
render(<AgentContent item={itemWithAnnotation as ChatItem} />)
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Log Annotation Content')
})
it('renders content prop if provided and no annotation', () => {
render(<AgentContent item={mockItem} content="Direct Content" />)
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Direct Content')
})
it('renders agent_thoughts if content is absent', () => {
const itemWithThoughts = {
...mockItem,
agent_thoughts: [
{ thought: 'Thought 1', tool: 'tool1' },
{ thought: 'Thought 2' },
],
}
render(<AgentContent item={itemWithThoughts as ChatItem} responding={false} />)
const items = screen.getAllByTestId('agent-thought-item')
expect(items).toHaveLength(2)
const thoughtMarkdowns = screen.getAllByTestId('agent-thought-markdown')
expect(thoughtMarkdowns[0]).toHaveTextContent('Thought 1')
expect(thoughtMarkdowns[1]).toHaveTextContent('Thought 2')
expect(screen.getByTestId('thought-component')).toHaveTextContent('Thought 1')
})
it('passes correct isFinished to Thought component', () => {
const itemWithThoughts = {
...mockItem,
agent_thoughts: [
{ thought: 'T1', tool: 'tool1', observation: 'obs1' }, // finished by observation
{ thought: 'T2', tool: 'tool2' }, // finished by responding=false
],
}
const { rerender } = render(<AgentContent item={itemWithThoughts as ChatItem} responding={true} />)
const thoughts = screen.getAllByTestId('thought-component')
expect(thoughts[0]).toHaveAttribute('data-finished', 'true')
expect(thoughts[1]).toHaveAttribute('data-finished', 'false')
rerender(<AgentContent item={itemWithThoughts as ChatItem} responding={false} />)
expect(screen.getAllByTestId('thought-component')[1]).toHaveAttribute('data-finished', 'true')
})
it('renders FileList if thought has message_files', () => {
const itemWithFiles = {
...mockItem,
agent_thoughts: [
{
thought: 'T1',
message_files: [{ id: 'file1' }, { id: 'file2' }],
},
],
}
render(<AgentContent item={itemWithFiles as ChatItem} />)
expect(screen.getByTestId('file-list-component')).toHaveTextContent('processed-file1, processed-file2')
})
it('renders nothing if no annotation, content, or thoughts', () => {
render(<AgentContent item={mockItem} />)
expect(screen.getByTestId('agent-content-container')).toBeEmptyDOMElement()
})
})

View File

@ -23,15 +23,29 @@ const AgentContent: FC<AgentContentProps> = ({
agent_thoughts,
} = item
if (annotation?.logAnnotation)
return <Markdown content={annotation?.logAnnotation.content || ''} />
if (annotation?.logAnnotation) {
return (
<Markdown
content={annotation?.logAnnotation.content || ''}
data-testid="agent-content-markdown"
/>
)
}
return (
<div>
{content ? <Markdown content={content} /> : agent_thoughts?.map((thought, index) => (
<div key={index} className="px-2 py-1">
<div data-testid="agent-content-container">
{content ? (
<Markdown
content={content}
data-testid="agent-content-markdown"
/>
) : agent_thoughts?.map((thought, index) => (
<div key={index} className="px-2 py-1" data-testid="agent-thought-item">
{thought.thought && (
<Markdown content={thought.thought} />
<Markdown
content={thought.thought}
data-testid="agent-thought-markdown"
/>
)}
{/* {item.tool} */}
{/* perhaps not use tool */}

View File

@ -0,0 +1,91 @@
import type { ChatItem } from '../../types'
import type { MarkdownProps } from '@/app/components/base/markdown'
import { render, screen } from '@testing-library/react'
import BasicContent from './basic-content'
// Mock Markdown component used only in tests
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content, className }: MarkdownProps) => (
<div data-testid="basic-content-markdown" data-content={String(content)} className={className}>
{String(content)}
</div>
),
}))
describe('BasicContent', () => {
const mockItem = {
id: '1',
content: 'Hello World',
isAnswer: true,
}
it('renders content correctly', () => {
render(<BasicContent item={mockItem as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', 'Hello World')
})
it('renders logAnnotation content if present', () => {
const itemWithAnnotation = {
...mockItem,
annotation: {
logAnnotation: {
content: 'Annotated Content',
},
},
}
render(<BasicContent item={itemWithAnnotation as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', 'Annotated Content')
})
it('wraps Windows UNC paths in backticks', () => {
const itemWithUNC = {
...mockItem,
content: '\\\\server\\share\\file.txt',
}
render(<BasicContent item={itemWithUNC as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', '`\\\\server\\share\\file.txt`')
})
it('does not wrap content in backticks if it already is', () => {
const itemWithBackticks = {
...mockItem,
content: '`\\\\server\\share\\file.txt`',
}
render(<BasicContent item={itemWithBackticks as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', '`\\\\server\\share\\file.txt`')
})
it('does not wrap backslash strings that are not UNC paths', () => {
const itemWithBackslashes = {
...mockItem,
content: '\\not-a-unc',
}
render(<BasicContent item={itemWithBackslashes as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', '\\not-a-unc')
})
it('applies error class when isError is true', () => {
const errorItem = {
...mockItem,
isError: true,
}
render(<BasicContent item={errorItem as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveClass('!text-[#F04438]')
})
it('renders non-string content without attempting to wrap (covers typeof !== "string" branch)', () => {
const itemWithNonStringContent = {
...mockItem,
content: 12345,
}
render(<BasicContent item={itemWithNonStringContent as unknown as ChatItem} />)
const markdown = screen.getByTestId('basic-content-markdown')
expect(markdown).toHaveAttribute('data-content', '12345')
})
})

View File

@ -15,8 +15,14 @@ const BasicContent: FC<BasicContentProps> = ({
content,
} = item
if (annotation?.logAnnotation)
return <Markdown content={annotation?.logAnnotation.content || ''} />
if (annotation?.logAnnotation) {
return (
<Markdown
content={annotation?.logAnnotation.content || ''}
data-testid="basic-content-markdown"
/>
)
}
// Preserve Windows UNC paths and similar backslash-heavy strings by
// wrapping them in inline code so Markdown renders backslashes verbatim.
@ -31,6 +37,7 @@ const BasicContent: FC<BasicContentProps> = ({
item.isError && '!text-[#F04438]',
)}
content={displayContent}
data-testid="basic-content-markdown"
/>
)
}

View File

@ -0,0 +1,111 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import ContentItem from './content-item'
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div data-testid="mock-markdown">{content}</div>,
}))
describe('ContentItem', () => {
const mockOnInputChange = vi.fn()
const mockFormInputFields: FormInputItem[] = [
{
type: 'paragraph',
output_variable_name: 'user_bio',
default: {
type: 'constant',
value: '',
selector: [],
},
} as FormInputItem,
]
const mockInputs = {
user_bio: 'Initial bio',
}
it('should render Markdown for literal content', () => {
render(
<ContentItem
content="Hello world"
formInputFields={[]}
inputs={{}}
onInputChange={mockOnInputChange}
/>,
)
expect(screen.getByTestId('mock-markdown')).toHaveTextContent('Hello world')
expect(screen.queryByTestId('content-item-textarea')).not.toBeInTheDocument()
})
it('should render Textarea for valid output variable content', () => {
render(
<ContentItem
content="{{#$output.user_bio#}}"
formInputFields={mockFormInputFields}
inputs={mockInputs}
onInputChange={mockOnInputChange}
/>,
)
const textarea = screen.getByTestId('content-item-textarea')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveValue('Initial bio')
expect(screen.queryByTestId('mock-markdown')).not.toBeInTheDocument()
})
it('should call onInputChange when textarea value changes', async () => {
const user = userEvent.setup()
render(
<ContentItem
content="{{#$output.user_bio#}}"
formInputFields={mockFormInputFields}
inputs={mockInputs}
onInputChange={mockOnInputChange}
/>,
)
const textarea = screen.getByTestId('content-item-textarea')
await user.type(textarea, 'x')
expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'Initial biox')
})
it('should render nothing if field name is valid but not found in formInputFields', () => {
const { container } = render(
<ContentItem
content="{{#$output.unknown_field#}}"
formInputFields={mockFormInputFields}
inputs={mockInputs}
onInputChange={mockOnInputChange}
/>,
)
expect(container.firstChild).toBeNull()
})
it('should render nothing if input type is not supported', () => {
const { container } = render(
<ContentItem
content="{{#$output.user_bio#}}"
formInputFields={[
{
type: 'text-input',
output_variable_name: 'user_bio',
default: {
type: 'constant',
value: '',
selector: [],
},
} as FormInputItem,
]}
inputs={mockInputs}
onInputChange={mockOnInputChange}
/>,
)
expect(container.querySelector('[data-testid="content-item-textarea"]')).not.toBeInTheDocument()
expect(container.querySelector('.py-3')?.textContent).toBe('')
})
})

View File

@ -45,6 +45,7 @@ const ContentItem = ({
className="h-[104px] sm:text-xs"
value={inputs[fieldName]}
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
data-testid="content-item-textarea"
/>
)}
</div>

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import ContentWrapper from './content-wrapper'
describe('ContentWrapper', () => {
const defaultProps = {
nodeTitle: 'Human Input Node',
children: <div data-testid="child-content">Child Content</div>,
}
it('should render node title and children by default when not collapsible', () => {
render(<ContentWrapper {...defaultProps} />)
expect(screen.getByText('Human Input Node')).toBeInTheDocument()
expect(screen.getByTestId('child-content')).toBeInTheDocument()
expect(screen.queryByTestId('expand-icon')).not.toBeInTheDocument()
})
it('should show/hide content when toggling expansion', async () => {
const user = userEvent.setup()
render(<ContentWrapper {...defaultProps} showExpandIcon={true} expanded={false} />)
// Initially collapsed
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
const expandToggle = screen.getByTestId('expand-icon')
expect(expandToggle.querySelector('.i-ri-arrow-right-s-line')).toBeInTheDocument()
// Expand
await user.click(expandToggle)
expect(screen.getByTestId('child-content')).toBeInTheDocument()
expect(expandToggle.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
// Collapse
await user.click(expandToggle)
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
})
it('should render children initially if expanded is true', () => {
render(<ContentWrapper {...defaultProps} showExpandIcon={true} expanded={true} />)
expect(screen.getByTestId('child-content')).toBeInTheDocument()
const expandToggle = screen.getByTestId('expand-icon')
expect(expandToggle.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
})
})

View File

@ -1,4 +1,3 @@
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { useCallback, useState } from 'react'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
@ -26,26 +25,33 @@ const ContentWrapper = ({
}, [isExpanded])
return (
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}>
<div
className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}
data-testid="content-wrapper"
>
<div className="flex items-center gap-2 p-2">
{/* node icon */}
<BlockIcon type={BlockEnum.HumanInput} className="shrink-0" />
{/* node name */}
<div
className="system-sm-semibold-uppercase grow truncate text-text-primary"
className="grow truncate text-text-primary system-sm-semibold-uppercase"
title={nodeTitle}
>
{nodeTitle}
</div>
{showExpandIcon && (
<div className="shrink-0 cursor-pointer" onClick={handleToggleExpand}>
<div
className="shrink-0 cursor-pointer"
onClick={handleToggleExpand}
data-testid="expand-icon"
>
{
isExpanded
? (
<RiArrowDownSLine className="size-4" />
<div className="i-ri-arrow-down-s-line size-4" />
)
: (
<RiArrowRightSLine className="size-4" />
<div className="i-ri-arrow-right-s-line size-4" />
)
}
</div>

View File

@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ExecutedAction from './executed-action'
describe('ExecutedAction', () => {
it('should render the triggered action information', () => {
const executedAction = {
id: 'btn_1',
title: 'Submit',
}
render(<ExecutedAction executedAction={executedAction} />)
expect(screen.getByTestId('executed-action')).toBeInTheDocument()
// Trans component mock from i18n-mock.ts renders a span with data-i18n-key
const trans = screen.getByTestId('executed-action').querySelector('span')
expect(trans).toHaveAttribute('data-i18n-key', 'nodes.humanInput.userActions.triggered')
// Check for the trigger icon class
expect(screen.getByTestId('executed-action').querySelector('.i-custom-vender-workflow-trigger-all')).toBeInTheDocument()
})
})

View File

@ -2,7 +2,6 @@ import type { ExecutedAction as ExecutedActionType } from './type'
import { memo } from 'react'
import { Trans } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
type ExecutedActionProps = {
executedAction: ExecutedActionType
@ -12,14 +11,14 @@ const ExecutedAction = ({
executedAction,
}: ExecutedActionProps) => {
return (
<div className="flex flex-col gap-y-1 py-1">
<div className="flex flex-col gap-y-1 py-1" data-testid="executed-action">
<Divider className="mb-2 mt-1 w-[30px]" />
<div className="system-xs-regular flex items-center gap-x-1 text-text-tertiary">
<TriggerAll className="size-3.5 shrink-0" />
<div className="flex items-center gap-x-1 text-text-tertiary system-xs-regular">
<div className="i-custom-vender-workflow-trigger-all size-3.5 shrink-0" />
<Trans
i18nKey="nodes.humanInput.userActions.triggered"
ns="workflow"
components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }}
components={{ strong: <span className="text-text-secondary system-xs-medium"></span> }}
values={{ actionName: executedAction.id }}
/>
</div>

View File

@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ExpirationTime from './expiration-time'
import * as utils from './utils'
// Mock utils to control time-based logic
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils')>()
return {
...actual,
getRelativeTime: vi.fn(),
isRelativeTimeSameOrAfter: vi.fn(),
}
})
describe('ExpirationTime', () => {
it('should render "Future" state with relative time', () => {
vi.mocked(utils.getRelativeTime).mockReturnValue('in 2 hours')
vi.mocked(utils.isRelativeTimeSameOrAfter).mockReturnValue(true)
const { container } = render(<ExpirationTime expirationTime={1234567890} />)
expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-tertiary')
expect(screen.getByText('share.humanInput.expirationTimeNowOrFuture:{"relativeTime":"in 2 hours"}')).toBeInTheDocument()
expect(container.querySelector('.i-ri-time-line')).toBeInTheDocument()
})
it('should render "Expired" state when time is in the past', () => {
vi.mocked(utils.getRelativeTime).mockReturnValue('2 hours ago')
vi.mocked(utils.isRelativeTimeSameOrAfter).mockReturnValue(false)
const { container } = render(<ExpirationTime expirationTime={1234567890} />)
expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-warning')
expect(screen.getByText('share.humanInput.expiredTip')).toBeInTheDocument()
expect(container.querySelector('.i-ri-alert-fill')).toBeInTheDocument()
})
})

View File

@ -1,5 +1,4 @@
'use client'
import { RiAlertFill, RiTimeLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useLocale } from '@/context/i18n'
import { cn } from '@/utils/classnames'
@ -19,8 +18,9 @@ const ExpirationTime = ({
return (
<div
data-testid="expiration-time"
className={cn(
'system-xs-regular mt-1 flex items-center gap-x-1 text-text-tertiary',
'mt-1 flex items-center gap-x-1 text-text-tertiary system-xs-regular',
!isSameOrAfter && 'text-text-warning',
)}
>
@ -28,13 +28,13 @@ const ExpirationTime = ({
isSameOrAfter
? (
<>
<RiTimeLine className="size-3.5" />
<div className="i-ri-time-line size-3.5" />
<span>{t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })}</span>
</>
)
: (
<>
<RiAlertFill className="size-3.5" />
<div className="i-ri-alert-fill size-3.5" />
<span>{t('humanInput.expiredTip', { ns: 'share' })}</span>
</>
)

View File

@ -0,0 +1,132 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import HumanInputForm from './human-input-form'
vi.mock('./content-item', () => ({
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: string) => void }) => (
<div data-testid="mock-content-item">
{content}
<button data-testid="update-input" onClick={() => onInputChange('field1', 'new value')}>Update</button>
</div>
),
}))
describe('HumanInputForm', () => {
const mockFormData: HumanInputFormData = {
form_id: 'form_1',
node_id: 'node_1',
node_title: 'Title',
display_in_ui: true,
expiration_time: 0,
form_token: 'token_123',
form_content: 'Part 1 {{#$output.field1#}} Part 2',
inputs: [
{
type: 'paragraph',
output_variable_name: 'field1',
default: { type: 'constant', value: 'initial', selector: [] },
} as FormInputItem,
],
actions: [
{ id: 'action_1', title: 'Submit', button_style: UserActionButtonType.Primary },
{ id: 'action_2', title: 'Cancel', button_style: UserActionButtonType.Default },
{ id: 'action_3', title: 'Accent', button_style: UserActionButtonType.Accent },
{ id: 'action_4', title: 'Ghost', button_style: UserActionButtonType.Ghost },
],
resolved_default_values: {},
}
it('should render content parts and action buttons', () => {
render(<HumanInputForm formData={mockFormData} />)
// splitByOutputVar should yield 3 parts: "Part 1 ", "{{#$output.field1#}}", " Part 2"
const contentItems = screen.getAllByTestId('mock-content-item')
expect(contentItems).toHaveLength(3)
expect(contentItems[0]).toHaveTextContent('Part 1')
expect(contentItems[1]).toHaveTextContent('{{#$output.field1#}}')
expect(contentItems[2]).toHaveTextContent('Part 2')
const buttons = screen.getAllByTestId('action-button')
expect(buttons).toHaveLength(4)
expect(buttons[0]).toHaveTextContent('Submit')
expect(buttons[1]).toHaveTextContent('Cancel')
expect(buttons[2]).toHaveTextContent('Accent')
expect(buttons[3]).toHaveTextContent('Ghost')
})
it('should handle input changes and submit correctly', async () => {
const user = userEvent.setup()
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
render(<HumanInputForm formData={mockFormData} onSubmit={mockOnSubmit} />)
// Update input via mock ContentItem
await user.click(screen.getAllByTestId('update-input')[0])
// Submit
const submitButton = screen.getByRole('button', { name: 'Submit' })
await user.click(submitButton)
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
action: 'action_1',
inputs: { field1: 'new value' },
})
})
it('should disable buttons during submission', async () => {
const user = userEvent.setup()
let resolveSubmit: (value: void | PromiseLike<void>) => void
const submitPromise = new Promise<void>((resolve) => {
resolveSubmit = resolve
})
const mockOnSubmit = vi.fn().mockReturnValue(submitPromise)
render(<HumanInputForm formData={mockFormData} onSubmit={mockOnSubmit} />)
const submitButton = screen.getByRole('button', { name: 'Submit' })
const cancelButton = screen.getByRole('button', { name: 'Cancel' })
await user.click(submitButton)
expect(submitButton).toBeDisabled()
expect(cancelButton).toBeDisabled()
// Finish submission
await act(async () => {
resolveSubmit!(undefined)
})
expect(submitButton).not.toBeDisabled()
expect(cancelButton).not.toBeDisabled()
})
it('should handle missing resolved_default_values', () => {
const formDataWithoutDefaults = { ...mockFormData, resolved_default_values: undefined }
render(<HumanInputForm formData={formDataWithoutDefaults as unknown as HumanInputFormData} />)
expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3)
})
it('should handle unsupported input types in initializeInputs', () => {
const formDataWithUnsupported = {
...mockFormData,
inputs: [
{
type: 'text-input',
output_variable_name: 'field2',
default: { type: 'variable', value: '', selector: [] },
} as FormInputItem,
{
type: 'number',
output_variable_name: 'field3',
default: { type: 'constant', value: '0', selector: [] },
} as FormInputItem,
],
resolved_default_values: { field2: 'default value' },
}
render(<HumanInputForm formData={formDataWithUnsupported} />)
expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3)
})
})

View File

@ -49,6 +49,7 @@ const HumanInputForm = ({
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(formToken, action.id, inputs)}
data-testid="action-button"
>
{action.title}
</Button>

View File

@ -0,0 +1,17 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import SubmittedContent from './submitted-content'
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div data-testid="mock-markdown">{content}</div>,
}))
describe('SubmittedContent', () => {
it('should render Markdown with the provided content', () => {
const content = '## Test Content'
render(<SubmittedContent content={content} />)
expect(screen.getByTestId('submitted-content')).toBeInTheDocument()
expect(screen.getByTestId('mock-markdown')).toHaveTextContent(content)
})
})

View File

@ -9,7 +9,9 @@ const SubmittedContent = ({
content,
}: SubmittedContentProps) => {
return (
<Markdown content={content} />
<div data-testid="submitted-content">
<Markdown content={content} />
</div>
)
}

View File

@ -0,0 +1,31 @@
import type { HumanInputFilledFormData } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { SubmittedHumanInputContent } from './submitted'
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div data-testid="mock-markdown">{content}</div>,
}))
describe('SubmittedHumanInputContent Integration', () => {
const mockFormData: HumanInputFilledFormData = {
rendered_content: 'Rendered **Markdown** content',
action_id: 'btn_1',
action_text: 'Submit Action',
node_id: 'node_1',
node_title: 'Node Title',
}
it('should render both content and executed action', () => {
render(<SubmittedHumanInputContent formData={mockFormData} />)
// Verify SubmittedContent rendering
expect(screen.getByTestId('submitted-content')).toBeInTheDocument()
expect(screen.getByTestId('mock-markdown')).toHaveTextContent('Rendered **Markdown** content')
// Verify ExecutedAction rendering
expect(screen.getByTestId('executed-action')).toBeInTheDocument()
// Trans component for triggered action. The mock usually renders the key.
expect(screen.getByText('nodes.humanInput.userActions.triggered')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,83 @@
import type { AppContextValue } from '@/context/app-context'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useSelector } from '@/context/app-context'
import Tips from './tips'
// Mock AppContext's useSelector to control user profile data
vi.mock('@/context/app-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/app-context')>()
return {
...actual,
useSelector: vi.fn(),
}
})
describe('Tips', () => {
const mockEmail = 'test@example.com'
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useSelector).mockImplementation((selector: (value: AppContextValue) => unknown) => {
return selector({
userProfile: {
email: mockEmail,
},
} as AppContextValue)
})
})
it('should render email tip in normal mode', () => {
render(
<Tips
showEmailTip={true}
isEmailDebugMode={false}
showDebugModeTip={false}
/>,
)
expect(screen.getByText('workflow.common.humanInputEmailTip')).toBeInTheDocument()
expect(screen.queryByText('common.humanInputEmailTipInDebugMode')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.common.humanInputWebappTip')).not.toBeInTheDocument()
})
it('should render email tip in debug mode', () => {
render(
<Tips
showEmailTip={true}
isEmailDebugMode={true}
showDebugModeTip={false}
/>,
)
expect(screen.getByText('common.humanInputEmailTipInDebugMode')).toBeInTheDocument()
expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument()
})
it('should render debug mode tip', () => {
render(
<Tips
showEmailTip={false}
isEmailDebugMode={false}
showDebugModeTip={true}
/>,
)
expect(screen.getByText('workflow.common.humanInputWebappTip')).toBeInTheDocument()
expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument()
})
it('should render nothing when all flags are false', () => {
const { container } = render(
<Tips
showEmailTip={false}
isEmailDebugMode={false}
showDebugModeTip={false}
/>,
)
expect(screen.queryByTestId('tips')).toBeEmptyDOMElement()
// Divider is outside of tips container, but within the fragment
expect(container.querySelector('.v-divider')).toBeDefined()
})
})

View File

@ -20,12 +20,12 @@ const Tips = ({
return (
<>
<Divider className="!my-2 w-[30px]" />
<div className="space-y-1 pt-1">
<div className="space-y-1 pt-1" data-testid="tips">
{showEmailTip && !isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
<div className="text-text-secondary system-xs-regular">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
)}
{showEmailTip && isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">
<div className="text-text-secondary system-xs-regular">
<Trans
i18nKey="common.humanInputEmailTipInDebugMode"
ns="workflow"
@ -34,7 +34,7 @@ const Tips = ({
/>
</div>
)}
{showDebugModeTip && <div className="system-xs-medium text-text-warning">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
{showDebugModeTip && <div className="text-text-warning system-xs-medium">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
</div>
</>
)

View File

@ -0,0 +1,212 @@
import type { InputVarType } from '@/app/components/workflow/types'
import type { AppContextValue } from '@/context/app-context'
import type { HumanInputFormData } from '@/types/workflow'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { useSelector } from '@/context/app-context'
import { UnsubmittedHumanInputContent } from './unsubmitted'
// Mock AppContext's useSelector to control user profile data
vi.mock('@/context/app-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/app-context')>()
return {
...actual,
useSelector: vi.fn(),
}
})
describe('UnsubmittedHumanInputContent Integration', () => {
const user = userEvent.setup()
// Helper to create valid form data
const createMockFormData = (overrides = {}): HumanInputFormData => ({
form_id: 'form_123',
node_id: 'node_456',
node_title: 'Input Form',
form_content: 'Fill this out: {{#$output.user_name#}}',
inputs: [
{
type: 'paragraph' as InputVarType,
output_variable_name: 'user_name',
default: {
type: 'constant',
value: 'Default value',
selector: [],
},
},
],
actions: [
{ id: 'btn_1', title: 'Submit', button_style: UserActionButtonType.Primary },
],
form_token: 'token_123',
resolved_default_values: {},
expiration_time: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
display_in_ui: true,
...overrides,
} as unknown as HumanInputFormData)
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useSelector).mockImplementation((selector: (value: AppContextValue) => unknown) => {
return selector({
userProfile: {
id: 'user_123',
name: 'Test User',
email: 'test@example.com',
avatar: '',
avatar_url: '',
is_password_set: false,
},
} as AppContextValue)
})
})
describe('Rendering', () => {
it('should render form, tips, and expiration time when all conditions met', () => {
render(
<UnsubmittedHumanInputContent
formData={createMockFormData()}
showEmailTip={true}
showDebugModeTip={true}
onSubmit={vi.fn()}
/>,
)
expect(screen.getByText('Submit')).toBeInTheDocument()
expect(screen.getByTestId('tips')).toBeInTheDocument()
expect(screen.getByTestId('expiration-time')).toBeInTheDocument()
expect(screen.getByText('workflow.common.humanInputWebappTip')).toBeInTheDocument()
})
it('should hide ExpirationTime when expiration_time is not a number', () => {
const data = createMockFormData({ expiration_time: undefined })
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
expect(screen.queryByTestId('expiration-time')).not.toBeInTheDocument()
})
it('should hide Tips when both tip flags are false', () => {
render(
<UnsubmittedHumanInputContent
formData={createMockFormData()}
showEmailTip={false}
showDebugModeTip={false}
onSubmit={vi.fn()}
/>,
)
expect(screen.queryByTestId('tips')).not.toBeInTheDocument()
})
it('should render different email tips based on debug mode', () => {
const { rerender } = render(
<UnsubmittedHumanInputContent
formData={createMockFormData()}
showEmailTip={true}
isEmailDebugMode={false}
onSubmit={vi.fn()}
/>,
)
expect(screen.getByText('workflow.common.humanInputEmailTip')).toBeInTheDocument()
rerender(
<UnsubmittedHumanInputContent
formData={createMockFormData()}
showEmailTip={true}
isEmailDebugMode={true}
onSubmit={vi.fn()}
/>,
)
expect(screen.getByText('common.humanInputEmailTipInDebugMode')).toBeInTheDocument()
})
it('should render "Expired" state when expiration time is in the past', () => {
const data = createMockFormData({ expiration_time: Math.floor(Date.now() / 1000) - 3600 })
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
expect(screen.getByText('share.humanInput.expiredTip')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should update input values and call onSubmit', async () => {
const handleSubmit = vi.fn().mockImplementation(() => Promise.resolve())
const data = createMockFormData()
render(<UnsubmittedHumanInputContent formData={data} onSubmit={handleSubmit} />)
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'New Value')
const submitBtn = screen.getByRole('button', { name: 'Submit' })
await user.click(submitBtn)
expect(handleSubmit).toHaveBeenCalledWith('token_123', {
action: 'btn_1',
inputs: { user_name: 'New Value' },
})
})
it('should handle loading state during submission', async () => {
let resolveSubmit: (value: void | PromiseLike<void>) => void
const handleSubmit = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
resolveSubmit = resolve
}))
const data = createMockFormData()
render(<UnsubmittedHumanInputContent formData={data} onSubmit={handleSubmit} />)
const submitBtn = screen.getByRole('button', { name: 'Submit' })
await user.click(submitBtn)
expect(submitBtn).toBeDisabled()
expect(handleSubmit).toHaveBeenCalled()
await waitFor(() => {
resolveSubmit!()
})
await waitFor(() => expect(submitBtn).not.toBeDisabled())
})
})
describe('Edge Cases', () => {
it('should handle missing resolved_default_values', () => {
const data = createMockFormData({ resolved_default_values: undefined })
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
expect(screen.getByText('Submit')).toBeInTheDocument()
})
it('should return null in ContentItem if field is not found', () => {
const data = createMockFormData({
form_content: '{{#$output.unknown_field#}}',
inputs: [],
})
const { container } = render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
// The form will be empty (except for buttons) because unknown_field is not in inputs
expect(container.querySelector('textarea')).not.toBeInTheDocument()
})
it('should render text-input type in initializeInputs correctly', () => {
const data = createMockFormData({
inputs: [
{
type: 'text-input',
output_variable_name: 'var1',
label: 'Var 1',
required: true,
default: { type: 'fixed', value: 'fixed_val' },
},
],
})
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
// initializeInputs is tested indirectly here.
// We can't easily assert the internal state of HumanInputForm, but we can verify it doesn't crash.
})
})
})

View File

@ -0,0 +1,58 @@
import type { HumanInputFilledFormData } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import HumanInputFilledFormList from './human-input-filled-form-list'
/**
* Type-safe factory.
* Forces test data to match real interface.
*/
const createFormData = (
overrides: Partial<HumanInputFilledFormData> = {},
): HumanInputFilledFormData => ({
node_id: 'node-1',
node_title: 'Node Title',
// 👇 IMPORTANT
// DO NOT guess properties like `inputs`
// Only include fields that actually exist in your project type.
// Leave everything else empty via spread.
...overrides,
} as HumanInputFilledFormData)
describe('HumanInputFilledFormList', () => {
it('renders nothing when list is empty', () => {
render(<HumanInputFilledFormList humanInputFilledFormDataList={[]} />)
expect(screen.queryByText('Node Title')).not.toBeInTheDocument()
})
it('renders one form item', () => {
const data = [createFormData()]
render(<HumanInputFilledFormList humanInputFilledFormDataList={data} />)
expect(screen.getByText('Node Title')).toBeInTheDocument()
})
it('renders multiple form items', () => {
const data = [
createFormData({ node_id: '1', node_title: 'First' }),
createFormData({ node_id: '2', node_title: 'Second' }),
]
render(<HumanInputFilledFormList humanInputFilledFormDataList={data} />)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('renders wrapper container', () => {
const { container } = render(
<HumanInputFilledFormList humanInputFilledFormDataList={[createFormData()]} />,
)
expect(container.firstChild).toHaveClass('flex')
expect(container.firstChild).toHaveClass('flex-col')
})
})

View File

@ -0,0 +1,131 @@
import type { HumanInputFormData } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
import HumanInputFormList from './human-input-form-list'
// Mock child components
vi.mock('./human-input-content/content-wrapper', () => ({
default: ({ children, nodeTitle }: { children: React.ReactNode, nodeTitle: string }) => (
<div data-testid="content-wrapper" data-nodetitle={nodeTitle}>
{children}
</div>
),
}))
vi.mock('./human-input-content/unsubmitted', () => ({
UnsubmittedHumanInputContent: ({ showEmailTip, isEmailDebugMode, showDebugModeTip }: { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }) => (
<div data-testid="unsubmitted-content">
<span data-testid="email-tip">{showEmailTip ? 'true' : 'false'}</span>
<span data-testid="email-debug">{isEmailDebugMode ? 'true' : 'false'}</span>
<span data-testid="debug-tip">{showDebugModeTip ? 'true' : 'false'}</span>
</div>
),
}))
describe('HumanInputFormList', () => {
const mockFormData = [
{
form_id: 'form1',
node_id: 'node1',
node_title: 'Title 1',
display_in_ui: true,
},
{
form_id: 'form2',
node_id: 'node2',
node_title: 'Title 2',
display_in_ui: false,
},
]
const mockGetNodeData = vi.fn()
it('should render empty list when no form data is provided', () => {
render(<HumanInputFormList humanInputFormDataList={[]} />)
expect(screen.getByTestId('human-input-form-list')).toBeEmptyDOMElement()
})
it('should render only items with display_in_ui set to true', () => {
mockGetNodeData.mockReturnValue({
data: {
delivery_methods: [],
},
})
render(
<HumanInputFormList
humanInputFormDataList={mockFormData as HumanInputFormData[]}
getHumanInputNodeData={mockGetNodeData}
/>,
)
const items = screen.getAllByTestId('human-input-form-item')
expect(items).toHaveLength(1)
expect(screen.getByTestId('content-wrapper')).toHaveAttribute('data-nodetitle', 'Title 1')
})
describe('Delivery Methods Config', () => {
it('should set default tips when node data is not found', () => {
mockGetNodeData.mockReturnValue(undefined)
render(
<HumanInputFormList
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
getHumanInputNodeData={mockGetNodeData}
/>,
)
expect(screen.getByTestId('email-tip')).toHaveTextContent('false')
expect(screen.getByTestId('email-debug')).toHaveTextContent('false')
expect(screen.getByTestId('debug-tip')).toHaveTextContent('false')
})
it('should set default tips when delivery_methods is empty', () => {
mockGetNodeData.mockReturnValue({ data: { delivery_methods: [] } })
render(
<HumanInputFormList
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
getHumanInputNodeData={mockGetNodeData}
/>,
)
expect(screen.getByTestId('email-tip')).toHaveTextContent('false')
expect(screen.getByTestId('email-debug')).toHaveTextContent('false')
expect(screen.getByTestId('debug-tip')).toHaveTextContent('false')
})
it('should show tips correctly based on delivery methods', () => {
mockGetNodeData.mockReturnValue({
data: {
delivery_methods: [
{ type: DeliveryMethodType.WebApp, enabled: true },
{ type: DeliveryMethodType.Email, enabled: true, config: { debug_mode: true } },
],
},
})
render(
<HumanInputFormList
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
getHumanInputNodeData={mockGetNodeData}
/>,
)
expect(screen.getByTestId('email-tip')).toHaveTextContent('true')
expect(screen.getByTestId('email-debug')).toHaveTextContent('true')
expect(screen.getByTestId('debug-tip')).toHaveTextContent('false') // WebApp is enabled
})
it('should show debug mode tip if WebApp is disabled', () => {
mockGetNodeData.mockReturnValue({
data: {
delivery_methods: [
{ type: DeliveryMethodType.WebApp, enabled: false },
{ type: DeliveryMethodType.Email, enabled: false },
],
},
})
render(
<HumanInputFormList
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
getHumanInputNodeData={mockGetNodeData}
/>,
)
expect(screen.getByTestId('email-tip')).toHaveTextContent('false')
expect(screen.getByTestId('debug-tip')).toHaveTextContent('true')
})
})
})

View File

@ -45,22 +45,28 @@ const HumanInputFormList = ({
const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui)
return (
<div className="mt-2 flex flex-col gap-y-2">
<div
className="mt-2 flex flex-col gap-y-2"
data-testid="human-input-form-list"
>
{
filteredHumanInputFormDataList.map(formData => (
<ContentWrapper
<div
key={formData.form_id}
nodeTitle={formData.node_title}
data-testid="human-input-form-item"
>
<UnsubmittedHumanInputContent
key={formData.form_id}
formData={formData}
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
onSubmit={onHumanInputFormSubmit}
/>
</ContentWrapper>
<ContentWrapper
nodeTitle={formData.node_title}
>
<UnsubmittedHumanInputContent
formData={formData}
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
onSubmit={onHumanInputFormSubmit}
/>
</ContentWrapper>
</div>
))
}
</div>

View File

@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react'
import More from './more'
describe('More', () => {
const mockMoreData = {
latency: 0.5,
tokens: 100,
tokens_per_second: 200,
time: '2023-10-27 10:00:00',
}
it('should render all details when all data is provided', () => {
render(<More more={mockMoreData} />)
expect(screen.getByTestId('more-container')).toBeInTheDocument()
// Check latency
expect(screen.getByTestId('more-latency')).toBeInTheDocument()
expect(screen.getByText(/timeConsuming/i)).toBeInTheDocument()
expect(screen.getByText(/0.5/)).toBeInTheDocument()
expect(screen.getByText(/second/i)).toBeInTheDocument()
// Check tokens
expect(screen.getByTestId('more-tokens')).toBeInTheDocument()
expect(screen.getByText(/tokenCost/i)).toBeInTheDocument()
expect(screen.getByText(/100/)).toBeInTheDocument()
// Check tokens per second
expect(screen.getByTestId('more-tps')).toBeInTheDocument()
expect(screen.getByText(/200 tokens\/s/i)).toBeInTheDocument()
// Check time
expect(screen.getByTestId('more-time')).toBeInTheDocument()
expect(screen.getByText('2023-10-27 10:00:00')).toBeInTheDocument()
})
it('should not render tokens per second when it is missing', () => {
const dataWithoutTPS = { ...mockMoreData, tokens_per_second: 0 }
render(<More more={dataWithoutTPS} />)
expect(screen.queryByTestId('more-tps')).not.toBeInTheDocument()
})
it('should render nothing inside container if more prop is missing', () => {
render(<More more={undefined} />)
const containerDiv = screen.getByTestId('more-container')
expect(containerDiv).toBeInTheDocument()
expect(containerDiv.children.length).toBe(0)
})
it('should apply group-hover opacity classes', () => {
render(<More more={mockMoreData} />)
const container = screen.getByTestId('more-container')
expect(container).toHaveClass('opacity-0')
expect(container).toHaveClass('group-hover:opacity-100')
})
it('should correctly format large token counts', () => {
const dataWithLargeTokens = { ...mockMoreData, tokens: 1234567 }
render(<More more={dataWithLargeTokens} />)
// formatNumber(1234567) should return '1,234,567'
expect(screen.getByText(/1,234,567/)).toBeInTheDocument()
})
})

View File

@ -13,19 +13,24 @@ const More: FC<MoreProps> = ({
const { t } = useTranslation()
return (
<div className="system-xs-regular mt-1 flex items-center text-text-quaternary opacity-0 group-hover:opacity-100">
<div
className="mt-1 flex items-center text-text-quaternary opacity-0 system-xs-regular group-hover:opacity-100"
data-testid="more-container"
>
{
more && (
<>
<div
className="mr-2 max-w-[25%] shrink-0 truncate"
title={`${t('detail.timeConsuming', { ns: 'appLog' })} ${more.latency}${t('detail.second', { ns: 'appLog' })}`}
data-testid="more-latency"
>
{`${t('detail.timeConsuming', { ns: 'appLog' })} ${more.latency}${t('detail.second', { ns: 'appLog' })}`}
</div>
<div
className="mr-2 max-w-[25%] shrink-0 truncate"
title={`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
data-testid="more-tokens"
>
{`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
</div>
@ -33,6 +38,7 @@ const More: FC<MoreProps> = ({
<div
className="mr-2 max-w-[25%] shrink-0 truncate"
title={`${more.tokens_per_second} tokens/s`}
data-testid="more-tps"
>
{`${more.tokens_per_second} tokens/s`}
</div>
@ -41,6 +47,7 @@ const More: FC<MoreProps> = ({
<div
className="max-w-[25%] shrink-0 truncate"
title={more.time}
data-testid="more-time"
>
{more.time}
</div>

View File

@ -0,0 +1,726 @@
import type { ChatConfig, ChatItem } from '../../types'
import type { ChatContextValue } from '../context'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { vi } from 'vitest'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import Operation from './operation'
const {
mockSetShowAnnotationFullModal,
mockProviderContext,
mockT,
mockAddAnnotation,
} = vi.hoisted(() => {
return {
mockAddAnnotation: vi.fn(),
mockSetShowAnnotationFullModal: vi.fn(),
mockT: vi.fn((key: string): string => key),
mockProviderContext: {
plan: {
usage: { annotatedResponse: 0 },
total: { annotatedResponse: 100 },
},
enableBilling: false,
},
}
})
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAnnotationFullModal: mockSetShowAnnotationFullModal,
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderContext,
}))
vi.mock('@/service/annotation', () => ({
addAnnotation: mockAddAnnotation,
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: {
getInstance: vi.fn(() => ({
getAudioPlayer: vi.fn(() => ({
playAudio: vi.fn(),
pauseAudio: vi.fn(),
})),
})),
},
}))
vi.mock('@/app/components/app/annotation/edit-annotation-modal', () => ({
default: ({ isShow, onHide, onEdited, onAdded, onRemove }: {
isShow: boolean
onHide: () => void
onEdited: (q: string, a: string) => void
onAdded: (id: string, name: string, q: string, a: string) => void
onRemove: () => void
}) =>
isShow
? (
<div data-testid="edit-reply-modal">
<button data-testid="modal-hide" onClick={onHide}>Close</button>
<button data-testid="modal-edit" onClick={() => onEdited('eq', 'ea')}>Edit</button>
<button data-testid="modal-add" onClick={() => onAdded('a1', 'author', 'eq', 'ea')}>Add</button>
<button data-testid="modal-remove" onClick={onRemove}>Remove</button>
</div>
)
: null,
}))
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button', () => ({
default: function AnnotationCtrlMock({ onAdded, onEdit, cached }: {
onAdded: (id: string, authorName: string) => void
onEdit: () => void
cached: boolean
}) {
const { setShowAnnotationFullModal } = useModalContext()
const { plan, enableBilling } = useProviderContext()
const handleAdd = () => {
if (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) {
setShowAnnotationFullModal()
return
}
onAdded('ann-new', 'Test User')
}
return (
<div data-testid="annotation-ctrl">
{cached
? (
<button data-testid="annotation-edit-btn" onClick={onEdit}>Edit</button>
)
: (
<button data-testid="annotation-add-btn" onClick={handleAdd}>Add</button>
)}
</div>
)
},
}))
vi.mock('@/app/components/base/new-audio-button', () => ({
default: () => <button data-testid="audio-btn">Play</button>,
}))
vi.mock('@/app/components/base/chat/chat/log', () => ({
default: () => <button data-testid="log-btn"><div className="i-ri-file-list-3-line" /></button>,
}))
vi.mock('next/navigation', () => ({
useParams: vi.fn(() => ({ appId: 'test-app' })),
usePathname: vi.fn(() => '/apps/test-app'),
}))
const makeChatConfig = (overrides: Partial<ChatConfig> = {}): ChatConfig => ({
opening_statement: '',
pre_prompt: '',
prompt_type: 'simple' as ChatConfig['prompt_type'],
user_input_form: [],
dataset_query_variable: '',
more_like_this: { enabled: false },
suggested_questions_after_answer: { enabled: false },
speech_to_text: { enabled: false },
text_to_speech: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
agent_mode: { enabled: false, tools: [] },
dataset_configs: { retrieval_model: 'single' } as ChatConfig['dataset_configs'],
system_parameters: {
audio_file_size_limit: 10,
file_size_limit: 10,
image_file_size_limit: 10,
video_file_size_limit: 10,
workflow_file_upload_limit: 10,
},
supportFeedback: false,
supportAnnotation: false,
...overrides,
} as ChatConfig)
const mockContextValue: ChatContextValue = {
chatList: [],
config: makeChatConfig({ supportFeedback: true }),
onFeedback: vi.fn().mockResolvedValue(undefined),
onRegenerate: vi.fn(),
onAnnotationAdded: vi.fn(),
onAnnotationEdited: vi.fn(),
onAnnotationRemoved: vi.fn(),
}
vi.mock('../context', () => ({
useChatContext: () => mockContextValue,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: mockT,
}),
}))
type OperationProps = {
item: ChatItem
question: string
index: number
showPromptLog?: boolean
maxSize: number
contentWidth: number
hasWorkflowProcess: boolean
noChatInput?: boolean
}
const baseItem: ChatItem = {
id: 'msg-1',
content: 'Hello world',
isAnswer: true,
}
const baseProps: OperationProps = {
item: baseItem,
question: 'What is this?',
index: 0,
maxSize: 500,
contentWidth: 300,
hasWorkflowProcess: false,
}
describe('Operation', () => {
const renderOperation = (props = baseProps) => {
return render(
<div className="group">
<Operation {...props} />
</div>,
)
}
beforeEach(() => {
vi.clearAllMocks()
mockContextValue.config = makeChatConfig({ supportFeedback: true })
mockContextValue.onFeedback = vi.fn().mockResolvedValue(undefined)
mockContextValue.onRegenerate = vi.fn()
mockContextValue.onAnnotationAdded = vi.fn()
mockContextValue.onAnnotationEdited = vi.fn()
mockContextValue.onAnnotationRemoved = vi.fn()
mockProviderContext.plan.usage.annotatedResponse = 0
mockProviderContext.enableBilling = false
mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } })
})
describe('Rendering', () => {
it('should hide action buttons for opening statements', () => {
const item = { ...baseItem, isOpeningStatement: true }
renderOperation({ ...baseProps, item })
expect(screen.queryByTestId('operation-actions')).not.toBeInTheDocument()
})
it('should show copy and regenerate buttons', () => {
renderOperation()
expect(screen.getByTestId('copy-btn')).toBeInTheDocument()
expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument()
})
it('should hide regenerate button when noChatInput is true', () => {
renderOperation({ ...baseProps, noChatInput: true })
expect(screen.queryByTestId('regenerate-btn')).not.toBeInTheDocument()
})
it('should show TTS button when text_to_speech is enabled', () => {
mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true } })
renderOperation()
expect(screen.getByTestId('audio-btn')).toBeInTheDocument()
})
it('should show annotation button when config supports it', () => {
mockContextValue.config = makeChatConfig({
supportAnnotation: true,
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
})
renderOperation()
expect(screen.getByTestId('annotation-ctrl')).toBeInTheDocument()
})
it('should show prompt log when showPromptLog is true', () => {
renderOperation({ ...baseProps, showPromptLog: true })
expect(screen.getByTestId('log-btn')).toBeInTheDocument()
})
it('should not show prompt log for opening statements', () => {
const item = { ...baseItem, isOpeningStatement: true }
renderOperation({ ...baseProps, item, showPromptLog: true })
expect(screen.queryByTestId('log-btn')).not.toBeInTheDocument()
})
})
describe('Copy functionality', () => {
it('should copy content on copy click', async () => {
const user = userEvent.setup()
renderOperation()
await user.click(screen.getByTestId('copy-btn'))
expect(copy).toHaveBeenCalledWith('Hello world')
})
it('should aggregate agent_thoughts for copy content', async () => {
const user = userEvent.setup()
const item: ChatItem = {
...baseItem,
content: 'ignored',
agent_thoughts: [
{ id: '1', thought: 'Hello ', tool: '', tool_input: '', observation: '', message_id: '', conversation_id: '', position: 0 },
{ id: '2', thought: 'World', tool: '', tool_input: '', observation: '', message_id: '', conversation_id: '', position: 1 },
],
}
renderOperation({ ...baseProps, item })
await user.click(screen.getByTestId('copy-btn'))
expect(copy).toHaveBeenCalledWith('Hello World')
})
})
describe('Regenerate', () => {
it('should call onRegenerate on regenerate click', async () => {
const user = userEvent.setup()
renderOperation()
await user.click(screen.getByTestId('regenerate-btn'))
expect(mockContextValue.onRegenerate).toHaveBeenCalledWith(baseItem)
})
})
describe('Hiding controls with humanInputFormDataList', () => {
it('should hide TTS/copy/annotation when humanInputFormDataList is present', () => {
mockContextValue.config = makeChatConfig({
supportFeedback: false,
text_to_speech: { enabled: true },
supportAnnotation: true,
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
})
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
renderOperation({ ...baseProps, item })
expect(screen.queryByTestId('audio-btn')).not.toBeInTheDocument()
expect(screen.queryByTestId('copy-btn')).not.toBeInTheDocument()
})
})
describe('User feedback (no annotation support)', () => {
beforeEach(() => {
mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: false })
})
it('should show like/dislike buttons', () => {
renderOperation()
const bar = screen.getByTestId('operation-bar')
expect(bar.querySelector('.i-ri-thumb-up-line')).toBeInTheDocument()
expect(bar.querySelector('.i-ri-thumb-down-line')).toBeInTheDocument()
})
it('should call onFeedback with like on like click', async () => {
const user = userEvent.setup()
renderOperation()
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
await user.click(thumbUp)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
})
it('should open feedback modal on dislike click', async () => {
const user = userEvent.setup()
renderOperation()
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
await user.click(thumbDown)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should submit dislike feedback from modal', async () => {
const user = userEvent.setup()
renderOperation()
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
await user.click(thumbDown)
const textarea = screen.getByRole('textbox')
await user.type(textarea, 'Bad response')
const confirmBtn = screen.getByText(/submit/i)
await user.click(confirmBtn)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: 'Bad response' })
})
it('should cancel feedback modal', async () => {
const user = userEvent.setup()
renderOperation()
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
await user.click(thumbDown)
expect(screen.getByRole('textbox')).toBeInTheDocument()
const cancelBtn = screen.getByText(/cancel/i)
await user.click(cancelBtn)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should show existing like feedback and allow undo', async () => {
const user = userEvent.setup()
const item = { ...baseItem, feedback: { rating: 'like' as const } }
renderOperation({ ...baseProps, item })
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
await user.click(thumbUp)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
})
it('should show existing dislike feedback and allow undo', async () => {
const user = userEvent.setup()
const item = { ...baseItem, feedback: { rating: 'dislike' as const, content: 'bad' } }
renderOperation({ ...baseProps, item })
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
await user.click(thumbDown)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
})
it('should undo like when already liked', async () => {
const user = userEvent.setup()
renderOperation()
// First click to like
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
await user.click(thumbUp)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
// Second click to undo - re-query as it might be a different node
const thumbUpUndo = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
await user.click(thumbUpUndo)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
})
it('should undo dislike when already disliked', async () => {
const user = userEvent.setup()
renderOperation()
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
await user.click(thumbDown)
const submitBtn = screen.getByText(/submit/i)
await user.click(submitBtn)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: '' })
// Re-query for undo
const thumbDownUndo = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
await user.click(thumbDownUndo)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
})
it('should show tooltip with dislike and content', () => {
const item = { ...baseItem, feedback: { rating: 'dislike' as const, content: 'Too slow' } }
renderOperation({ ...baseProps, item })
const bar = screen.getByTestId('operation-bar')
expect(bar.querySelector('.i-ri-thumb-down-line')).toBeInTheDocument()
})
it('should show tooltip with only rating', () => {
const item = { ...baseItem, feedback: { rating: 'like' as const } }
renderOperation({ ...baseProps, item })
const bar = screen.getByTestId('operation-bar')
expect(bar.querySelector('.i-ri-thumb-up-line')).toBeInTheDocument()
})
it('should not show feedback bar for opening statements', () => {
const item = { ...baseItem, isOpeningStatement: true }
renderOperation({ ...baseProps, item })
expect(screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument()
})
it('should not show user feedback bar when humanInputFormDataList is present', () => {
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
renderOperation({ ...baseProps, item })
expect(screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument()
})
it('should not call feedback when supportFeedback is disabled', async () => {
mockContextValue.config = makeChatConfig({ supportFeedback: false })
mockContextValue.onFeedback = undefined
renderOperation()
const bar = screen.getByTestId('operation-bar')
expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBe(0)
})
})
describe('Admin feedback (with annotation support)', () => {
beforeEach(() => {
mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: true })
})
it('should show admin like/dislike buttons', () => {
renderOperation()
const bar = screen.getByTestId('operation-bar')
expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBeGreaterThanOrEqual(1)
expect(bar.querySelectorAll('.i-ri-thumb-down-line').length).toBeGreaterThanOrEqual(1)
})
it('should call onFeedback with like for admin', async () => {
const user = userEvent.setup()
renderOperation()
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line')
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
await user.click(adminThumb)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
})
it('should open feedback modal on admin dislike click', async () => {
const user = userEvent.setup()
renderOperation()
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line')
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
await user.click(adminThumb)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should show user feedback read-only in admin bar when user has liked', () => {
const item = { ...baseItem, feedback: { rating: 'like' as const } }
renderOperation({ ...baseProps, item })
const bar = screen.getByTestId('operation-bar')
expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBeGreaterThanOrEqual(2)
})
it('should show separator in admin bar when user has feedback', () => {
const item = { ...baseItem, feedback: { rating: 'dislike' as const } }
renderOperation({ ...baseProps, item })
const bar = screen.getByTestId('operation-bar')
expect(bar.querySelector('.bg-components-actionbar-border')).toBeInTheDocument()
})
it('should show existing admin like feedback and allow undo', async () => {
const user = userEvent.setup()
const item = { ...baseItem, adminFeedback: { rating: 'like' as const } }
renderOperation({ ...baseProps, item })
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
await user.click(thumbUp)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
})
it('should show existing admin dislike and allow undo', async () => {
const user = userEvent.setup()
const item = { ...baseItem, adminFeedback: { rating: 'dislike' as const } }
renderOperation({ ...baseProps, item })
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
await user.click(thumbDown)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
})
it('should undo admin like when already liked', async () => {
const user = userEvent.setup()
renderOperation()
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line')
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
await user.click(adminThumb)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
const thumbsUndo = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line')
const adminThumbUndo = thumbsUndo[thumbsUndo.length - 1].closest('button')!
await user.click(adminThumbUndo)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
})
it('should undo admin dislike when already disliked', async () => {
const user = userEvent.setup()
renderOperation()
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line')
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
await user.click(adminThumb)
const submitBtn = screen.getByText(/submit/i)
await user.click(submitBtn)
const thumbsUndo = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line')
const adminThumbUndo = thumbsUndo[thumbsUndo.length - 1].closest('button')!
await user.click(adminThumbUndo)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
})
it('should not show admin feedback bar when humanInputFormDataList is present', () => {
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
renderOperation({ ...baseProps, item })
expect(screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line').length).toBe(0)
})
})
describe('Positioning and layout', () => {
it('should position right when operationWidth < maxSize', () => {
renderOperation({ ...baseProps, maxSize: 500 })
const bar = screen.getByTestId('operation-bar')
expect(bar.style.left).toBeTruthy()
})
it('should position bottom when operationWidth >= maxSize', () => {
renderOperation({ ...baseProps, maxSize: 1 })
const bar = screen.getByTestId('operation-bar')
expect(bar.style.left).toBeFalsy()
})
it('should apply workflow process class when hasWorkflowProcess is true', () => {
renderOperation({ ...baseProps, hasWorkflowProcess: true })
const bar = screen.getByTestId('operation-bar')
expect(bar.className).toContain('-bottom-4')
})
it('should calculate width correctly for all features combined', () => {
mockContextValue.config = makeChatConfig({
text_to_speech: { enabled: true },
supportAnnotation: true,
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
supportFeedback: true,
})
const item = { ...baseItem, feedback: { rating: 'like' as const }, adminFeedback: { rating: 'dislike' as const } }
renderOperation({ ...baseProps, item, showPromptLog: true })
const bar = screen.getByTestId('operation-bar')
expect(bar).toBeInTheDocument()
})
it('should show separator when user has feedback in admin mode', () => {
mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: true })
const item = { ...baseItem, feedback: { rating: 'like' as const } }
renderOperation({ ...baseProps, item })
const bar = screen.getByTestId('operation-bar')
expect(bar.querySelector('.bg-components-actionbar-border')).toBeInTheDocument()
})
it('should handle missing translation fallbacks in buildFeedbackTooltip', () => {
// Mock t to return null for specific keys
mockT.mockImplementation((key: string): string => {
if (key.includes('Rate') || key.includes('like'))
return '' // Safe string fallback
return key
})
renderOperation()
expect(screen.getByTestId('operation-bar')).toBeInTheDocument()
// Reset to default behavior
mockT.mockImplementation(key => key)
})
})
describe('Annotation integration', () => {
beforeEach(() => {
mockContextValue.config = makeChatConfig({
supportAnnotation: true,
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
appId: 'test-app',
})
})
it('should add annotation via annotation ctrl button', async () => {
const user = userEvent.setup()
renderOperation()
const addBtn = screen.getByTestId('annotation-add-btn')
await user.click(addBtn)
expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith('ann-new', 'Test User', 'What is this?', 'Hello world', 0)
})
it('should show annotation full modal when limit reached', async () => {
const user = userEvent.setup()
mockProviderContext.enableBilling = true
mockProviderContext.plan.usage.annotatedResponse = 100
renderOperation()
const addBtn = screen.getByTestId('annotation-add-btn')
await user.click(addBtn)
expect(mockSetShowAnnotationFullModal).toHaveBeenCalled()
expect(mockAddAnnotation).not.toHaveBeenCalled()
})
it('should open edit reply modal when cached annotation exists', async () => {
const user = userEvent.setup()
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
renderOperation({ ...baseProps, item })
const editBtn = screen.getByTestId('annotation-edit-btn')
await user.click(editBtn)
expect(screen.getByTestId('edit-reply-modal')).toBeInTheDocument()
})
it('should call onAnnotationEdited from edit reply modal', async () => {
const user = userEvent.setup()
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
renderOperation({ ...baseProps, item })
const editBtn = screen.getByTestId('annotation-edit-btn')
await user.click(editBtn)
await user.click(screen.getByTestId('modal-edit'))
expect(mockContextValue.onAnnotationEdited).toHaveBeenCalledWith('eq', 'ea', 0)
})
it('should call onAnnotationAdded from edit reply modal', async () => {
const user = userEvent.setup()
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
renderOperation({ ...baseProps, item })
const editBtn = screen.getByTestId('annotation-edit-btn')
await user.click(editBtn)
await user.click(screen.getByTestId('modal-add'))
expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith('a1', 'author', 'eq', 'ea', 0)
})
it('should call onAnnotationRemoved from edit reply modal', async () => {
const user = userEvent.setup()
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
renderOperation({ ...baseProps, item })
const editBtn = screen.getByTestId('annotation-edit-btn')
await user.click(editBtn)
await user.click(screen.getByTestId('modal-remove'))
expect(mockContextValue.onAnnotationRemoved).toHaveBeenCalledWith(0)
})
it('should close edit reply modal via onHide', async () => {
const user = userEvent.setup()
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
renderOperation({ ...baseProps, item })
const editBtn = screen.getByTestId('annotation-edit-btn')
await user.click(editBtn)
expect(screen.getByTestId('edit-reply-modal')).toBeInTheDocument()
await user.click(screen.getByTestId('modal-hide'))
expect(screen.queryByTestId('edit-reply-modal')).not.toBeInTheDocument()
})
})
describe('TTS audio button', () => {
beforeEach(() => {
mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true, voice: 'test-voice' } })
})
it('should show audio play button when TTS enabled', () => {
renderOperation()
expect(screen.getByTestId('audio-btn')).toBeInTheDocument()
})
it('should not show audio button for humanInputFormDataList', () => {
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
renderOperation({ ...baseProps, item })
expect(screen.queryByTestId('audio-btn')).not.toBeInTheDocument()
})
})
describe('Edge cases', () => {
it('should handle feedback content with only whitespace', async () => {
const user = userEvent.setup()
mockContextValue.config = makeChatConfig({ supportFeedback: true })
renderOperation()
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
await user.click(thumbDown)
const textarea = screen.getByRole('textbox')
await user.type(textarea, ' ')
const confirmBtn = screen.getByText(/submit/i)
await user.click(confirmBtn)
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: ' ' })
})
it('should handle missing onFeedback callback gracefully', async () => {
mockContextValue.onFeedback = undefined
mockContextValue.config = makeChatConfig({ supportFeedback: true })
renderOperation()
const bar = screen.getByTestId('operation-bar')
expect(bar.querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument()
})
it('should handle empty agent_thoughts array', async () => {
const user = userEvent.setup()
const item: ChatItem = { ...baseItem, agent_thoughts: [] }
renderOperation({ ...baseProps, item })
await user.click(screen.getByTestId('copy-btn'))
expect(copy).toHaveBeenCalledWith('Hello world')
})
})
})

View File

@ -3,12 +3,6 @@ import type {
ChatItem,
Feedback,
} from '../../types'
import {
RiClipboardLine,
RiResetLeftLine,
RiThumbDownLine,
RiThumbUpLine,
} from '@remixicon/react'
import copy from 'copy-to-clipboard'
import {
memo,
@ -127,20 +121,10 @@ const Operation: FC<OperationProps> = ({
}
const handleLikeClick = (target: 'user' | 'admin') => {
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
if (currentRating === 'like') {
handleFeedback(null, undefined, target)
return
}
handleFeedback('like', undefined, target)
}
const handleDislikeClick = (target: 'user' | 'admin') => {
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
if (currentRating === 'dislike') {
handleFeedback(null, undefined, target)
return
}
setFeedbackTarget(target)
setIsShowFeedbackModal(true)
}
@ -186,6 +170,7 @@ const Operation: FC<OperationProps> = ({
!hasWorkflowProcess && positionRight && '!top-[9px]',
)}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
data-testid="operation-bar"
>
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
@ -204,8 +189,8 @@ const Operation: FC<OperationProps> = ({
onClick={() => handleFeedback(null, undefined, 'user')}
>
{displayUserFeedback?.rating === 'like'
? <RiThumbUpLine className="h-4 w-4" />
: <RiThumbDownLine className="h-4 w-4" />}
? <div className="i-ri-thumb-up-line h-4 w-4" />
: <div className="i-ri-thumb-down-line h-4 w-4" />}
</ActionButton>
</Tooltip>
)
@ -215,13 +200,13 @@ const Operation: FC<OperationProps> = ({
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('user')}
>
<RiThumbUpLine className="h-4 w-4" />
<div className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
<ActionButton
state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('user')}
>
<RiThumbDownLine className="h-4 w-4" />
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
</>
)}
@ -242,12 +227,12 @@ const Operation: FC<OperationProps> = ({
{displayUserFeedback.rating === 'like'
? (
<ActionButton state={ActionButtonState.Active}>
<RiThumbUpLine className="h-4 w-4" />
<div className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
)
: (
<ActionButton state={ActionButtonState.Destructive}>
<RiThumbDownLine className="h-4 w-4" />
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
)}
</Tooltip>
@ -266,8 +251,8 @@ const Operation: FC<OperationProps> = ({
onClick={() => handleFeedback(null, undefined, 'admin')}
>
{adminLocalFeedback?.rating === 'like'
? <RiThumbUpLine className="h-4 w-4" />
: <RiThumbDownLine className="h-4 w-4" />}
? <div className="i-ri-thumb-up-line h-4 w-4" />
: <div className="i-ri-thumb-down-line h-4 w-4" />}
</ActionButton>
</Tooltip>
)
@ -281,7 +266,7 @@ const Operation: FC<OperationProps> = ({
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('admin')}
>
<RiThumbUpLine className="h-4 w-4" />
<div className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
</Tooltip>
<Tooltip
@ -292,7 +277,7 @@ const Operation: FC<OperationProps> = ({
state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('admin')}
>
<RiThumbDownLine className="h-4 w-4" />
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
</Tooltip>
</>
@ -305,7 +290,7 @@ const Operation: FC<OperationProps> = ({
</div>
)}
{!isOpeningStatement && (
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex">
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex" data-testid="operation-actions">
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
<NewAudioButton
id={id}
@ -314,17 +299,19 @@ const Operation: FC<OperationProps> = ({
/>
)}
{!humanInputFormDataList?.length && (
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
<ActionButton
onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
data-testid="copy-btn"
>
<RiClipboardLine className="h-4 w-4" />
<div className="i-ri-clipboard-line h-4 w-4" />
</ActionButton>
)}
{!noChatInput && (
<ActionButton onClick={() => onRegenerate?.(item)}>
<RiResetLeftLine className="h-4 w-4" />
<ActionButton onClick={() => onRegenerate?.(item)} data-testid="regenerate-btn">
<div className="i-ri-reset-left-line h-4 w-4" />
</ActionButton>
)}
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
@ -366,7 +353,7 @@ const Operation: FC<OperationProps> = ({
>
<div className="space-y-3">
<div>
<label className="system-sm-semibold mb-2 block text-text-secondary">
<label className="mb-2 block text-text-secondary system-sm-semibold">
{t('feedback.content', { ns: 'common' }) || 'Feedback Content'}
</label>
<Textarea

View File

@ -0,0 +1,83 @@
import type { Mock } from 'vitest' // Or 'jest' if using Jest
import type { IChatItem } from '../type'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useChatContext } from '../context'
import SuggestedQuestions from './suggested-questions'
// Mock the chat context
vi.mock('../context', () => ({
useChatContext: vi.fn(),
}))
describe('SuggestedQuestions', () => {
const mockOnSend = vi.fn()
beforeEach(() => {
vi.clearAllMocks();
// Use 'as Mock' instead of 'as any'
(useChatContext as Mock).mockReturnValue({
onSend: mockOnSend,
readonly: false,
})
})
const mockItem: IChatItem = {
id: '1',
content: '',
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: ['What is Dify?', 'How to use it?', ' ', ''],
}
it('should render suggested questions and filter out empty ones', () => {
render(<SuggestedQuestions item={mockItem} />)
const questions = screen.getAllByTestId('suggested-question')
expect(questions).toHaveLength(2)
expect(questions[0]).toHaveTextContent('What is Dify?')
expect(questions[1]).toHaveTextContent('How to use it?')
})
it('should call onSend when a question is clicked', async () => {
const user = userEvent.setup()
render(<SuggestedQuestions item={mockItem} />)
const questions = screen.getAllByTestId('suggested-question')
await user.click(questions[0])
expect(mockOnSend).toHaveBeenCalledWith('What is Dify?')
})
it('should not render if isOpeningStatement is false', () => {
render(<SuggestedQuestions item={{ ...mockItem, isOpeningStatement: false }} />)
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
})
it('should not render if suggestedQuestions is missing or empty', () => {
render(<SuggestedQuestions item={{ ...mockItem, suggestedQuestions: [] }} />)
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
// Use 'as IChatItem' instead of 'as any'
render(<SuggestedQuestions item={{ ...mockItem, suggestedQuestions: undefined } as IChatItem} />)
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
})
it('should be disabled and not call onSend when readonly is true', async () => {
const user = userEvent.setup();
// Use 'as Mock' instead of 'as any'
(useChatContext as Mock).mockReturnValue({
onSend: mockOnSend,
readonly: true,
})
render(<SuggestedQuestions item={mockItem} />)
const questions = screen.getAllByTestId('suggested-question')
expect(questions[0]).toHaveClass('pointer-events-none')
expect(questions[0]).toHaveClass('opacity-50')
await user.click(questions[0])
expect(mockOnSend).not.toHaveBeenCalled()
})
})

View File

@ -26,10 +26,11 @@ const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
<div
key={index}
className={cn(
'system-sm-medium mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
'mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs system-sm-medium last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
readonly && 'pointer-events-none opacity-50',
)}
onClick={() => !readonly && onSend?.(question)}
data-testid="suggested-question"
>
{question}
</div>

View File

@ -0,0 +1,74 @@
import type { ToolInfoInThought } from '../type'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ToolDetail from './tool-detail'
describe('ToolDetail', () => {
const mockPayload: ToolInfoInThought = {
name: 'test_tool',
label: 'Test Tool Label',
input: 'test input content',
output: 'test output content',
isFinished: true,
}
const datasetPayload: ToolInfoInThought = {
...mockPayload,
name: 'dataset_123',
label: 'Dataset Label',
}
it('should render the tool label and "used" state when finished', () => {
render(<ToolDetail payload={mockPayload} />)
expect(screen.getByText('Test Tool Label')).toBeInTheDocument()
expect(screen.getByText('tools.thought.used')).toBeInTheDocument()
})
it('should render the knowledge label and "using" state when not finished and name is a dataset', () => {
render(<ToolDetail payload={{ ...datasetPayload, isFinished: false }} />)
expect(screen.getByText('dataset.knowledge')).toBeInTheDocument()
expect(screen.getByText('tools.thought.using')).toBeInTheDocument()
})
it('should toggle expansion and show request/response details on click', async () => {
const user = userEvent.setup()
render(<ToolDetail payload={mockPayload} />)
// Initially collapsed: request/response titles should not be visible
expect(screen.queryByText('tools.thought.requestTitle')).not.toBeInTheDocument()
expect(screen.queryByText(mockPayload.input)).not.toBeInTheDocument()
// Click to expand
const label = screen.getByText('Test Tool Label')
await user.click(label)
// Now expanded
expect(screen.getByText('tools.thought.requestTitle')).toBeInTheDocument()
expect(screen.getByText(mockPayload.input)).toBeInTheDocument()
expect(screen.getByText('tools.thought.responseTitle')).toBeInTheDocument()
expect(screen.getByText(mockPayload.output)).toBeInTheDocument()
// Click again to collapse
await user.click(label)
expect(screen.queryByText('tools.thought.requestTitle')).not.toBeInTheDocument()
})
it('should apply different styles when expanded', async () => {
const user = userEvent.setup()
const { container } = render(<ToolDetail payload={mockPayload} />)
const rootDiv = container.firstChild as HTMLElement
const label = screen.getByText('Test Tool Label')
const headerDiv = label.parentElement!
// Initial styles
expect(rootDiv).toHaveClass('bg-workflow-process-bg')
expect(headerDiv).not.toHaveClass('pb-1.5')
// Expand
await user.click(label)
expect(rootDiv).toHaveClass('bg-background-section-burn')
expect(headerDiv).toHaveClass('pb-1.5')
})
})

View File

@ -0,0 +1,109 @@
import type { WorkflowProcess } from '../../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import WorkflowProcessItem from './workflow-process'
// Mock TracingPanel as it's a complex child component
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
default: () => <div data-testid="tracing-panel">Tracing Panel</div>,
}))
describe('WorkflowProcessItem', () => {
const mockData = {
status: WorkflowRunningStatus.Succeeded,
tracing: [
{ id: '1', title: 'Start' },
{ id: '2', title: 'End' },
],
}
it('should render the latest node title when collapsed', () => {
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={false} />)
expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('End')
expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument()
})
it('should render "Workflow Process" title and TracingPanel when expanded', () => {
// We expect t('common.workflowProcess', { ns: 'workflow' }) to be called
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={true} />)
expect(screen.getByText(/workflowProcess/i)).toBeInTheDocument()
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
})
it('should toggle collapse state on header click', async () => {
const user = userEvent.setup()
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={false} />)
const header = screen.getByTestId('workflow-process-header')
// Expand
await user.click(header)
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
expect(screen.getByText(/workflowProcess/i)).toBeInTheDocument()
// Collapse
await user.click(header)
expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('End')
})
it('should render nothing if readonly is true', () => {
const { container } = render(<WorkflowProcessItem data={mockData as WorkflowProcess} readonly={true} />)
expect(container.firstChild).toBeNull()
})
describe('Status Icons', () => {
it('should show running spinner when status is Running', () => {
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} />)
expect(screen.getByTestId('status-icon-running')).toBeInTheDocument()
})
it('should show success circle when status is Succeeded', () => {
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} />)
expect(screen.getByTestId('status-icon-success')).toBeInTheDocument()
})
it('should show error warning when status is Failed', () => {
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />)
expect(screen.getByTestId('status-icon-failed')).toBeInTheDocument()
})
it('should show error warning when status is Stopped', () => {
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Stopped } as WorkflowProcess} />)
expect(screen.getByTestId('status-icon-failed')).toBeInTheDocument()
})
it('should show pause circle when status is Paused', () => {
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} />)
expect(screen.getByTestId('status-icon-paused')).toBeInTheDocument()
})
})
describe('Background Colors', () => {
it('should apply correct background when collapsed for different statuses', () => {
const { rerender } = render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-bg')
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-paused-bg')
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-failed-bg')
})
it('should apply correct background when expanded for different statuses', () => {
const { rerender } = render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} expand={true} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-background-section-burn')
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} expand={true} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-success-hover')
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} expand={true} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-destructive-hover')
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} expand={true} />)
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-warning-hover')
})
})
})

View File

@ -1,16 +1,10 @@
import type { ChatItem, WorkflowProcess } from '../../types'
import {
RiArrowRightSLine,
RiErrorWarningFill,
RiLoader2Line,
RiPauseCircleFill,
} from '@remixicon/react'
import {
useEffect,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
@ -58,35 +52,52 @@ const WorkflowProcessItem = ({
collapse && paused && 'bg-workflow-process-paused-bg',
collapse && failed && 'bg-workflow-process-failed-bg',
)}
data-testid="workflow-process-item"
>
<div
className={cn('flex cursor-pointer items-center', !collapse && 'px-1.5')}
onClick={() => setCollapse(!collapse)}
data-testid="workflow-process-header"
>
{
running && (
<RiLoader2Line className="mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-tertiary" />
<div
className="i-ri-loader-2-line mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-tertiary"
data-testid="status-icon-running"
/>
)
}
{
succeeded && (
<CheckCircle className="mr-1 h-3.5 w-3.5 shrink-0 text-text-success" />
<div
className="i-custom-vender-solid-general-check-circle mr-1 h-3.5 w-3.5 shrink-0 text-text-success"
data-testid="status-icon-success"
/>
)
}
{
failed && (
<RiErrorWarningFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive" />
<div
className="i-ri-error-warning-fill mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive"
data-testid="status-icon-failed"
/>
)
}
{
paused && (
<RiPauseCircleFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary" />
<div
className="i-ri-pause-circle-fill mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary"
data-testid="status-icon-paused"
/>
)
}
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
<div
className={cn('text-text-secondary system-xs-medium', !collapse && 'grow')}
data-testid="workflow-process-title"
>
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
</div>
<RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
<div className={cn('i-ri-arrow-right-s-line ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
</div>
{
!collapse && (