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 && (

View File

@@ -0,0 +1,568 @@
import type { FileUpload } from '@/app/components/base/features/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { TransferMethod } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { vi } from 'vitest'
import ChatInputArea from './index'
// ---------------------------------------------------------------------------
// Hoist shared mock references so they are available inside vi.mock factories
// ---------------------------------------------------------------------------
const { mockGetPermission, mockNotify } = vi.hoisted(() => ({
mockGetPermission: vi.fn().mockResolvedValue(undefined),
mockNotify: vi.fn(),
}))
// ---------------------------------------------------------------------------
// External dependency mocks
// ---------------------------------------------------------------------------
vi.mock('js-audio-recorder', () => ({
default: class {
static getPermission = mockGetPermission
start = vi.fn()
stop = vi.fn()
getWAVBlob = vi.fn().mockReturnValue(new Blob([''], { type: 'audio/wav' }))
getRecordAnalyseData = vi.fn().mockReturnValue(new Uint8Array(128))
},
}))
vi.mock('@/service/share', () => ({
audioToText: vi.fn().mockResolvedValue({ text: 'Converted text' }),
AppSourceType: { webApp: 'webApp', installedApp: 'installedApp' },
}))
// ---------------------------------------------------------------------------
// File-uploader store shared mutable state so individual tests can mutate it
// ---------------------------------------------------------------------------
const mockFileStore: { files: FileEntity[], setFiles: ReturnType<typeof vi.fn> } = {
files: [],
setFiles: vi.fn(),
}
vi.mock('@/app/components/base/file-uploader/store', () => ({
useFileStore: () => ({ getState: () => mockFileStore }),
useStore: (selector: (s: typeof mockFileStore) => unknown) => selector(mockFileStore),
FileContextProvider: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
}))
// ---------------------------------------------------------------------------
// File-uploader hooks provide stable drag/drop handlers
// ---------------------------------------------------------------------------
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
useFile: () => ({
handleDragFileEnter: vi.fn(),
handleDragFileLeave: vi.fn(),
handleDragFileOver: vi.fn(),
handleDropFile: vi.fn(),
handleClipboardPasteFile: vi.fn(),
isDragActive: false,
}),
}))
// ---------------------------------------------------------------------------
// Features context hook avoids needing FeaturesContext.Provider in the tree
// ---------------------------------------------------------------------------
// FeatureBar calls: useFeatures(s => s.features)
// So the selector receives the store state object; we must nest the features
// under a `features` key to match what the real store exposes.
const mockFeaturesState = {
features: {
moreLikeThis: { enabled: false },
opening: { enabled: false },
moderation: { enabled: false },
speech2text: { enabled: false },
text2speech: { enabled: false },
file: { enabled: false },
suggested: { enabled: false },
citation: { enabled: false },
annotationReply: { enabled: false },
},
}
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (s: typeof mockFeaturesState) => unknown) =>
selector(mockFeaturesState),
}))
// ---------------------------------------------------------------------------
// Toast context
// ---------------------------------------------------------------------------
vi.mock('@/app/components/base/toast', async () => {
const actual = await vi.importActual<typeof import('@/app/components/base/toast')>(
'@/app/components/base/toast',
)
return {
...actual,
useToastContext: () => ({ notify: mockNotify }),
}
})
// ---------------------------------------------------------------------------
// Internal layout hook controls single/multi-line textarea mode
// ---------------------------------------------------------------------------
let mockIsMultipleLine = false
vi.mock('./hooks', () => ({
useTextAreaHeight: () => ({
wrapperRef: { current: document.createElement('div') },
textareaRef: { current: document.createElement('textarea') },
textValueRef: { current: document.createElement('div') },
holdSpaceRef: { current: document.createElement('div') },
handleTextareaResize: vi.fn(),
get isMultipleLine() {
return mockIsMultipleLine
},
}),
}))
// ---------------------------------------------------------------------------
// Input-forms validation hook always passes by default
// ---------------------------------------------------------------------------
vi.mock('../check-input-forms-hooks', () => ({
useCheckInputsForms: () => ({
checkInputsForm: vi.fn().mockReturnValue(true),
}),
}))
// ---------------------------------------------------------------------------
// Next.js navigation
// ---------------------------------------------------------------------------
vi.mock('next/navigation', () => ({
useParams: () => ({ token: 'test-token' }),
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/test',
}))
// ---------------------------------------------------------------------------
// Shared fixture typed as FileUpload to avoid implicit any
// ---------------------------------------------------------------------------
// const mockVisionConfig: FileUpload = {
// fileUploadConfig: {
// image_file_size_limit: 10,
// file_size_limit: 10,
// audio_file_size_limit: 10,
// video_file_size_limit: 10,
// workflow_file_upload_limit: 10,
// },
// allowed_file_types: [],
// allowed_file_extensions: [],
// enabled: true,
// number_limits: 3,
// transfer_methods: ['local_file', 'remote_url'],
// } as FileUpload
const mockVisionConfig: FileUpload = {
// Required because of '& EnabledOrDisabled' at the end of your type
enabled: true,
// The nested config object
fileUploadConfig: {
image_file_size_limit: 10,
file_size_limit: 10,
audio_file_size_limit: 10,
video_file_size_limit: 10,
workflow_file_upload_limit: 10,
batch_count_limit: 0,
image_file_batch_limit: 0,
single_chunk_attachment_limit: 0,
attachment_image_file_size_limit: 0,
file_upload_limit: 0,
},
// These match the keys in your FileUpload type
allowed_file_types: [],
allowed_file_extensions: [],
number_limits: 3,
// NOTE: Your type defines 'allowed_file_upload_methods',
// not 'transfer_methods' at the top level.
allowed_file_upload_methods: ['local_file', 'remote_url'] as TransferMethod[],
// If you wanted to define specific image/video behavior:
image: {
enabled: true,
number_limits: 3,
transfer_methods: ['local_file', 'remote_url'] as TransferMethod[],
},
}
// ---------------------------------------------------------------------------
// Minimal valid FileEntity fixture avoids undefined `type` crash in FileItem
// ---------------------------------------------------------------------------
const makeFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'file-1',
name: 'photo.png',
type: 'image/png', // required: FileItem calls type.split('/')[0]
size: 1024,
progress: 100,
transferMethod: 'local_file',
uploadedId: 'uploaded-ok',
...overrides,
} as FileEntity)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const getTextarea = () => screen.getByPlaceholderText(/inputPlaceholder/i)
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ChatInputArea', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFileStore.files = []
mockIsMultipleLine = false
})
// -------------------------------------------------------------------------
describe('Rendering', () => {
it('should render the textarea with default placeholder', () => {
render(<ChatInputArea visionConfig={mockVisionConfig} />)
expect(getTextarea()).toBeInTheDocument()
})
it('should render the readonly placeholder when readonly prop is set', () => {
render(<ChatInputArea visionConfig={mockVisionConfig} readonly />)
expect(screen.getByPlaceholderText(/inputDisabledPlaceholder/i)).toBeInTheDocument()
})
it('should render the send button', () => {
render(<ChatInputArea visionConfig={mockVisionConfig} />)
expect(screen.getByTestId('send-button')).toBeInTheDocument()
})
it('should apply disabled styles when the disabled prop is true', () => {
const { container } = render(<ChatInputArea visionConfig={mockVisionConfig} disabled />)
const disabledWrapper = container.querySelector('.pointer-events-none')
expect(disabledWrapper).toBeInTheDocument()
})
it('should render the operation section inline when single-line', () => {
// mockIsMultipleLine is false by default
render(<ChatInputArea visionConfig={mockVisionConfig} />)
expect(screen.getByTestId('send-button')).toBeInTheDocument()
})
it('should render the operation section below the textarea when multi-line', () => {
mockIsMultipleLine = true
render(<ChatInputArea visionConfig={mockVisionConfig} />)
expect(screen.getByTestId('send-button')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
describe('Typing', () => {
it('should update textarea value as the user types', async () => {
const user = userEvent.setup()
render(<ChatInputArea visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello world')
expect(getTextarea()).toHaveValue('Hello world')
})
it('should clear the textarea after a message is successfully sent', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello world')
await user.click(screen.getByTestId('send-button'))
expect(getTextarea()).toHaveValue('')
})
})
// -------------------------------------------------------------------------
describe('Sending Messages', () => {
it('should call onSend with query and files when clicking the send button', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello world')
await user.click(screen.getByTestId('send-button'))
expect(onSend).toHaveBeenCalledTimes(1)
expect(onSend).toHaveBeenCalledWith('Hello world', [])
})
it('should call onSend and reset the input when pressing Enter', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello world{Enter}')
expect(onSend).toHaveBeenCalledWith('Hello world', [])
expect(getTextarea()).toHaveValue('')
})
it('should NOT call onSend when pressing Shift+Enter (inserts newline instead)', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello world{Shift>}{Enter}{/Shift}')
expect(onSend).not.toHaveBeenCalled()
expect(getTextarea()).toHaveValue('Hello world\n')
})
it('should NOT call onSend in readonly mode', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} readonly />)
await user.click(screen.getByTestId('send-button'))
expect(onSend).not.toHaveBeenCalled()
})
it('should pass already-uploaded files to onSend', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
// makeFile ensures `type` is always a proper MIME string
const uploadedFile = makeFile({ id: 'file-1', name: 'photo.png', uploadedId: 'uploaded-123' })
mockFileStore.files = [uploadedFile]
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'With attachment')
await user.click(screen.getByTestId('send-button'))
expect(onSend).toHaveBeenCalledWith('With attachment', [uploadedFile])
})
})
// -------------------------------------------------------------------------
describe('History Navigation', () => {
it('should restore the last sent message when pressing Cmd+ArrowUp once', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()
await user.type(textarea, 'First{Enter}')
await user.type(textarea, 'Second{Enter}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
expect(textarea).toHaveValue('Second')
})
it('should go further back in history with repeated Cmd+ArrowUp', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()
await user.type(textarea, 'First{Enter}')
await user.type(textarea, 'Second{Enter}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
expect(textarea).toHaveValue('First')
})
it('should move forward in history when pressing Cmd+ArrowDown', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()
await user.type(textarea, 'First{Enter}')
await user.type(textarea, 'Second{Enter}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → Second
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → First
await user.type(textarea, '{Meta>}{ArrowDown}{/Meta}') // → Second
expect(textarea).toHaveValue('Second')
})
it('should clear the input when navigating past the most recent history entry', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()
await user.type(textarea, 'First{Enter}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → First
await user.type(textarea, '{Meta>}{ArrowDown}{/Meta}') // → past end → ''
expect(textarea).toHaveValue('')
})
it('should not go below the start of history when pressing Cmd+ArrowUp at the boundary', async () => {
const user = userEvent.setup()
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()
await user.type(textarea, 'Only{Enter}')
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → Only
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → '' (seed at index 0)
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // boundary should stay at ''
expect(textarea).toHaveValue('')
})
})
// -------------------------------------------------------------------------
describe('Voice Input', () => {
it('should render the voice input button when speech-to-text is enabled', () => {
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
expect(screen.getByTestId('voice-input-button')).toBeInTheDocument()
})
it('should NOT render the voice input button when speech-to-text is disabled', () => {
render(<ChatInputArea speechToTextConfig={{ enabled: false }} visionConfig={mockVisionConfig} />)
expect(screen.queryByTestId('voice-input-button')).not.toBeInTheDocument()
})
it('should request microphone permission when the voice button is clicked', async () => {
const user = userEvent.setup()
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
await user.click(screen.getByTestId('voice-input-button'))
expect(mockGetPermission).toHaveBeenCalledTimes(1)
})
it('should notify with an error when microphone permission is denied', async () => {
const user = userEvent.setup()
mockGetPermission.mockRejectedValueOnce(new Error('Permission denied'))
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
await user.click(screen.getByTestId('voice-input-button'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
})
it('should NOT invoke onSend while voice input is being activated', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(
<ChatInputArea
onSend={onSend}
speechToTextConfig={{ enabled: true }}
visionConfig={mockVisionConfig}
/>,
)
await user.click(screen.getByTestId('voice-input-button'))
expect(onSend).not.toHaveBeenCalled()
})
})
// -------------------------------------------------------------------------
describe('Validation', () => {
it('should notify and NOT call onSend when the query is blank', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.click(screen.getByTestId('send-button'))
expect(onSend).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
})
it('should notify and NOT call onSend when the query contains only whitespace', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), ' ')
await user.click(screen.getByTestId('send-button'))
expect(onSend).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
})
it('should notify and NOT call onSend while the bot is already responding', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<ChatInputArea onSend={onSend} isResponding visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello')
await user.click(screen.getByTestId('send-button'))
expect(onSend).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
})
it('should notify and NOT call onSend while a file upload is still in progress', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
// uploadedId is empty string → upload not yet finished
mockFileStore.files = [
makeFile({ id: 'file-upload', uploadedId: '', progress: 50 }),
]
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'Hello')
await user.click(screen.getByTestId('send-button'))
expect(onSend).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
})
it('should call onSend normally when all uploaded files have completed', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
// uploadedId is present → upload finished
mockFileStore.files = [makeFile({ uploadedId: 'uploaded-ok' })]
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
await user.type(getTextarea(), 'With completed file')
await user.click(screen.getByTestId('send-button'))
expect(onSend).toHaveBeenCalledTimes(1)
})
})
// -------------------------------------------------------------------------
describe('Feature Bar', () => {
it('should render the FeatureBar section when showFeatureBar is true', () => {
const { container } = render(
<ChatInputArea visionConfig={mockVisionConfig} showFeatureBar />,
)
// FeatureBar renders a rounded-bottom container beneath the input
expect(container.querySelector('[class*="rounded-b"]')).toBeInTheDocument()
})
it('should NOT render the FeatureBar when showFeatureBar is false', () => {
const { container } = render(
<ChatInputArea visionConfig={mockVisionConfig} showFeatureBar={false} />,
)
expect(container.querySelector('[class*="rounded-b"]')).not.toBeInTheDocument()
})
it('should not invoke onFeatureBarClick when the component is in readonly mode', async () => {
const user = userEvent.setup()
const onFeatureBarClick = vi.fn()
render(
<ChatInputArea
visionConfig={mockVisionConfig}
showFeatureBar
readonly
onFeatureBarClick={onFeatureBarClick}
/>,
)
// In readonly mode the FeatureBar receives `noop` as its click handler.
// Click every button that is not a named test-id button to exercise the guard.
const buttons = screen.queryAllByRole('button')
for (const btn of buttons) {
if (!btn.dataset.testid)
await user.click(btn)
}
expect(onFeatureBarClick).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,170 @@
import type { EnableType } from '../../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Theme } from '../../embedded-chatbot/theme/theme-context'
import Operation from './operation'
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInChatInput: ({ readonly }: { readonly?: boolean }) => (
<div data-testid="file-uploader" data-readonly={readonly} />
),
}))
const createMockTheme = (overrides?: Partial<Theme>): Theme => {
const theme = new Theme()
theme.primaryColor = 'rgb(255, 0, 0)'
return Object.assign(theme, overrides || {})
}
describe('Operation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render send button always', () => {
render(<Operation onSend={vi.fn()} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render file uploader when fileConfig.enabled is true', () => {
const fileConfig: FileUpload = { enabled: true } as FileUpload
render(
<Operation
onSend={vi.fn()}
fileConfig={fileConfig}
/>,
)
expect(screen.getByTestId('file-uploader')).toBeInTheDocument()
})
it('should not render file uploader when fileConfig is undefined', () => {
render(<Operation onSend={vi.fn()} />)
expect(screen.queryByTestId('file-uploader')).not.toBeInTheDocument()
})
it('should render voice input button when speechToTextConfig.enabled is true', () => {
const speechConfig: EnableType = { enabled: true }
render(
<Operation
onSend={vi.fn()}
speechToTextConfig={speechConfig}
/>,
)
expect(screen.getAllByRole('button')).toHaveLength(2)
})
it('should not render voice input button when speechToTextConfig.enabled is false', () => {
const speechConfig: EnableType = { enabled: false }
render(
<Operation
onSend={vi.fn()}
speechToTextConfig={speechConfig}
/>,
)
expect(screen.getAllByRole('button')).toHaveLength(1)
})
})
describe('Send Button Behavior', () => {
it('should call onSend when clicked and not readonly', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<Operation onSend={onSend} />)
await user.click(screen.getByRole('button'))
expect(onSend).toHaveBeenCalledTimes(1)
})
it('should not call onSend when readonly is true', async () => {
const user = userEvent.setup()
const onSend = vi.fn()
render(<Operation onSend={onSend} readonly />)
await user.click(screen.getByRole('button'))
expect(onSend).not.toHaveBeenCalled()
})
it('should apply theme primaryColor as background style when theme is provided', () => {
render(
<Operation
onSend={vi.fn()}
theme={createMockTheme()}
/>,
)
expect(screen.getByRole('button')).toHaveStyle({
backgroundColor: 'rgb(255, 0, 0)',
})
})
it('should not apply background style when theme is null', () => {
render(
<Operation
onSend={vi.fn()}
theme={null}
/>,
)
expect(screen.getByRole('button').style.backgroundColor).toBe('')
})
})
describe('Voice Input Button', () => {
it('should call onShowVoiceInput when clicked', async () => {
const user = userEvent.setup()
const onShowVoiceInput = vi.fn()
render(
<Operation
onSend={vi.fn()}
speechToTextConfig={{ enabled: true }}
onShowVoiceInput={onShowVoiceInput}
/>,
)
const buttons = screen.getAllByRole('button')
const voiceButton = buttons[0]
await user.click(voiceButton)
expect(onShowVoiceInput).toHaveBeenCalledTimes(1)
})
it('should disable voice button when readonly is true', async () => {
const user = userEvent.setup()
const onShowVoiceInput = vi.fn()
render(
<Operation
onSend={vi.fn()}
speechToTextConfig={{ enabled: true }}
onShowVoiceInput={onShowVoiceInput}
readonly
/>,
)
const buttons = screen.getAllByRole('button')
const voiceButton = buttons[0]
expect(voiceButton).toBeDisabled()
await user.click(voiceButton)
expect(onShowVoiceInput).not.toHaveBeenCalled()
})
})
})

View File

@@ -51,6 +51,7 @@ const Operation: FC<OperationProps> = ({
size="l"
disabled={readonly}
onClick={onShowVoiceInput}
data-testid="voice-input-button"
>
<RiMicLine className="h-5 w-5" />
</ActionButton>
@@ -61,6 +62,7 @@ const Operation: FC<OperationProps> = ({
className="ml-3 w-8 px-0"
variant="primary"
onClick={readonly ? noop : onSend}
data-testid="send-button"
style={
theme
? {

View File

@@ -0,0 +1,364 @@
import type { CitationItem } from '../type'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import Citation from './index'
vi.mock('./popup', () => ({
default: ({ data, showHitInfo }: { data: { documentName: string }, showHitInfo?: boolean }) => (
<div data-testid="popup" data-show-hit-info={String(!!showHitInfo)}>
{data.documentName}
</div>
),
}))
const originalClientWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
type ClientWidthConfig = {
container: number
item: number
}
const mockClientWidths = ({ container, item }: ClientWidthConfig) => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
get() {
const el = this as HTMLElement
if (el.className?.includes?.('chat-answer-container') || el.className?.includes?.('my-custom-container'))
return container
if (el.dataset?.testid === 'citation-measurement-item')
return item
return 0
},
configurable: true,
})
}
const restoreClientWidth = () => {
if (originalClientWidthDescriptor)
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidthDescriptor)
}
afterAll(() => {
restoreClientWidth()
})
const makeCitationItem = (overrides: Partial<CitationItem> = {}): CitationItem => ({
document_id: 'doc-1',
document_name: 'Document One',
data_source_type: 'upload_file',
segment_id: 'seg-1',
content: 'Some content',
dataset_id: 'dataset-1',
dataset_name: 'Dataset One',
segment_position: 1,
word_count: 100,
hit_count: 5,
index_node_hash: 'abc123',
score: 0.95,
...overrides,
})
const setupContainer = (className = 'chat-answer-container') => {
const wrapper = document.createElement('div')
wrapper.className = className
document.body.appendChild(wrapper)
return wrapper
}
describe('Citation', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
restoreClientWidth()
})
describe('Rendering', () => {
it('should render the citation title section', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem()]} />)
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
})
it('should render one measurement ghost item per unique document', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1', document_name: 'Alpha' }),
makeCitationItem({ document_id: 'doc-2', document_name: 'Beta' }),
]}
/>,
)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(2)
})
it('should display the document name inside each measurement item', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem({ document_name: 'My Report' })]} />)
expect(screen.getByTestId('citation-measurement-item')).toHaveTextContent('My Report')
})
it('should render a popup for each resource that fits within the container', () => {
mockClientWidths({ container: 840, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1' }),
makeCitationItem({ document_id: 'doc-2' }),
]}
/>,
)
expect(screen.getAllByTestId('popup')).toHaveLength(2)
})
it('should render the citation title i18n key', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem()]} />)
expect(screen.getByText(/citation\.title/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should use chat-answer-container as the default containerClassName', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem()]} />)
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
})
it('should use a custom containerClassName to resolve the container element', () => {
mockClientWidths({ container: 600, item: 50 })
setupContainer('my-custom-container')
render(<Citation data={[makeCitationItem()]} containerClassName="my-custom-container" />)
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
})
it('should forward showHitInfo=true to each rendered Popup', () => {
mockClientWidths({ container: 840, item: 50 })
setupContainer()
render(
<Citation
data={[
makeCitationItem({ document_id: 'doc-1' }),
makeCitationItem({ document_id: 'doc-2' }),
]}
showHitInfo={true}
/>,
)
screen.getAllByTestId('popup').forEach(p =>
expect(p).toHaveAttribute('data-show-hit-info', 'true'),
)
})
it('should forward showHitInfo=false when prop is omitted', () => {
mockClientWidths({ container: 840, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem({ document_id: 'doc-1' })]} />)
screen.getAllByTestId('popup').forEach(p =>
expect(p).toHaveAttribute('data-show-hit-info', 'false'),
)
})
})
describe('Resource Grouping', () => {
it('should merge citations with the same document_id into one resource', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'shared', segment_id: 'seg-1' }),
makeCitationItem({ document_id: 'shared', segment_id: 'seg-2' }),
]}
/>,
)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
})
it('should create a separate resource for each distinct document_id', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-a' }),
makeCitationItem({ document_id: 'doc-b' }),
makeCitationItem({ document_id: 'doc-c' }),
]}
/>,
)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(3)
})
it('should handle mixed shared and unique document_ids correctly', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-1' }),
makeCitationItem({ document_id: 'doc-y', segment_id: 'seg-2' }),
makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-3' }),
]}
/>,
)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(2)
})
})
describe('Layout Adjustment all resources fit', () => {
it('should show all popups and no more-toggle when every item fits within container', () => {
// effective containerWidth = 840 - 40 = 800; each item = 50px → all 3 fit
mockClientWidths({ container: 840, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1' }),
makeCitationItem({ document_id: 'doc-2' }),
makeCitationItem({ document_id: 'doc-3' }),
]}
/>,
)
expect(screen.getAllByTestId('popup')).toHaveLength(3)
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
})
})
describe('Layout Adjustment overflow branch: setLimitNumberInOneLine(i - 1)', () => {
it('should show more-toggle when backed-out totalWidth + 34 still exceeds containerWidth', () => {
// effective = 140 - 40 = 100
// i=0: total=80, 80+0=80 ≤ 100 → limit=1
// i=1: total=160, 160+4=164 > 100 → overflow; back-out=80; 80+34=114 > 100 → setLimit(0)
// 0 < 2 → toggle shown
mockClientWidths({ container: 140, item: 80 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
]}
/>,
)
expect(screen.getByTestId('citation-more-toggle')).toBeInTheDocument()
})
})
describe('Layout Adjustment overflow branch: setLimitNumberInOneLine(i)', () => {
it('should show more-toggle and limit=i when backed-out totalWidth + 34 fits within containerWidth', () => {
// effective = 240 - 40 = 200
// i=0: 80+0=80 ≤ 200 → limit=1
// i=1: 160+4=164 ≤ 200 → limit=2
// i=2: 240+8=248 > 200 → overflow; back-out=160; 160+34=194 ≤ 200 → setLimit(2)
// 2 < 3 → toggle shown; 2 popups visible
mockClientWidths({ container: 240, item: 80 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }),
]}
/>,
)
expect(screen.getByTestId('citation-more-toggle')).toBeInTheDocument()
expect(screen.getAllByTestId('popup')).toHaveLength(2)
})
})
describe('Show More / Show Less Toggle', () => {
const renderOverflowScenario = () => {
// effective = 140 - 40 = 100; items=80px
// i=0: 80 ≤ 100 → limit=1
// i=1: 160+4=164 > 100 → overflow; back-out=80; 80+34=114 > 100 → setLimit(0)
// 0 < 3 → toggle shown; 0 popups visible (slice(0, 0) = [])
mockClientWidths({ container: 140, item: 80 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }),
]}
/>,
)
return screen.getByTestId('citation-more-toggle')
}
it('should show the overflow count label matching /+\\s*\\d+/ on the more-toggle in collapsed state', () => {
renderOverflowScenario()
expect(screen.getByTestId('citation-more-toggle').textContent).toMatch(/^\+\s*\d+$/)
})
it('should display the collapse icon div after clicking more-toggle to expand', async () => {
const user = userEvent.setup()
renderOverflowScenario()
await user.click(screen.getByTestId('citation-more-toggle'))
expect(document.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
})
it('should return to the count label after clicking the toggle a second time to collapse', async () => {
const user = userEvent.setup()
renderOverflowScenario()
await user.click(screen.getByTestId('citation-more-toggle'))
await user.click(screen.getByTestId('citation-more-toggle'))
expect(screen.getByTestId('citation-more-toggle').textContent).toMatch(/^\+\s*\d+$/)
})
it('should show all resource popups after expanding via the more-toggle', async () => {
const user = userEvent.setup()
renderOverflowScenario()
await user.click(screen.getByTestId('citation-more-toggle'))
await waitFor(() => {
expect(screen.getAllByTestId('popup')).toHaveLength(3)
})
})
})
describe('Edge Cases', () => {
it('should render without crashing when data is an empty array', () => {
mockClientWidths({ container: 500, item: 0 })
setupContainer()
render(<Citation data={[]} />)
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
expect(screen.queryAllByTestId('citation-measurement-item')).toHaveLength(0)
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
})
it('should render correctly with a single citation item that fits', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(<Citation data={[makeCitationItem()]} />)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
})
it('should handle all citations sharing one document_id as a single resource', () => {
mockClientWidths({ container: 500, item: 50 })
setupContainer()
render(
<Citation data={[
makeCitationItem({ document_id: 'only', segment_id: 's1' }),
makeCitationItem({ document_id: 'only', segment_id: 's2' }),
makeCitationItem({ document_id: 'only', segment_id: 's3' }),
]}
/>,
)
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
})
it('should handle a large number of citation items without throwing', () => {
mockClientWidths({ container: 5000, item: 50 })
setupContainer()
const data = Array.from({ length: 20 }, (_, i) =>
makeCitationItem({ document_id: `doc-${i}`, document_name: `Document ${i}` }))
expect(() => render(<Citation data={data} />)).not.toThrow()
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(20)
})
})
})

View File

@@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { CitationItem } from '../type'
import { RiArrowDownSLine } from '@remixicon/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Popup from './popup'
@@ -47,37 +46,36 @@ const Citation: FC<CitationProps> = ({
return prev
}, []), [data])
const handleAdjustResourcesLayout = () => {
useEffect(() => {
const containerWidth = document.querySelector(`.${containerClassName}`)!.clientWidth - 40
let totalWidth = 0
let limit = 0
for (let i = 0; i < resources.length; i++) {
totalWidth += elesRef.current[i].clientWidth
if (totalWidth + i * 4 > containerWidth!) {
if (totalWidth + i * 4 > containerWidth) {
totalWidth -= elesRef.current[i].clientWidth
if (totalWidth + 34 > containerWidth!)
setLimitNumberInOneLine(i - 1)
if (totalWidth + 34 > containerWidth)
limit = i - 1
else
setLimitNumberInOneLine(i)
limit = i
break
}
else {
setLimitNumberInOneLine(i + 1)
limit = i + 1
}
}
}
useEffect(() => {
handleAdjustResourcesLayout()
setLimitNumberInOneLine(limit)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const resourcesLength = resources.length
return (
<div className="-mb-1 mt-3">
<div className="system-xs-medium mb-2 flex items-center text-text-tertiary">
<div data-testid="citation-title" className="mb-2 flex items-center text-text-tertiary system-xs-medium">
{t('chat.citation.title', { ns: 'common' })}
<div className="ml-2 h-px grow bg-divider-regular" />
</div>
@@ -85,17 +83,18 @@ const Citation: FC<CitationProps> = ({
{
resources.map((res, index) => (
<div
key={index}
key={res.documentId}
data-testid="citation-measurement-item"
className="absolute left-0 top-0 -z-10 mb-1 mr-1 h-7 w-auto max-w-[240px] whitespace-nowrap pl-7 pr-2 text-xs opacity-0"
ref={(ele: any) => (elesRef.current[index] = ele!)}
ref={(ele: HTMLDivElement | null) => { elesRef.current[index] = ele! }}
>
{res.documentName}
</div>
))
}
{
resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map((res, index) => (
<div key={index} className="mb-1 mr-1 cursor-pointer">
resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map(res => (
<div key={res.documentId} className="mb-1 mr-1 cursor-pointer">
<Popup
data={res}
showHitInfo={showHitInfo}
@@ -106,13 +105,14 @@ const Citation: FC<CitationProps> = ({
{
limitNumberInOneLine < resourcesLength && (
<div
className="system-xs-medium flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 text-text-tertiary"
data-testid="citation-more-toggle"
className="flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 text-text-tertiary system-xs-medium"
onClick={() => setShowMore(v => !v)}
>
{
!showMore
? `+ ${resourcesLength - limitNumberInOneLine}`
: <RiArrowDownSLine className="h-4 w-4 rotate-180 text-text-tertiary" />
: <div className="i-ri-arrow-down-s-line h-4 w-4 rotate-180 text-text-tertiary" />
}
</div>
)

View File

@@ -0,0 +1,609 @@
import type { Resources } from './index'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
import Popup from './popup'
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentDownload: vi.fn(),
}))
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
vi.mock('@/app/components/base/file-icon', () => ({
default: ({ type }: { type: string }) => <div data-testid="file-icon" data-type={type} />,
}))
vi.mock('./progress-tooltip', () => ({
default: ({ data }: { data: number }) => <div data-testid="progress-tooltip">{data}</div>,
}))
vi.mock('./tooltip', () => ({
default: ({ text, data }: { text: string, data: number | string }) => (
<div data-testid="citation-tooltip" data-text={text}>{data}</div>
),
}))
const mockDownloadDocument = vi.fn()
const mockUseDocumentDownload = vi.mocked(useDocumentDownload)
const mockDownloadUrl = vi.mocked(downloadUrl)
const makeSource = (overrides: Partial<Resources['sources'][number]> = {}): Resources['sources'][number] => ({
dataset_id: 'ds-1',
dataset_name: 'Test Dataset',
document_id: 'doc-1',
segment_id: 'seg-1',
segment_position: 1,
content: 'Source content here',
word_count: 120,
hit_count: 3,
index_node_hash: 'abcdef1234567',
score: 0.85,
data_source_type: 'upload_file',
document_name: 'test.pdf',
...overrides,
} as Resources['sources'][number])
const makeData = (overrides: Partial<Resources> = {}): Resources => ({
documentId: 'doc-1',
documentName: 'report.pdf',
dataSourceType: 'upload_file',
sources: [makeSource()],
...overrides,
})
const openPopup = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByTestId('popup-trigger'))
}
describe('Popup', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseDocumentDownload.mockReturnValue({
mutateAsync: mockDownloadDocument,
isPending: false,
} as unknown as ReturnType<typeof useDocumentDownload>)
})
describe('Rendering Trigger', () => {
it('should render the trigger element', () => {
render(<Popup data={makeData()} />)
expect(screen.getByTestId('popup-trigger')).toBeInTheDocument()
})
it('should show the document name in the trigger', () => {
render(<Popup data={makeData({ documentName: 'My Report.pdf' })} />)
expect(screen.getByTestId('popup-trigger')).toHaveTextContent('My Report.pdf')
})
it('should pass the extracted file extension to FileIcon for non-notion sources', () => {
render(<Popup data={makeData({ documentName: 'report.pdf', dataSourceType: 'upload_file' })} />)
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', 'pdf')
})
it('should pass notion as fileType to FileIcon for notion sources', () => {
render(<Popup data={makeData({ documentName: 'Notion Page', dataSourceType: 'notion' })} />)
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', 'notion')
})
it('should pass empty string as fileType when document has no extension', () => {
render(<Popup data={makeData({ documentName: 'nodotfile', dataSourceType: 'upload_file' })} />)
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', '')
})
it('should not render popup content before trigger is clicked', () => {
render(<Popup data={makeData()} />)
expect(screen.queryByTestId('popup-content')).not.toBeInTheDocument()
})
})
describe('Popup Open / Close', () => {
it('should open the popup content on trigger click', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} />)
await openPopup(user)
expect(screen.getByTestId('popup-content')).toBeInTheDocument()
})
it('should close the popup on second trigger click', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} />)
await openPopup(user)
await openPopup(user)
expect(screen.queryByTestId('popup-content')).not.toBeInTheDocument()
})
it('should re-open popup after open → close → open cycle', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} />)
await openPopup(user)
await openPopup(user)
await openPopup(user)
expect(screen.getByTestId('popup-content')).toBeInTheDocument()
})
})
describe('Popup Header Download Button', () => {
it('should render download button in header for upload_file dataSourceType with dataset_id', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
await openPopup(user)
expect(screen.getByTestId('popup-download-btn')).toBeInTheDocument()
})
it('should render download button in header for file dataSourceType with dataset_id', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
dataSourceType: 'file',
sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
expect(screen.getByTestId('popup-download-btn')).toBeInTheDocument()
})
it('should render plain document name in header (no button) for notion type', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
documentName: 'Notion Doc',
dataSourceType: 'notion',
sources: [makeSource({ dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
})
it('should render plain document name in header when dataset_id is absent', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
dataSourceType: 'upload_file',
sources: [makeSource({ dataset_id: '' })],
})}
/>,
)
await openPopup(user)
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
})
it('should disable the download button while isDownloading is true', async () => {
mockUseDocumentDownload.mockReturnValue({
mutateAsync: mockDownloadDocument,
isPending: true,
} as unknown as ReturnType<typeof useDocumentDownload>)
const user = userEvent.setup()
render(<Popup data={makeData()} />)
await openPopup(user)
expect(screen.getByTestId('popup-download-btn')).toBeDisabled()
})
})
describe('Source Items', () => {
it('should render one source item per source entry', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource(), makeSource({ segment_id: 'seg-2' })] })} />)
await openPopup(user)
expect(screen.getAllByTestId('popup-source-item')).toHaveLength(2)
})
it('should render source content text', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ content: 'Unique content text' })] })} />)
await openPopup(user)
expect(screen.getByTestId('popup-source-content')).toHaveTextContent('Unique content text')
})
it('should show segment_position when it is truthy', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ segment_position: 7 })] })} />)
await openPopup(user)
expect(screen.getByTestId('popup-segment-position')).toHaveTextContent('7')
})
it('should fall back to index + 1 when segment_position is 0', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ segment_position: 0 })] })} />)
await openPopup(user)
expect(screen.getByTestId('popup-segment-position')).toHaveTextContent('1')
})
})
describe('Source Dividers', () => {
it('should render a divider between multiple sources', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
sources: [makeSource(), makeSource({ segment_id: 'seg-2' }), makeSource({ segment_id: 'seg-3' })],
})}
/>,
)
await openPopup(user)
expect(screen.getAllByTestId('popup-source-divider')).toHaveLength(2)
})
it('should not render any divider for a single source', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource()] })} />)
await openPopup(user)
expect(screen.queryByTestId('popup-source-divider')).not.toBeInTheDocument()
})
it('should render exactly n-1 dividers for n sources', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
sources: [
makeSource({ segment_id: 's1' }),
makeSource({ segment_id: 's2' }),
makeSource({ segment_id: 's3' }),
makeSource({ segment_id: 's4' }),
],
})}
/>,
)
await openPopup(user)
expect(screen.getAllByTestId('popup-source-divider')).toHaveLength(3)
})
})
describe('showHitInfo=false (default)', () => {
it('should not render the dataset link when showHitInfo is false', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} showHitInfo={false} />)
await openPopup(user)
expect(screen.queryByTestId('popup-dataset-link')).not.toBeInTheDocument()
})
it('should not render hit info section when showHitInfo is false', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} showHitInfo={false} />)
await openPopup(user)
expect(screen.queryByTestId('popup-hit-info')).not.toBeInTheDocument()
})
it('should not render Tooltip components when showHitInfo is false', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} showHitInfo={false} />)
await openPopup(user)
expect(screen.queryAllByTestId('citation-tooltip')).toHaveLength(0)
})
it('should not render ProgressTooltip when showHitInfo is false', async () => {
const user = userEvent.setup()
render(<Popup data={makeData()} showHitInfo={false} />)
await openPopup(user)
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
})
})
describe('showHitInfo=true', () => {
const dataWithScore = makeData({ sources: [makeSource({ score: 0.85 })] })
it('should render the dataset link when showHitInfo is true', async () => {
const user = userEvent.setup()
render(<Popup data={dataWithScore} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('popup-dataset-link')).toBeInTheDocument()
})
it('should render the dataset link with correct href', async () => {
const user = userEvent.setup()
render(<Popup data={dataWithScore} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('popup-dataset-link')).toHaveAttribute(
'href',
`/datasets/${dataWithScore.sources[0].dataset_id}/documents/${dataWithScore.sources[0].document_id}`,
)
})
it('should render the linkToDataset i18n key in the link', async () => {
const user = userEvent.setup()
render(<Popup data={dataWithScore} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('popup-dataset-link')).toHaveTextContent(/linkToDataset/i)
})
it('should render hit info section when showHitInfo is true', async () => {
const user = userEvent.setup()
render(<Popup data={dataWithScore} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('popup-hit-info')).toBeInTheDocument()
})
it('should render three Tooltip components (characters, hitCount, vectorHash)', async () => {
const user = userEvent.setup()
render(<Popup data={dataWithScore} showHitInfo={true} />)
await openPopup(user)
expect(screen.getAllByTestId('citation-tooltip')).toHaveLength(3)
})
it('should render ProgressTooltip when source score is greater than 0', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ score: 0.9 })] })} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('progress-tooltip')).toBeInTheDocument()
})
it('should not render ProgressTooltip when source score is 0', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ score: 0 })] })} showHitInfo={true} />)
await openPopup(user)
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
})
it('should pass score rounded to 2 decimal places to ProgressTooltip', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ score: 0.856 })] })} showHitInfo={true} />)
await openPopup(user)
expect(screen.getByTestId('progress-tooltip')).toHaveTextContent('0.86')
})
it('should pass word_count to the characters Tooltip', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ word_count: 250 })] })} showHitInfo={true} />)
await openPopup(user)
const tooltips = screen.getAllByTestId('citation-tooltip')
expect(tooltips[0]).toHaveTextContent('250')
})
it('should pass hit_count to the hitCount Tooltip', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ hit_count: 7 })] })} showHitInfo={true} />)
await openPopup(user)
const tooltips = screen.getAllByTestId('citation-tooltip')
expect(tooltips[1]).toHaveTextContent('7')
})
it('should pass truncated index_node_hash (first 7 chars) to vectorHash Tooltip', async () => {
const user = userEvent.setup()
render(<Popup data={makeData({ sources: [makeSource({ index_node_hash: 'abcdef1234567' })] })} showHitInfo={true} />)
await openPopup(user)
const tooltips = screen.getAllByTestId('citation-tooltip')
expect(tooltips[2]).toHaveTextContent('abcdef1')
})
it('should render hit info for each source when multiple sources are present', async () => {
const user = userEvent.setup()
render(
<Popup
data={makeData({
sources: [makeSource({ score: 0.9 }), makeSource({ segment_id: 'seg-2', score: 0.7 })],
})}
showHitInfo={true}
/>,
)
await openPopup(user)
expect(screen.getAllByTestId('popup-hit-info')).toHaveLength(2)
})
})
describe('handleDownloadUploadFile', () => {
it('should call downloadDocument and downloadUrl on successful download', async () => {
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
const user = userEvent.setup()
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
await waitFor(() => {
expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'doc-1' })
expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/file.pdf', fileName: 'report.pdf' })
})
})
it('should not call downloadUrl when res.url is absent', async () => {
mockDownloadDocument.mockResolvedValue({ url: '' })
const user = userEvent.setup()
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
await waitFor(() => expect(mockDownloadDocument).toHaveBeenCalled())
expect(mockDownloadUrl).not.toHaveBeenCalled()
})
it('should not call downloadDocument when dataSourceType is not upload_file or file', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
dataSourceType: 'notion',
sources: [makeSource({ dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
expect(mockDownloadDocument).not.toHaveBeenCalled()
})
it('should not call downloadDocument when isDownloading is true', async () => {
mockUseDocumentDownload.mockReturnValue({
mutateAsync: mockDownloadDocument,
isPending: true,
} as unknown as ReturnType<typeof useDocumentDownload>)
const user = userEvent.setup()
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
expect(mockDownloadDocument).not.toHaveBeenCalled()
})
it('should use documentId from data.documentId as priority over sources[0].document_id', async () => {
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
const user = userEvent.setup()
render(
<Popup data={makeData({
documentId: 'primary-doc-id',
dataSourceType: 'upload_file',
sources: [makeSource({ document_id: 'fallback-doc-id', dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
await waitFor(() => {
expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'primary-doc-id' })
})
})
it('should work with file dataSourceType the same as upload_file', async () => {
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
const user = userEvent.setup()
render(
<Popup data={makeData({
dataSourceType: 'file',
sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
await waitFor(() => {
expect(mockDownloadDocument).toHaveBeenCalled()
expect(mockDownloadUrl).toHaveBeenCalled()
})
})
it('should not call downloadDocument when both data.documentId and sources[0].document_id are empty', async () => {
const user = userEvent.setup()
render(
<Popup data={makeData({
documentId: '',
dataSourceType: 'upload_file',
sources: [makeSource({ document_id: '', dataset_id: 'ds-1' })],
})}
/>,
)
await openPopup(user)
await user.click(screen.getByTestId('popup-download-btn'))
expect(mockDownloadDocument).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should render without crashing with minimum required props', () => {
expect(() => render(<Popup data={makeData()} />)).not.toThrow()
})
it('should render without crashing with an empty sources array', () => {
expect(() => render(<Popup data={makeData({ sources: [] })} />)).not.toThrow()
})
it('should render correctly when source has no score (undefined)', async () => {
const user = userEvent.setup()
render(
<Popup
data={makeData({
sources: [makeSource({ score: undefined })],
})}
showHitInfo={true}
/>,
)
await openPopup(user)
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
})
it('should render correctly when index_node_hash is undefined', async () => {
const user = userEvent.setup()
render(
<Popup
data={makeData({
sources: [makeSource({ index_node_hash: undefined })],
})}
showHitInfo={true}
/>,
)
await openPopup(user)
const tooltips = screen.getAllByTestId('citation-tooltip')
expect(tooltips[2]).toBeInTheDocument()
})
})
})

View File

@@ -4,15 +4,6 @@ import Link from 'next/link'
import { Fragment, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FileIcon from '@/app/components/base/file-icon'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
import {
BezierCurve03,
TypeSquare,
} from '@/app/components/base/icons/src/vender/line/editor'
import {
Hash02,
Target04,
} from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@@ -40,23 +31,16 @@ const Popup: FC<PopupProps> = ({
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
/**
* Download the original uploaded file for citations whose data source is upload-file.
* We request a signed URL from the dataset document download endpoint, then trigger browser download.
*/
const handleDownloadUploadFile = async (e: MouseEvent<HTMLElement>) => {
// Prevent toggling the citation popup when user clicks the download link.
e.preventDefault()
e.stopPropagation()
// Only upload-file citations can be downloaded this way (needs dataset/document ids).
const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file'
const datasetId = data.sources?.[0]?.dataset_id
const documentId = data.documentId || data.sources?.[0]?.document_id
if (!isUploadFile || !datasetId || !documentId || isDownloading)
return
// Fetch signed URL (usually points to `/files/<id>/file-preview?...&as_attachment=true`).
const res = await downloadDocument({ datasetId, documentId })
if (res?.url)
downloadUrl({ url: res.url, fileName: data.documentName })
@@ -73,22 +57,21 @@ const Popup: FC<PopupProps> = ({
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
{/* Keep the trigger purely for opening the popup (no download link here). */}
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<div className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
<div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
<div className="px-4 pb-2 pt-3">
<div className="flex h-[18px] items-center">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
<div className="system-xs-medium truncate text-text-tertiary">
{/* If it's an upload-file reference, the title becomes a download link. */}
<div className="truncate text-text-tertiary system-xs-medium">
{(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id
? (
<button
data-testid="popup-download-btn"
type="button"
className="cursor-pointer truncate text-text-tertiary hover:underline"
onClick={handleDownloadUploadFile}
@@ -104,63 +87,71 @@ const Popup: FC<PopupProps> = ({
<div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5">
<div className="w-full">
{
data.sources.map((source, index) => (
<Fragment key={index}>
<div className="group py-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex h-5 items-center rounded-md border border-divider-subtle px-1.5">
<Hash02 className="mr-0.5 h-3 w-3 text-text-quaternary" />
<div className="text-[11px] font-medium text-text-tertiary">
{source.segment_position || index + 1}
data.sources.map((source, index) => {
const itemKey = source.document_id
? `${source.document_id}-${source.segment_position ?? index}`
: source.index_node_hash ?? `${data.documentId ?? 'doc'}-${index}`
return (
<Fragment key={itemKey}>
<div data-testid="popup-source-item" className="group py-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex h-5 items-center rounded-md border border-divider-subtle px-1.5">
{/* replaced svg component with tailwind icon class per lint rule */}
<i className="i-custom-vender-line-general-hash-02 mr-0.5 h-3 w-3 text-text-quaternary" aria-hidden />
<div data-testid="popup-segment-position" className="text-[11px] font-medium text-text-tertiary">
{source.segment_position || index + 1}
</div>
</div>
{
showHitInfo && (
<Link
data-testid="popup-dataset-link"
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
className="hidden h-[18px] items-center text-xs text-text-accent group-hover:flex"
>
{t('chat.citation.linkToDataset', { ns: 'common' })}
<i className="i-custom-vender-line-arrows-arrow-up-right ml-1 h-3 w-3" aria-hidden />
</Link>
)
}
</div>
<div data-testid="popup-source-content" className="break-words text-[13px] text-text-secondary">{source.content}</div>
{
showHitInfo && (
<Link
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
className="hidden h-[18px] items-center text-xs text-text-accent group-hover:flex"
>
{t('chat.citation.linkToDataset', { ns: 'common' })}
<ArrowUpRight className="ml-1 h-3 w-3" />
</Link>
<div data-testid="popup-hit-info" className="mt-2 flex flex-wrap items-center text-text-quaternary system-xs-medium">
<Tooltip
text={t('chat.citation.characters', { ns: 'common' })}
data={source.word_count}
icon={<i className="i-custom-vender-line-editor-type-square mr-1 h-3 w-3" aria-hidden />}
/>
<Tooltip
text={t('chat.citation.hitCount', { ns: 'common' })}
data={source.hit_count}
icon={<i className="i-custom-vender-line-general-target-04 mr-1 h-3 w-3" aria-hidden />}
/>
<Tooltip
text={t('chat.citation.vectorHash', { ns: 'common' })}
data={source.index_node_hash?.substring(0, 7)}
icon={<i className="i-custom-vender-line-editor-bezier-curve-03 mr-1 h-3 w-3" aria-hidden />}
/>
{
!!source.score && (
<ProgressTooltip data={Number(source.score.toFixed(2))} />
)
}
</div>
)
}
</div>
<div className="break-words text-[13px] text-text-secondary">{source.content}</div>
{
showHitInfo && (
<div className="system-xs-medium mt-2 flex flex-wrap items-center text-text-quaternary">
<Tooltip
text={t('chat.citation.characters', { ns: 'common' })}
data={source.word_count}
icon={<TypeSquare className="mr-1 h-3 w-3" />}
/>
<Tooltip
text={t('chat.citation.hitCount', { ns: 'common' })}
data={source.hit_count}
icon={<Target04 className="mr-1 h-3 w-3" />}
/>
<Tooltip
text={t('chat.citation.vectorHash', { ns: 'common' })}
data={source.index_node_hash?.substring(0, 7)}
icon={<BezierCurve03 className="mr-1 h-3 w-3" />}
/>
{
!!source.score && (
<ProgressTooltip data={Number(source.score.toFixed(2))} />
)
}
</div>
index !== data.sources.length - 1 && (
<div data-testid="popup-source-divider" className="my-1 h-px bg-divider-regular" />
)
}
</div>
{
index !== data.sources.length - 1 && (
<div className="my-1 h-px bg-divider-regular" />
)
}
</Fragment>
))
</Fragment>
)
})
}
</div>
</div>

View File

@@ -0,0 +1,144 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import ProgressTooltip from './progress-tooltip'
describe('ProgressTooltip', () => {
describe('Rendering', () => {
it('should render the trigger content', () => {
render(<ProgressTooltip data={0.75} />)
expect(screen.getByTestId('progress-trigger-content')).toBeInTheDocument()
})
it('should render the data value in the trigger', () => {
render(<ProgressTooltip data={0.75} />)
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.75')
})
it('should render the progress bar fill element', () => {
render(<ProgressTooltip data={0.5} />)
expect(screen.getByTestId('progress-bar-fill')).toBeInTheDocument()
})
it('should not render the tooltip popup before hovering', () => {
render(<ProgressTooltip data={0.5} />)
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
})
})
describe('Progress Bar Width', () => {
it('should set fill width to data * 100 percent', () => {
render(<ProgressTooltip data={0.75} />)
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '75%' })
})
it('should set fill width to 0% when data is 0', () => {
render(<ProgressTooltip data={0} />)
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '0%' })
})
it('should set fill width to 100% when data is 1', () => {
render(<ProgressTooltip data={1} />)
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '100%' })
})
it('should set fill width to 50% when data is 0.5', () => {
render(<ProgressTooltip data={0.5} />)
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '50%' })
})
})
describe('Tooltip Visibility', () => {
it('should show the tooltip popup on mouse enter', async () => {
const user = userEvent.setup()
render(<ProgressTooltip data={0.8} />)
await user.hover(screen.getByTestId('progress-trigger-content'))
expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument()
})
it('should hide the tooltip popup on mouse leave', async () => {
const user = userEvent.setup()
render(<ProgressTooltip data={0.8} />)
await user.hover(screen.getByTestId('progress-trigger-content'))
await user.unhover(screen.getByTestId('progress-trigger-content'))
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
})
it('should show the hitScore i18n key in the tooltip', async () => {
const user = userEvent.setup()
render(<ProgressTooltip data={0.8} />)
await user.hover(screen.getByTestId('progress-trigger-content'))
expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent(/hitScore/i)
})
it('should show the data value inside the tooltip popup', async () => {
const user = userEvent.setup()
render(<ProgressTooltip data={0.8} />)
await user.hover(screen.getByTestId('progress-trigger-content'))
expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent('0.8')
})
})
describe('Props', () => {
it('should render correctly with a small fractional value', () => {
render(<ProgressTooltip data={0.12} />)
expect(screen.getByTestId('progress-bar-fill').getAttribute('style')).toMatch(/width:\s*12/)
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.12')
})
it('should render correctly with a value close to 1', () => {
render(<ProgressTooltip data={0.99} />)
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '99%' })
})
it('should update displayed data when prop changes', () => {
const { rerender } = render(<ProgressTooltip data={0.3} />)
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.3')
rerender(<ProgressTooltip data={0.9} />)
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.9')
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '90%' })
})
})
describe('Edge Cases', () => {
it('should render without crashing when data is exactly 0', () => {
expect(() => render(<ProgressTooltip data={0} />)).not.toThrow()
})
it('should render without crashing when data is exactly 1', () => {
expect(() => render(<ProgressTooltip data={1} />)).not.toThrow()
})
it('should re-show tooltip after hover → unhover → hover cycle', async () => {
const user = userEvent.setup()
render(<ProgressTooltip data={0.5} />)
await user.hover(screen.getByTestId('progress-trigger-content'))
await user.unhover(screen.getByTestId('progress-trigger-content'))
await user.hover(screen.getByTestId('progress-trigger-content'))
expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument()
})
it('should keep tooltip closed without any interaction', () => {
render(<ProgressTooltip data={0.42} />)
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
})
it('should not call any external handlers by default', () => {
const consoleError = vi.spyOn(console, 'error')
render(<ProgressTooltip data={0.5} />)
expect(consoleError).not.toHaveBeenCalled()
consoleError.mockRestore()
})
})
})

View File

@@ -27,15 +27,20 @@ const ProgressTooltip: FC<ProgressTooltipProps> = ({
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div className="flex grow items-center">
<div data-testid="progress-trigger-content" className="flex grow items-center">
<div className="mr-1 h-1.5 w-16 overflow-hidden rounded-[3px] border border-components-progress-gray-border">
<div className="h-full bg-components-progress-gray-progress" style={{ width: `${data * 100}%` }}></div>
<div
data-testid="progress-bar-fill"
className="h-full bg-components-progress-gray-progress"
style={{ width: `${data * 100}%` }}
>
</div>
</div>
{data}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div className="system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg">
<div data-testid="progress-tooltip-popup" className="rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg system-xs-medium">
{t('chat.citation.hitScore', { ns: 'common' })}
{' '}
{data}

View File

@@ -0,0 +1,155 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import Tooltip from './tooltip'
const renderTooltip = (data: number | string = 42, text = 'Characters', icon = <span data-testid="mock-icon">icon</span>) =>
render(<Tooltip data={data} text={text} icon={icon} />)
describe('Tooltip', () => {
describe('Rendering', () => {
it('should render the trigger content wrapper', () => {
renderTooltip()
expect(screen.getByTestId('tooltip-trigger-content')).toBeInTheDocument()
})
it('should render the icon inside the trigger', () => {
renderTooltip(42, 'Characters', <span data-testid="mock-icon">icon</span>)
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
})
it('should render a numeric data value in the trigger', () => {
renderTooltip(123)
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('123')
})
it('should render a string data value in the trigger', () => {
renderTooltip('abc123')
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('abc123')
})
it('should not render the tooltip popup before hovering', () => {
renderTooltip()
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should render the provided text label when tooltip is open', async () => {
const user = userEvent.setup()
renderTooltip(10, 'Word Count')
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Word Count')
})
it('should render the data value inside the tooltip popup', async () => {
const user = userEvent.setup()
renderTooltip(99, 'Hit Count')
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('99')
})
it('should render a string data value inside the tooltip popup', async () => {
const user = userEvent.setup()
renderTooltip('abc1234', 'Vector Hash')
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('abc1234')
})
it('should render both text and data together inside the tooltip popup', async () => {
const user = userEvent.setup()
renderTooltip(55, 'Characters')
await user.hover(screen.getByTestId('tooltip-trigger-content'))
const popup = screen.getByTestId('tooltip-popup')
expect(popup).toHaveTextContent('Characters')
expect(popup).toHaveTextContent('55')
})
it('should render any arbitrary ReactNode as icon', () => {
render(<Tooltip data={1} text="text" icon={<div data-testid="custom-icon"></div>} />)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('should update displayed data when prop changes', () => {
const { rerender } = render(<Tooltip data={10} text="Words" icon={<span />} />)
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('10')
rerender(<Tooltip data={20} text="Words" icon={<span />} />)
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('20')
})
it('should update displayed text in popup when prop changes and tooltip is open', async () => {
const user = userEvent.setup()
const { rerender } = render(<Tooltip data={10} text="Original" icon={<span />} />)
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Original')
rerender(<Tooltip data={10} text="Updated" icon={<span />} />)
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Updated')
})
})
describe('Tooltip Visibility', () => {
it('should show the tooltip popup on mouse enter', async () => {
const user = userEvent.setup()
renderTooltip()
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument()
})
it('should hide the tooltip popup on mouse leave', async () => {
const user = userEvent.setup()
renderTooltip()
await user.hover(screen.getByTestId('tooltip-trigger-content'))
await user.unhover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
})
it('should re-show tooltip after hover → unhover → hover cycle', async () => {
const user = userEvent.setup()
renderTooltip()
await user.hover(screen.getByTestId('tooltip-trigger-content'))
await user.unhover(screen.getByTestId('tooltip-trigger-content'))
await user.hover(screen.getByTestId('tooltip-trigger-content'))
expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render without crashing when data is 0', () => {
expect(() => render(<Tooltip data={0} text="score" icon={<span />} />)).not.toThrow()
})
it('should render without crashing when data is an empty string', () => {
expect(() => render(<Tooltip data="" text="label" icon={<span />} />)).not.toThrow()
})
it('should render without crashing when text is an empty string', () => {
expect(() => render(<Tooltip data={1} text="" icon={<span />} />)).not.toThrow()
})
it('should keep tooltip closed without any interaction', () => {
renderTooltip(0.5)
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
})
it('should render data value 0 in the trigger', () => {
render(<Tooltip data={0} text="score" icon={<span />} />)
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('0')
})
})
})

View File

@@ -30,13 +30,13 @@ const Tooltip: FC<TooltipProps> = ({
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div className="mr-6 flex items-center">
<div data-testid="tooltip-trigger-content" className="mr-6 flex items-center">
{icon}
{data}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
<div className="system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg">
<div data-testid="tooltip-popup" className="rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg system-xs-medium">
{text}
{' '}
{data}

View File

@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import ContentSwitch from './content-switch'
describe('ContentSwitch', () => {
const defaultProps = {
count: 3,
currentIndex: 1,
prevDisabled: false,
nextDisabled: false,
switchSibling: vi.fn(),
}
it('renders nothing when count is 1 or less', () => {
const { container } = render(<ContentSwitch {...defaultProps} count={1} />)
expect(container.firstChild).toBeNull()
})
it('renders nothing when currentIndex is undefined', () => {
const { container } = render(<ContentSwitch {...defaultProps} currentIndex={undefined} />)
expect(container.firstChild).toBeNull()
})
it('renders correctly with current page and total count', () => {
render(<ContentSwitch {...defaultProps} currentIndex={0} count={5} />)
expect(screen.getByText(/1[^\n\r/\u2028\u2029]*\/.*5/)).toBeInTheDocument()
})
it('calls switchSibling with "prev" when left button is clicked', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
render(<ContentSwitch {...defaultProps} switchSibling={switchSibling} />)
const prevButton = screen.getByRole('button', { name: /previous/i })
await user.click(prevButton)
expect(switchSibling).toHaveBeenCalledWith('prev')
})
it('calls switchSibling with "next" when right button is clicked', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
render(<ContentSwitch {...defaultProps} switchSibling={switchSibling} />)
const nextButton = screen.getByRole('button', { name: /next/i })
await user.click(nextButton)
expect(switchSibling).toHaveBeenCalledWith('next')
})
it('applies disabled styles and prevents clicks when prevDisabled is true', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
render(<ContentSwitch {...defaultProps} prevDisabled={true} switchSibling={switchSibling} />)
const prevButton = screen.getByRole('button', { name: /previous/i })
expect(prevButton).toHaveClass('opacity-30')
expect(prevButton).toBeDisabled()
await user.click(prevButton)
expect(switchSibling).not.toHaveBeenCalled()
})
it('applies disabled styles and prevents clicks when nextDisabled is true', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
render(<ContentSwitch {...defaultProps} nextDisabled={true} switchSibling={switchSibling} />)
const nextButton = screen.getByRole('button', { name: /next/i })
expect(nextButton).toHaveClass('opacity-30')
expect(nextButton).toBeDisabled()
await user.click(nextButton)
expect(switchSibling).not.toHaveBeenCalled()
})
})

View File

@@ -18,6 +18,7 @@ export default function ContentSwitch({
<div className="flex items-center justify-center pt-3.5 text-sm">
<button
type="button"
aria-label="Previous" // Added for accessibility and testing
className={`${prevDisabled ? 'opacity-30' : 'opacity-100'}`}
disabled={prevDisabled}
onClick={() => !prevDisabled && switchSibling('prev')}
@@ -32,6 +33,7 @@ export default function ContentSwitch({
</span>
<button
type="button"
aria-label="Next" // Added for accessibility and testing
className={`${nextDisabled ? 'opacity-30' : 'opacity-100'}`}
disabled={nextDisabled}
onClick={() => !nextDisabled && switchSibling('next')}

View File

@@ -0,0 +1,94 @@
import type { ChatItem } from '../types'
import type { ChatContextValue } from './context'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ChatContextProvider, useChatContext } from './context'
const TestConsumer = () => {
const context = useChatContext()
return (
<div>
<div data-testid="isResponding">{String(context.isResponding)}</div>
<div data-testid="readonly">{String(context.readonly)}</div>
<div data-testid="chatListCount">{context.chatList.length}</div>
<div data-testid="questionIcon">{context.questionIcon}</div>
<button onClick={() => context.onSend?.('test message', [])}>Send Button</button>
<button onClick={() => context.onRegenerate?.({ id: '1' } as ChatItem, { message: 'retry' })}>Regenerate Button</button>
</div>
)
}
describe('ChatContextProvider', () => {
const mockOnSend = vi.fn()
const mockOnRegenerate = vi.fn()
const defaultProps: ChatContextValue = {
config: {} as ChatContextValue['config'],
isResponding: false,
chatList: [{ id: '1', content: 'hello' } as ChatItem],
showPromptLog: false,
questionIcon: <span data-testid="custom-icon">Icon</span>,
answerIcon: null,
onSend: mockOnSend,
onRegenerate: mockOnRegenerate,
onAnnotationEdited: vi.fn(),
onAnnotationAdded: vi.fn(),
onAnnotationRemoved: vi.fn(),
disableFeedback: false,
onFeedback: vi.fn(),
getHumanInputNodeData: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should provide context values to children', () => {
render(
<ChatContextProvider {...defaultProps} readonly={true}>
<TestConsumer />
</ChatContextProvider>,
)
expect(screen.getByTestId('isResponding')).toHaveTextContent('false')
expect(screen.getByTestId('readonly')).toHaveTextContent('true')
expect(screen.getByTestId('chatListCount')).toHaveTextContent('1')
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('should use default values for optional props', () => {
const minimalProps = { ...defaultProps, chatList: undefined as unknown as ChatItem[] }
render(
<ChatContextProvider {...minimalProps}>
<TestConsumer />
</ChatContextProvider>,
)
expect(screen.getByTestId('chatListCount')).toHaveTextContent('0')
expect(screen.getByTestId('readonly')).toHaveTextContent('false')
})
it('should handle callbacks correctly via the hook', async () => {
const user = userEvent.setup()
render(
<ChatContextProvider {...defaultProps}>
<TestConsumer />
</ChatContextProvider>,
)
const sendBtn = screen.getByRole('button', { name: /send button/i })
const regenBtn = screen.getByRole('button', { name: /regenerate button/i })
await user.click(sendBtn)
expect(mockOnSend).toHaveBeenCalledWith('test message', [])
await user.click(regenBtn)
expect(mockOnRegenerate).toHaveBeenCalledWith(
expect.objectContaining({ id: '1' }),
expect.objectContaining({ message: 'retry' }),
)
})
})

View File

@@ -0,0 +1,606 @@
import type { ChatConfig, ChatItem, OnSend } from '../types'
import type { ChatProps } from './index'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useStore as useAppStore } from '@/app/components/app/store'
import Chat from './index'
// ─── Why each mock exists ─────────────────────────────────────────────────────
//
// Answer transitively pulls Markdown (rehype/remark/katex), AgentContent,
// WorkflowProcessItem and Operation; none can resolve in jsdom.
// Question pulls Markdown, copy-to-clipboard, react-textarea-autosize.
// ChatInputArea pulls js-audio-recorder (requires Web Audio API unavailable in
// jsdom) and VoiceInput / FileContextProvider chains.
// PromptLogModal pulls CopyFeedbackNew and deep modal dep chain.
// AgentLogModal pulls @remixicon/react (causes lint push error), useClickAway
// from ahooks, and AgentLogDetail (workflow graph renderer).
// es-toolkit/compat debounce must return a fn with .cancel() or the cleanup
// effect throws on unmount.
//
// NOT mocked (run real):
// ChatContextProvider plain context wrapper, zero side-effects.
// TryToAsk only uses Button (base), Divider (base), i18n (global mock).
// ─────────────────────────────────────────────────────────────────────────────
vi.mock('./answer', () => ({
default: ({ item, responding }: { item: ChatItem, responding?: boolean }) => (
<div
data-testid="answer-item"
data-id={item.id}
data-responding={String(!!responding)}
>
{item.content}
</div>
),
}))
vi.mock('./question', () => ({
default: ({ item }: { item: ChatItem }) => (
<div data-testid="question-item" data-id={item.id}>{item.content}</div>
),
}))
vi.mock('./chat-input-area', () => ({
default: ({ disabled, readonly }: { disabled?: boolean, readonly?: boolean }) => (
<div
data-testid="chat-input-area"
data-disabled={String(!!disabled)}
data-readonly={String(!!readonly)}
/>
),
}))
vi.mock('@/app/components/base/prompt-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="prompt-log-modal">
<button data-testid="prompt-log-cancel" onClick={onCancel}>cancel</button>
</div>
),
}))
vi.mock('@/app/components/base/agent-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="agent-log-modal">
<button data-testid="agent-log-cancel" onClick={onCancel}>cancel</button>
</div>
),
}))
vi.mock('es-toolkit/compat', () => ({
debounce: (fn: (...args: unknown[]) => void) => {
const debounced = (...args: unknown[]) => fn(...args)
debounced.cancel = vi.fn()
return debounced
},
}))
// ─── ResizeObserver capture ───────────────────────────────────────────────────
type ResizeCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void
let capturedResizeCallbacks: ResizeCallback[] = []
const makeResizeEntry = (blockSize: number, inlineSize: number): ResizeObserverEntry => ({
borderBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
contentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
contentRect: new DOMRect(0, 0, inlineSize, blockSize),
devicePixelContentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
target: document.createElement('div'),
})
// ─── Factories ────────────────────────────────────────────────────────────────
const makeChatItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({
id: `item-${Math.random().toString(36).slice(2)}`,
content: 'Test content',
isAnswer: false,
...overrides,
})
const mockSetCurrentLogItem = vi.fn()
const mockSetShowPromptLogModal = vi.fn()
const mockSetShowAgentLogModal = vi.fn()
const baseStoreState = {
currentLogItem: undefined,
setCurrentLogItem: mockSetCurrentLogItem,
showPromptLogModal: false,
setShowPromptLogModal: mockSetShowPromptLogModal,
showAgentLogModal: false,
setShowAgentLogModal: mockSetShowAgentLogModal,
}
const renderChat = (props: Partial<ChatProps> = {}) =>
render(<Chat chatList={[]} {...props} />)
// ─── Suite ────────────────────────────────────────────────────────────────────
describe('Chat', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedResizeCallbacks = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 0
})
vi.stubGlobal('ResizeObserver', class {
private cb: ResizeCallback
constructor(cb: ResizeCallback) {
this.cb = cb
capturedResizeCallbacks.push(cb)
}
observe() { }
unobserve() { }
disconnect() { }
})
useAppStore.setState(baseStoreState)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Rendering', () => {
it('should render without crashing with an empty chatList', () => {
renderChat()
expect(screen.getByTestId('chat-root')).toBeInTheDocument()
})
it('should render chatNode when provided', () => {
renderChat({ chatNode: <div data-testid="slot-node">slot</div> })
expect(screen.getByTestId('slot-node')).toBeInTheDocument()
})
it('should apply flex-col to root when isTryApp=true', () => {
renderChat({ isTryApp: true })
expect(screen.getByTestId('chat-root')).toHaveClass('flex', 'flex-col')
})
it('should not have flex-col when isTryApp is falsy', () => {
renderChat({ isTryApp: false })
expect(screen.getByTestId('chat-root')).not.toHaveClass('flex-col')
})
it('should apply chatContainerClassName to the scroll container', () => {
renderChat({ chatContainerClassName: 'my-custom-class' })
expect(screen.getByTestId('chat-container')).toHaveClass('my-custom-class')
})
it('should apply px-8 spacing by default', () => {
const { container } = renderChat({ noSpacing: false })
expect(container.querySelector('.w-full')).toHaveClass('px-8')
})
it('should omit px-8 when noSpacing=true', () => {
const { container } = renderChat({ noSpacing: true })
expect(container.querySelector('.w-full')).not.toHaveClass('px-8')
})
})
describe('Chat List', () => {
it('should render a Question for a non-answer item', () => {
renderChat({ chatList: [makeChatItem({ id: 'q1', isAnswer: false })] })
expect(screen.getByTestId('question-item')).toBeInTheDocument()
})
it('should render an Answer for an answer item', () => {
renderChat({ chatList: [makeChatItem({ id: 'a1', isAnswer: true })] })
expect(screen.getByTestId('answer-item')).toBeInTheDocument()
})
it('should render both Question and Answer from a mixed chatList', () => {
renderChat({
chatList: [
makeChatItem({ id: 'q1', isAnswer: false }),
makeChatItem({ id: 'a1', isAnswer: true }),
],
})
expect(screen.getByTestId('question-item')).toBeInTheDocument()
expect(screen.getByTestId('answer-item')).toBeInTheDocument()
})
it('should pass responding=true only to the last answer when isResponding=true', () => {
renderChat({
isResponding: true,
chatList: [
makeChatItem({ id: 'q1', isAnswer: false }),
makeChatItem({ id: 'a1', isAnswer: true }),
makeChatItem({ id: 'q2', isAnswer: false }),
makeChatItem({ id: 'a2', isAnswer: true }),
],
})
const answers = screen.getAllByTestId('answer-item')
expect(answers[0]).toHaveAttribute('data-responding', 'false')
expect(answers[1]).toHaveAttribute('data-responding', 'true')
})
it('should pass responding=false to all answers when isResponding=false', () => {
renderChat({
isResponding: false,
chatList: [
makeChatItem({ id: 'a1', isAnswer: true }),
makeChatItem({ id: 'a2', isAnswer: true }),
],
})
screen.getAllByTestId('answer-item').forEach(el =>
expect(el).toHaveAttribute('data-responding', 'false'),
)
})
it('should render correct counts for a long mixed chatList', () => {
const chatList = Array.from({ length: 6 }, (_, i) =>
makeChatItem({ id: `item-${i}`, isAnswer: i % 2 === 1 }))
renderChat({ chatList })
expect(screen.getAllByTestId('question-item')).toHaveLength(3)
expect(screen.getAllByTestId('answer-item')).toHaveLength(3)
})
})
describe('Stop Responding Button', () => {
it('should show the stop button when isResponding=true and noStopResponding is falsy', () => {
renderChat({ isResponding: true, noStopResponding: false })
expect(screen.getByTestId('stop-responding-container')).toBeInTheDocument()
})
it('should hide the stop button when noStopResponding=true', () => {
renderChat({ isResponding: true, noStopResponding: true })
expect(screen.queryByTestId('stop-responding-container')).not.toBeInTheDocument()
})
it('should hide the stop button when isResponding=false', () => {
renderChat({ isResponding: false, noStopResponding: false })
expect(screen.queryByTestId('stop-responding-container')).not.toBeInTheDocument()
})
it('should call onStopResponding when the stop button is clicked', async () => {
const user = userEvent.setup()
const onStopResponding = vi.fn()
renderChat({ isResponding: true, noStopResponding: false, onStopResponding })
await user.click(screen.getByText(/stopResponding/i))
expect(onStopResponding).toHaveBeenCalledTimes(1)
})
it('should render the stopResponding i18n key', () => {
renderChat({ isResponding: true, noStopResponding: false })
expect(screen.getByText(/stopResponding/i)).toBeInTheDocument()
})
})
describe('TryToAsk (real component)', () => {
const tryToAskConfig: ChatConfig = {
suggested_questions_after_answer: { enabled: true },
} as ChatConfig
const mockOnSend = vi.fn() as unknown as OnSend
it('should render the tryToAsk i18n key when all conditions are met', () => {
renderChat({
config: tryToAskConfig,
suggestedQuestions: ['What is AI?'],
onSend: mockOnSend,
})
expect(screen.getByText(/tryToAsk/i)).toBeInTheDocument()
})
it('should render each suggested question as a button', () => {
renderChat({
config: tryToAskConfig,
suggestedQuestions: ['First question', 'Second question'],
onSend: mockOnSend,
})
expect(screen.getByText('First question')).toBeInTheDocument()
expect(screen.getByText('Second question')).toBeInTheDocument()
})
it('should call onSend with the question text when a suggestion button is clicked', async () => {
const user = userEvent.setup()
const onSend = vi.fn() as unknown as OnSend
renderChat({
config: tryToAskConfig,
suggestedQuestions: ['Ask this'],
onSend,
})
await user.click(screen.getByText('Ask this'))
expect(onSend).toHaveBeenCalledWith('Ask this')
})
it('should not render TryToAsk when suggested_questions_after_answer is disabled', () => {
renderChat({
config: { suggested_questions_after_answer: { enabled: false } } as ChatConfig,
suggestedQuestions: ['q1'],
onSend: mockOnSend,
})
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
})
it('should not render TryToAsk when suggestedQuestions is an empty array', () => {
renderChat({
config: tryToAskConfig,
suggestedQuestions: [],
onSend: mockOnSend,
})
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
})
it('should not render TryToAsk when suggestedQuestions is undefined', () => {
renderChat({
config: tryToAskConfig,
suggestedQuestions: undefined,
onSend: mockOnSend,
})
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
})
it('should not render TryToAsk when onSend is undefined', () => {
renderChat({
config: tryToAskConfig,
suggestedQuestions: ['q1'],
onSend: undefined,
})
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
})
it('should not render TryToAsk when config is undefined', () => {
renderChat({
config: undefined,
suggestedQuestions: ['q1'],
onSend: mockOnSend,
})
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
})
})
describe('ChatInputArea', () => {
it('should render when noChatInput is falsy', () => {
renderChat({ noChatInput: false })
expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
})
it('should not render when noChatInput=true', () => {
renderChat({ noChatInput: true })
expect(screen.queryByTestId('chat-input-area')).not.toBeInTheDocument()
})
it('should pass disabled=true when inputDisabled=true', () => {
renderChat({ inputDisabled: true })
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-disabled', 'true')
})
it('should pass disabled=false when inputDisabled is falsy', () => {
renderChat({ inputDisabled: false })
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-disabled', 'false')
})
it('should pass readonly=true to ChatInputArea when readonly=true', () => {
renderChat({ readonly: true })
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-readonly', 'true')
})
})
describe('PromptLogModal', () => {
it('should render when showPromptLogModal=true and hideLogModal is falsy', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
renderChat({ hideLogModal: false })
expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument()
})
it('should not render when showPromptLogModal=false', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: false })
renderChat()
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
})
it('should not render when hideLogModal=true even if showPromptLogModal=true', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
renderChat({ hideLogModal: true })
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
})
it('should call setCurrentLogItem and setShowPromptLogModal(false) on cancel', async () => {
const user = userEvent.setup()
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
renderChat({ hideLogModal: false })
await user.click(screen.getByTestId('prompt-log-cancel'))
expect(mockSetCurrentLogItem).toHaveBeenCalledTimes(1)
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false)
})
})
describe('AgentLogModal', () => {
it('should render when showAgentLogModal=true and hideLogModal is falsy', () => {
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
renderChat({ hideLogModal: false })
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
})
it('should not render when showAgentLogModal=false', () => {
useAppStore.setState({ ...baseStoreState, showAgentLogModal: false })
renderChat()
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
})
it('should not render when hideLogModal=true even if showAgentLogModal=true', () => {
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
renderChat({ hideLogModal: true })
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
})
it('should call setCurrentLogItem and setShowAgentLogModal(false) on cancel', async () => {
const user = userEvent.setup()
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
renderChat({ hideLogModal: false })
await user.click(screen.getByTestId('agent-log-cancel'))
expect(mockSetCurrentLogItem).toHaveBeenCalledTimes(1)
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
})
})
describe('Window Resize', () => {
it('should register a resize listener on mount', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
renderChat()
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function))
})
it('should remove the resize listener on unmount', () => {
const removeSpy = vi.spyOn(window, 'removeEventListener')
const { unmount } = renderChat()
unmount()
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
})
it('should not throw when the resize event fires', () => {
renderChat()
expect(() => window.dispatchEvent(new Event('resize'))).not.toThrow()
})
})
describe('ResizeObserver Callbacks', () => {
it('should set paddingBottom on chatContainer from the footer blockSize', () => {
renderChat({
chatList: [
makeChatItem({ id: 'q1', isAnswer: false }),
makeChatItem({ id: 'a1', isAnswer: true }),
],
})
const containerCb = capturedResizeCallbacks[0]
if (containerCb) {
act(() => containerCb([makeResizeEntry(80, 400)], {} as ResizeObserver))
expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px')
}
})
it('should set footer width from the container inlineSize', () => {
renderChat()
const footerCb = capturedResizeCallbacks[1]
if (footerCb) {
act(() => footerCb([makeResizeEntry(50, 600)], {} as ResizeObserver))
expect(screen.getByTestId('chat-footer').style.width).toBe('600px')
}
})
it('should disconnect both observers on unmount', () => {
const disconnectSpy = vi.fn()
vi.stubGlobal('ResizeObserver', class {
observe() { }
unobserve() { }
disconnect = disconnectSpy
})
const { unmount } = renderChat()
unmount()
expect(disconnectSpy).toHaveBeenCalled()
})
})
describe('Scroll Behavior', () => {
it('should not throw when chatList has 1 item (scroll guard: length > 1 not met)', () => {
expect(() => renderChat({ chatList: [makeChatItem({ id: 'q1' })] })).not.toThrow()
})
it('should not throw when a scroll event fires on the container', () => {
renderChat()
expect(() =>
screen.getByTestId('chat-container').dispatchEvent(new Event('scroll')),
).not.toThrow()
})
it('should set userScrolled when distanceToBottom exceeds threshold', () => {
renderChat()
const container = screen.getByTestId('chat-container')
Object.defineProperty(container, 'scrollHeight', { value: 1000, configurable: true })
Object.defineProperty(container, 'clientHeight', { value: 400, configurable: true })
Object.defineProperty(container, 'scrollTop', { value: 0, configurable: true })
expect(() => container.dispatchEvent(new Event('scroll'))).not.toThrow()
})
it('should not set userScrolled when distanceToBottom is within threshold', () => {
renderChat()
const container = screen.getByTestId('chat-container')
Object.defineProperty(container, 'scrollHeight', { value: 500, configurable: true })
Object.defineProperty(container, 'clientHeight', { value: 400, configurable: true })
Object.defineProperty(container, 'scrollTop', { value: 99, configurable: true })
expect(() => container.dispatchEvent(new Event('scroll'))).not.toThrow()
})
})
describe('ChatList Scroll Reset', () => {
it('should not throw with empty chatList (length <= 1 branch)', () => {
expect(() => renderChat({ chatList: [] })).not.toThrow()
})
it('should not throw with exactly one item (length <= 1 branch)', () => {
expect(() => renderChat({ chatList: [makeChatItem({ id: 'msg-1' })] })).not.toThrow()
})
it('should reset scroll state when the first message ID changes on rerender', () => {
const { rerender } = renderChat({
chatList: [makeChatItem({ id: 'first' }), makeChatItem({ id: 'second' })],
})
expect(() =>
rerender(<Chat chatList={[makeChatItem({ id: 'new-first' }), makeChatItem({ id: 'new-second' })]} />),
).not.toThrow()
})
it('should not reset scroll when the first message ID is unchanged', () => {
const item1 = makeChatItem({ id: 'stable-id' })
const { rerender } = renderChat({ chatList: [item1, makeChatItem({ id: 'second' })] })
expect(() =>
rerender(<Chat chatList={[item1, makeChatItem({ id: 'third' })]} />),
).not.toThrow()
})
})
describe('Sidebar Collapse State', () => {
it('should schedule a resize via setTimeout when sidebarCollapseState becomes false', () => {
vi.useFakeTimers()
const { rerender } = renderChat({ sidebarCollapseState: true })
rerender(<Chat chatList={[]} sidebarCollapseState={false} />)
expect(() => vi.runAllTimers()).not.toThrow()
vi.useRealTimers()
})
it('should not schedule a resize when sidebarCollapseState stays true', () => {
vi.useFakeTimers()
renderChat({ sidebarCollapseState: true })
expect(() => vi.runAllTimers()).not.toThrow()
vi.useRealTimers()
})
})
describe('Edge Cases', () => {
it('should render without crashing with no optional props', () => {
expect(() => render(<Chat chatList={[]} />)).not.toThrow()
})
it('should handle readonly=true without crashing', () => {
expect(() => renderChat({ readonly: true })).not.toThrow()
})
it('should render no modals when both modal flags are false', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: false, showAgentLogModal: false })
renderChat()
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
})
it('should render both modals when both flags are true and hideLogModal is false', () => {
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true, showAgentLogModal: true })
renderChat({ hideLogModal: false })
expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument()
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
})
})
})

View File

@@ -26,7 +26,6 @@ import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import AgentLogModal from '@/app/components/base/agent-log-modal'
import Button from '@/app/components/base/button'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import PromptLogModal from '@/app/components/base/prompt-log-modal'
import { cn } from '@/utils/classnames'
import Answer from './answer'
@@ -188,7 +187,6 @@ const Chat: FC<ChatProps> = ({
useEffect(() => {
if (chatFooterRef.current && chatContainerRef.current) {
// container padding bottom
const resizeContainerObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { blockSize } = entry.borderBoxSize[0]
@@ -198,7 +196,6 @@ const Chat: FC<ChatProps> = ({
})
resizeContainerObserver.observe(chatFooterRef.current)
// footer width
const resizeFooterObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { inlineSize } = entry.borderBoxSize[0]
@@ -237,20 +234,19 @@ const Chat: FC<ChatProps> = ({
return () => container.removeEventListener('scroll', setUserScrolled)
}, [])
// Reset user scroll state when conversation changes or a new chat starts
// Track the first message ID to detect conversation switches (fixes #29820)
const prevFirstMessageIdRef = useRef<string | undefined>(undefined)
useEffect(() => {
const firstMessageId = chatList[0]?.id
// Reset when: new chat (length <= 1) OR conversation switched (first message ID changed)
if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId))
userScrolledRef.current = false
prevFirstMessageIdRef.current = firstMessageId
}, [chatList])
useEffect(() => {
if (!sidebarCollapseState)
setTimeout(() => handleWindowResize(), 200)
if (!sidebarCollapseState) {
const timer = setTimeout(() => handleWindowResize(), 200)
return () => clearTimeout(timer)
}
}, [handleWindowResize, sidebarCollapseState])
const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
@@ -273,8 +269,9 @@ const Chat: FC<ChatProps> = ({
onFeedback={onFeedback}
getHumanInputNodeData={getHumanInputNodeData}
>
<div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
<div data-testid="chat-root" className={cn('relative h-full', isTryApp && 'flex flex-col')}>
<div
data-testid="chat-container"
ref={chatContainerRef}
className={cn('relative h-full overflow-y-auto overflow-x-hidden', isTryApp && 'h-0 grow', chatContainerClassName)}
>
@@ -323,6 +320,7 @@ const Chat: FC<ChatProps> = ({
</div>
</div>
<div
data-testid="chat-footer"
className={`absolute bottom-0 z-10 flex justify-center bg-chat-input-mask ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
ref={chatFooterRef}
>
@@ -332,9 +330,10 @@ const Chat: FC<ChatProps> = ({
>
{
!noStopResponding && isResponding && (
<div className="mb-2 flex justify-center">
<div data-testid="stop-responding-container" className="mb-2 flex justify-center">
<Button className="border-components-panel-border bg-components-panel-bg text-components-button-secondary-text" onClick={onStopResponding}>
<StopCircle className="mr-[5px] h-3.5 w-3.5" />
{/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
<div className="i-custom-vender-solid-mediaanddevices-stop-circle mr-[5px] h-3.5 w-3.5" />
<span className="text-xs font-normal">{t('operation.stopResponding', { ns: 'appDebug' })}</span>
</Button>
</div>

View File

@@ -0,0 +1,22 @@
import { render } from '@testing-library/react'
import LoadingAnim from './index'
describe('LoadingAnim', () => {
it('should render correctly with text type', () => {
const { container } = render(<LoadingAnim type="text" />)
const element = container.firstChild as HTMLElement
expect(element).toBeInTheDocument()
expect(element.className).toMatch(/dot-flashing/)
expect(element.className).toMatch(/text/)
})
it('should render correctly with avatar type', () => {
const { container } = render(<LoadingAnim type="avatar" />)
const element = container.firstChild as HTMLElement
expect(element).toBeInTheDocument()
expect(element.className).toMatch(/dot-flashing/)
expect(element.className).toMatch(/avatar/)
})
})

View File

@@ -0,0 +1,129 @@
import type { IChatItem, ThoughtItem } from '@/app/components/base/chat/chat/type'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { useStore as useAppStore } from '@/app/components/app/store'
import Log from './index'
vi.mock('@/app/components/app/store', () => ({
useStore: vi.fn(),
}))
describe('Log', () => {
const mockSetCurrentLogItem = vi.fn()
const mockSetShowPromptLogModal = vi.fn()
const mockSetShowAgentLogModal = vi.fn()
const mockSetShowMessageLogModal = vi.fn()
const createLogItem = (overrides?: Partial<IChatItem>): IChatItem => ({
id: '1',
content: 'test',
isAnswer: true, // Required per your IChatItem type
workflow_run_id: '',
agent_thoughts: [],
message_files: [],
...overrides,
})
beforeEach(() => {
vi.mocked(useAppStore).mockImplementation(selector => selector({
// State properties
appSidebarExpand: 'expand',
currentLogModalActiveTab: 'question',
showPromptLogModal: false,
showAgentLogModal: false,
showMessageLogModal: false,
showAppConfigureFeaturesModal: false, // Fixed: Added missing required property
currentLogItem: null,
// Action functions
setCurrentLogItem: mockSetCurrentLogItem,
setShowPromptLogModal: mockSetShowPromptLogModal,
setShowAgentLogModal: mockSetShowAgentLogModal,
setShowMessageLogModal: mockSetShowMessageLogModal,
} as unknown as Parameters<typeof selector>[0])) // Fixed: Double cast to avoid overlap error
})
it('should render correctly', () => {
render(<Log logItem={createLogItem()} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should show message log modal when workflow_run_id exists', async () => {
const user = userEvent.setup()
const logItem = createLogItem({ workflow_run_id: 'run-123' })
render(<Log logItem={logItem} />)
const container = screen.getByRole('button').parentElement
if (container)
await user.click(container)
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(true)
})
it('should show agent log modal when agent_thoughts exists and workflow_run_id is missing', async () => {
const user = userEvent.setup()
const thought: ThoughtItem = {
id: 't1',
tool: 'test',
thought: 'thinking',
tool_input: '',
message_id: 'm1',
conversation_id: 'c1',
observation: '',
position: 1,
}
const logItem = createLogItem({
workflow_run_id: '',
agent_thoughts: [thought],
})
render(<Log logItem={logItem} />)
const container = screen.getByRole('button').parentElement
if (container)
await user.click(container)
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(true)
})
it('should show prompt log modal when both workflow_run_id and agent_thoughts are missing', async () => {
const user = userEvent.setup()
const logItem = createLogItem({
workflow_run_id: '',
agent_thoughts: [],
})
render(<Log logItem={logItem} />)
const container = screen.getByRole('button').parentElement
if (container)
await user.click(container)
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true)
})
it('should prevent event propagation on click', async () => {
const user = userEvent.setup()
// 1. Spy on both the standard propagation and the immediate propagation
const stopPropagationSpy = vi.spyOn(Event.prototype, 'stopPropagation')
const stopImmediatePropagationSpy = vi.spyOn(Event.prototype, 'stopImmediatePropagation')
render(<Log logItem={createLogItem()} />)
// Find the container div that has the onClick handler
const container = screen.getByRole('button').parentElement
if (container)
await user.click(container)
// 2. Assert that both were called
expect(stopPropagationSpy).toHaveBeenCalled()
expect(stopImmediatePropagationSpy).toHaveBeenCalled()
// 3. Clean up spies (Good practice to avoid interfering with other tests)
stopPropagationSpy.mockRestore()
stopImmediatePropagationSpy.mockRestore()
})
})

View File

@@ -0,0 +1,267 @@
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import type { ChatConfig, ChatItem, OnRegenerate } from '../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { act, render, screen, waitFor } 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 Toast from '../../toast'
import { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
import { ChatContextProvider } from './context'
import Question from './question'
// Global Mocks
vi.mock('@react-aria/interactions', () => ({
useFocusVisible: () => ({ isFocusVisible: false }),
}))
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
// Mock ResizeObserver and capture lifecycle for targeted coverage
const observeMock = vi.fn()
const unobserveMock = vi.fn()
const disconnectMock = vi.fn()
let resizeCallback: ResizeObserverCallback | null = null
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback
}
observe = observeMock
unobserve = unobserveMock
disconnect = disconnectMock
}
vi.stubGlobal('ResizeObserver', MockResizeObserver)
type RenderProps = {
theme?: Theme | null
questionIcon?: React.ReactNode
enableEdit?: boolean
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
answerIcon?: React.ReactNode
}
const makeItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({
id: 'q-1',
content: 'This is the question content',
message_files: [],
siblingCount: 3,
siblingIndex: 0,
prevSibling: null,
nextSibling: 'q-2',
...overrides,
} as unknown as ChatItem)
const renderWithProvider = (
item: ChatItem,
onRegenerate: OnRegenerate = vi.fn() as unknown as OnRegenerate,
props: RenderProps = {},
) => {
return render(
<ChatContextProvider
config={{} as unknown as (ChatConfig | undefined)}
isResponding={false}
chatList={[]}
showPromptLog={false}
questionIcon={props.questionIcon}
answerIcon={props.answerIcon}
onSend={vi.fn()}
onRegenerate={onRegenerate}
onAnnotationEdited={vi.fn()}
onAnnotationAdded={vi.fn()}
onAnnotationRemoved={vi.fn()}
disableFeedback={false}
onFeedback={vi.fn()}
getHumanInputNodeData={vi.fn()}
>
<Question
item={item}
theme={props.theme}
questionIcon={props.questionIcon}
enableEdit={props.enableEdit}
switchSibling={props.switchSibling}
hideAvatar={props.hideAvatar}
/>
</ChatContextProvider>,
)
}
describe('Question component', () => {
beforeEach(() => {
vi.clearAllMocks()
resizeCallback = null
})
it('should render the question content container and default avatar when hideAvatar is false', () => {
const { container } = renderWithProvider(makeItem())
const markdown = container.querySelector('.markdown-body')
expect(markdown).toBeInTheDocument()
const avatar = container.querySelector('.h-10.w-10') || container.querySelector('.h-10.w-10.shrink-0')
expect(avatar).toBeTruthy()
})
it('should hide avatar when hideAvatar is true', () => {
const { container } = renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { hideAvatar: true })
const avatar = container.querySelector('.h-10.w-10')
expect(avatar).toBeNull()
})
it('should observe content width resize and update layout accurately', () => {
renderWithProvider(makeItem())
expect(observeMock).toHaveBeenCalled()
expect(resizeCallback).not.toBeNull()
// Mock HTML element clientWidth to trigger logic mapping line coverage
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 })
act(() => {
if (resizeCallback) {
resizeCallback([], {} as ResizeObserver)
}
})
const actionContainer = screen.getByTestId('action-container')
// 500 width + 8 offset defined in styles
expect(actionContainer).toHaveStyle({ right: '508px' })
// Restore original
if (originalClientWidth) {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
}
})
it('should disconnect ResizeObserver on component unmount', () => {
const { unmount } = renderWithProvider(makeItem())
unmount()
expect(disconnectMock).toHaveBeenCalled()
})
it('should call copy-to-clipboard and show a toast when copy action is clicked', async () => {
const user = userEvent.setup()
const toastSpy = vi.spyOn(Toast, 'notify')
renderWithProvider(makeItem())
const copyBtn = screen.getByTestId('copy-btn')
await user.click(copyBtn)
await waitFor(() => {
expect(copy).toHaveBeenCalledWith('This is the question content')
expect(toastSpy).toHaveBeenCalled()
})
})
it('should not show edit action when enableEdit is false', () => {
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { enableEdit: false })
expect(screen.getByTestId('copy-btn')).toBeInTheDocument()
expect(screen.queryByTestId('edit-btn')).not.toBeInTheDocument()
})
it('should enter edit mode when edit action clicked, allow editing and call onRegenerate on resend', async () => {
const user = userEvent.setup()
const onRegenerate = vi.fn() as unknown as OnRegenerate
renderWithProvider(makeItem(), onRegenerate)
const editBtn = screen.getByTestId('edit-btn')
await user.click(editBtn)
const textbox = await screen.findByRole('textbox')
expect(textbox).toHaveValue('This is the question content')
await user.clear(textbox)
await user.type(textbox, 'Edited question')
const resendBtn = screen.getByRole('button', { name: /chat.resend/i })
await user.click(resendBtn)
await waitFor(() => {
expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { message: 'Edited question', files: [] })
})
})
it('should cancel editing and revert to original markdown when cancel is clicked', async () => {
const user = userEvent.setup()
const { container } = renderWithProvider(makeItem())
const editBtn = screen.getByTestId('edit-btn')
await user.click(editBtn)
const textbox = await screen.findByRole('textbox')
await user.clear(textbox)
await user.type(textbox, 'Edited question')
const cancelBtn = screen.getByRole('button', { name: /operation.cancel/i })
await user.click(cancelBtn)
await waitFor(() => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
const md = container.querySelector('.markdown-body')
expect(md).toBeInTheDocument()
})
})
it('should switch siblings when prev/next buttons are clicked', async () => {
const user = userEvent.setup()
const switchSibling = vi.fn()
const item = makeItem({ prevSibling: 'q-prev', nextSibling: 'q-next', siblingIndex: 1 })
renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling })
const prevBtn = screen.getByRole('button', { name: /previous/i })
const nextBtn = screen.getByRole('button', { name: /next/i })
await user.click(prevBtn)
await user.click(nextBtn)
expect(switchSibling).toHaveBeenCalledTimes(2)
expect(switchSibling).toHaveBeenCalledWith('q-prev')
expect(switchSibling).toHaveBeenCalledWith('q-next')
})
it('should render prev disabled when no prevSibling is provided', () => {
const item = makeItem({ prevSibling: undefined, nextSibling: 'q-next', siblingIndex: 0, siblingCount: 2 })
renderWithProvider(item, vi.fn() as unknown as OnRegenerate)
const prevBtn = screen.getByRole('button', { name: /previous/i })
const nextBtn = screen.getByRole('button', { name: /next/i })
expect(prevBtn).toBeDisabled()
expect(nextBtn).not.toBeDisabled()
})
it('should render message files block when message_files provided (audio file branch covered)', () => {
const files = [
{
name: 'audio1.mp3',
url: 'https://example.com/audio1.mp3',
type: 'audio/mpeg',
previewUrl: 'https://example.com/audio1.mp3',
size: 1234,
} as unknown as FileEntity,
]
renderWithProvider(makeItem({ message_files: files }))
expect(screen.getByText(/audio1.mp3/i)).toBeInTheDocument()
})
it('should apply theme bubble styles when theme provided', () => {
const themeBuilder = new ThemeBuilder()
themeBuilder.buildTheme('#ff0000', false)
const theme = themeBuilder.theme
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { theme })
const contentContainer = screen.getByTestId('question-content')
expect(contentContainer.getAttribute('style')).not.toBeNull()
})
})

View File

@@ -4,7 +4,6 @@ import type {
} from 'react'
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import type { ChatItem } from '../types'
import { RiClipboardLine, RiEditLine } from '@remixicon/react'
import copy from 'copy-to-clipboard'
import {
memo,
@@ -16,7 +15,6 @@ import {
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
import { FileList } from '@/app/components/base/file-uploader'
import { User } from '@/app/components/base/icons/src/public/avatar'
import { Markdown } from '@/app/components/base/markdown'
import { cn } from '@/utils/classnames'
import ActionButton from '../../action-button'
@@ -107,25 +105,29 @@ const Question: FC<QuestionProps> = ({
<div className={cn('group relative mr-4 flex max-w-full items-start overflow-x-hidden pl-14', isEditing && 'flex-1')}>
<div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}>
<div
data-testid="action-container"
className="absolute hidden 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"
style={{ right: contentWidth + 8 }}
>
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
<ActionButton
data-testid="copy-btn"
onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
>
<RiClipboardLine className="h-4 w-4" />
<div className="i-ri-clipboard-line h-4 w-4" />
</ActionButton>
{enableEdit && (
<ActionButton onClick={handleEdit}>
<RiEditLine className="h-4 w-4" />
<ActionButton data-testid="edit-btn" onClick={handleEdit}>
<div className="i-ri-edit-line h-4 w-4" />
</ActionButton>
)}
</div>
</div>
<div
ref={contentRef}
data-testid="question-content"
className="w-full rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 px-4 py-3 text-sm text-text-primary"
style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}}
>
@@ -150,7 +152,7 @@ const Question: FC<QuestionProps> = ({
<div className="max-h-[158px] overflow-y-auto overflow-x-hidden">
<Textarea
className={cn(
'body-lg-regular w-full p-1 leading-6 text-text-tertiary outline-none',
'w-full p-1 leading-6 text-text-tertiary outline-none body-lg-regular',
)}
autoFocus
minRows={1}
@@ -181,7 +183,7 @@ const Question: FC<QuestionProps> = ({
{
questionIcon || (
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
<User className="h-full w-full" />
<div className="i-custom-public-avatar-user h-full w-full" />
</div>
)
}

View File

@@ -0,0 +1,345 @@
import type { ThoughtItem } from '../type'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Thought from './index'
describe('Thought', () => {
const createThought = (overrides?: Partial<ThoughtItem>): ThoughtItem => ({
id: 'test-id',
tool: 'test-tool',
tool_input: 'test input',
observation: 'test output',
...overrides,
} as ThoughtItem)
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render single tool thought in collapsed state', () => {
const thought = createThought()
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText(/used/i)).toBeInTheDocument()
expect(screen.getByText('test-tool')).toBeInTheDocument()
})
it('should render multiple tool thoughts from JSON array', () => {
const thought = createThought({
tool: JSON.stringify(['tool1', 'tool2']),
tool_input: JSON.stringify(['input1', 'input2']),
observation: JSON.stringify(['output1', 'output2']),
})
render(<Thought thought={thought} isFinished={false} />)
expect(screen.getAllByText(/using/i)).toHaveLength(2)
expect(screen.getByText('tool1')).toBeInTheDocument()
expect(screen.getByText('tool2')).toBeInTheDocument()
})
it('should show input and output when expanded', async () => {
const user = userEvent.setup()
const thought = createThought({
tool_input: 'test input data',
observation: 'test output data',
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.queryByText('test input data')).not.toBeInTheDocument()
expect(screen.queryByText('test output data')).not.toBeInTheDocument()
await user.click(screen.getByText(/used/i))
expect(screen.getByText('test input data')).toBeInTheDocument()
expect(screen.getByText('test output data')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show finished state with correct text', () => {
const thought = createThought()
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText(/used/i)).toBeInTheDocument()
})
it('should show in-progress state with correct text', () => {
const thought = createThought()
render(<Thought thought={thought} isFinished={false} />)
expect(screen.getByText(/using/i)).toBeInTheDocument()
})
})
describe('Tool labels', () => {
it('should use tool name when no labels provided', () => {
const thought = createThought({
tool: 'custom-tool',
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('custom-tool')).toBeInTheDocument()
})
it('should fallback to tool name when tool_labels is undefined', () => {
const thought = createThought({
tool: 'fallback-tool',
tool_labels: undefined,
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('fallback-tool')).toBeInTheDocument()
})
it('should fallback to tool name when toolName property is missing', () => {
const thought = createThought({
tool: 'another-tool',
tool_labels: {},
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('another-tool')).toBeInTheDocument()
})
it('should fallback to tool name when language property is missing', () => {
const thought = createThought({
tool: 'test-tool',
tool_labels: {
toolName: {
en_US: 'English Label',
zh_Hans: '中文标签',
},
},
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('test-tool')).toBeInTheDocument()
})
it('should show knowledge label for dataset tools', () => {
const thought = createThought({
tool: 'dataset_123',
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText(/knowledge/i)).toBeInTheDocument()
})
})
describe('Value parsing', () => {
it('should handle invalid JSON in tool field', () => {
const thought = createThought({
tool: 'invalid-json-{',
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('invalid-json-{')).toBeInTheDocument()
})
it('should handle non-array JSON in tool field', () => {
const thought = createThought({
tool: JSON.stringify({ name: 'object-tool' }),
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText('{"name":"object-tool"}')).toBeInTheDocument()
})
it('should handle invalid JSON in tool_input when parsing array', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: JSON.stringify(['tool1']),
tool_input: 'invalid-json-{',
})
render(<Thought thought={thought} isFinished={true} />)
await user.click(screen.getByText(/used/i))
expect(screen.getByText('invalid-json-{')).toBeInTheDocument()
})
it('should handle invalid JSON in observation when parsing array', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: JSON.stringify(['tool1']),
observation: 'invalid-json-[',
})
render(<Thought thought={thought} isFinished={true} />)
await user.click(screen.getByText(/used/i))
expect(screen.getByText('invalid-json-[')).toBeInTheDocument()
})
it('should extract correct values from JSON arrays by index', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: JSON.stringify(['tool1', 'tool2', 'tool3']),
tool_input: JSON.stringify(['input1', 'input2', 'input3']),
observation: JSON.stringify(['output1', 'output2', 'output3']),
})
render(<Thought thought={thought} isFinished={true} />)
const toolSections = screen.getAllByText(/used/i)
expect(toolSections).toHaveLength(3)
await user.click(toolSections[0])
expect(screen.getByText('input1')).toBeInTheDocument()
expect(screen.getByText('output1')).toBeInTheDocument()
await user.click(toolSections[1])
expect(screen.getByText('input2')).toBeInTheDocument()
expect(screen.getByText('output2')).toBeInTheDocument()
await user.click(toolSections[2])
expect(screen.getByText('input3')).toBeInTheDocument()
expect(screen.getByText('output3')).toBeInTheDocument()
})
it('should use original value when isValueArray is false', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: 'single-tool',
tool_input: 'regular input',
observation: 'regular output',
})
render(<Thought thought={thought} isFinished={true} />)
await user.click(screen.getByText(/used/i))
expect(screen.getByText('regular input')).toBeInTheDocument()
expect(screen.getByText('regular output')).toBeInTheDocument()
})
})
describe('User interactions', () => {
it('should toggle expand state on click', async () => {
const user = userEvent.setup()
const thought = createThought({
tool_input: 'test input',
observation: 'test output',
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.queryByText('test input')).not.toBeInTheDocument()
await user.click(screen.getByText(/used/i))
expect(screen.getByText('test input')).toBeInTheDocument()
await user.click(screen.getByText(/used/i))
expect(screen.queryByText('test input')).not.toBeInTheDocument()
})
it('should expand multiple tools independently', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: JSON.stringify(['tool1', 'tool2']),
tool_input: JSON.stringify(['input1', 'input2']),
observation: JSON.stringify(['output1', 'output2']),
})
render(<Thought thought={thought} isFinished={true} />)
const toolHeaders = screen.getAllByText(/used/i)
await user.click(toolHeaders[0])
expect(screen.getByText('input1')).toBeInTheDocument()
expect(screen.queryByText('input2')).not.toBeInTheDocument()
await user.click(toolHeaders[1])
expect(screen.getByText('input1')).toBeInTheDocument()
expect(screen.getByText('input2')).toBeInTheDocument()
})
})
describe('Multiple tools with labels', () => {
it('should render multiple tools with dataset prefix', () => {
const thought = createThought({
tool: JSON.stringify(['dataset_123', 'dataset_456']),
tool_input: JSON.stringify(['input1', 'input2']),
observation: JSON.stringify(['output1', 'output2']),
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getAllByText(/knowledge/i)).toHaveLength(2)
})
it('should handle mixed dataset and regular tools', () => {
const thought = createThought({
tool: JSON.stringify(['dataset_123', 'regular-tool']),
tool_input: JSON.stringify(['input1', 'input2']),
observation: JSON.stringify(['output1', 'output2']),
})
render(<Thought thought={thought} isFinished={true} />)
expect(screen.getByText(/knowledge/i)).toBeInTheDocument()
expect(screen.getByText('regular-tool')).toBeInTheDocument()
})
})
describe('Edge cases', () => {
it('should handle empty tool_input', async () => {
const user = userEvent.setup()
const thought = createThought({
tool_input: '',
observation: 'output',
})
render(<Thought thought={thought} isFinished={true} />)
await user.click(screen.getByText(/used/i))
expect(screen.getByText(/requestTitle/i)).toBeInTheDocument()
})
it('should handle empty observation', async () => {
const user = userEvent.setup()
const thought = createThought({
tool_input: 'input',
observation: '',
})
render(<Thought thought={thought} isFinished={true} />)
await user.click(screen.getByText(/used/i))
expect(screen.getByText(/responseTitle/i)).toBeInTheDocument()
})
it('should handle JSON array with undefined elements', async () => {
const user = userEvent.setup()
const thought = createThought({
tool: JSON.stringify(['tool1', 'tool2']),
tool_input: JSON.stringify(['input1']),
observation: JSON.stringify(['output1']),
})
render(<Thought thought={thought} isFinished={true} />)
const toolHeaders = screen.getAllByText(/used/i)
await user.click(toolHeaders[1])
expect(screen.getByText('tool2')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,102 @@
import type { OnSend } from '../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TryToAsk from './try-to-ask'
describe('TryToAsk', () => {
const mockOnSend: OnSend = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the component with header text', () => {
render(
<TryToAsk
suggestedQuestions={['Question 1']}
onSend={mockOnSend}
/>,
)
expect(screen.getByText(/tryToAsk/i)).toBeInTheDocument()
})
it('renders all suggested questions as buttons', () => {
const questions = ['What is AI?', 'How does it work?', 'Tell me more']
render(
<TryToAsk
suggestedQuestions={questions}
onSend={mockOnSend}
/>,
)
questions.forEach((question) => {
expect(screen.getByRole('button', { name: question })).toBeInTheDocument()
})
})
it('calls onSend with the correct question when button is clicked', async () => {
const user = userEvent.setup()
const questions = ['Question 1', 'Question 2', 'Question 3']
render(
<TryToAsk
suggestedQuestions={questions}
onSend={mockOnSend}
/>,
)
await user.click(screen.getByRole('button', { name: 'Question 2' }))
expect(mockOnSend).toHaveBeenCalledTimes(1)
expect(mockOnSend).toHaveBeenCalledWith('Question 2')
})
it('calls onSend for each button click', async () => {
const user = userEvent.setup()
const questions = ['First', 'Second', 'Third']
render(
<TryToAsk
suggestedQuestions={questions}
onSend={mockOnSend}
/>,
)
await user.click(screen.getByRole('button', { name: 'First' }))
await user.click(screen.getByRole('button', { name: 'Third' }))
expect(mockOnSend).toHaveBeenCalledTimes(2)
expect(mockOnSend).toHaveBeenNthCalledWith(1, 'First')
expect(mockOnSend).toHaveBeenNthCalledWith(2, 'Third')
})
it('renders no buttons when suggestedQuestions is empty', () => {
render(
<TryToAsk
suggestedQuestions={[]}
onSend={mockOnSend}
/>,
)
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
it('renders single question correctly', async () => {
const user = userEvent.setup()
const question = 'Single question'
render(
<TryToAsk
suggestedQuestions={[question]}
onSend={mockOnSend}
/>,
)
const button = screen.getByRole('button', { name: question })
expect(button).toBeInTheDocument()
await user.click(button)
expect(mockOnSend).toHaveBeenCalledWith(question)
})
})

View File

@@ -1460,26 +1460,6 @@
"count": 1
}
},
"app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/chat/chat/answer/human-input-content/executed-action.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/chat/chat/answer/human-input-content/tips.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/base/chat/chat/answer/human-input-content/utils.ts": {
"ts/no-explicit-any": {
"count": 1
@@ -1496,21 +1476,6 @@
"count": 1
}
},
"app/components/base/chat/chat/answer/more.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/chat/chat/answer/operation.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/chat/chat/answer/suggested-questions.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/chat/chat/answer/tool-detail.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 5
@@ -1519,9 +1484,6 @@
"app/components/base/chat/chat/answer/workflow-process.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/chat/chat/chat-input-area/index.tsx": {
@@ -1539,27 +1501,6 @@
},
"app/components/base/chat/chat/citation/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/chat/chat/citation/popup.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/base/chat/chat/citation/progress-tooltip.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/chat/chat/citation/tooltip.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
@@ -1584,11 +1525,6 @@
"count": 3
}
},
"app/components/base/chat/chat/question.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/chat/chat/try-to-ask.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1