mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08:00
test: add unit tests for chat components (#32367)
This commit is contained in:
114
web/app/components/base/chat/chat/answer/agent-content.spec.tsx
Normal file
114
web/app/components/base/chat/chat/answer/agent-content.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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 */}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -9,7 +9,9 @@ const SubmittedContent = ({
|
||||
content,
|
||||
}: SubmittedContentProps) => {
|
||||
return (
|
||||
<Markdown content={content} />
|
||||
<div data-testid="submitted-content">
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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.
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
65
web/app/components/base/chat/chat/answer/more.spec.tsx
Normal file
65
web/app/components/base/chat/chat/answer/more.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
726
web/app/components/base/chat/chat/answer/operation.spec.tsx
Normal file
726
web/app/components/base/chat/chat/answer/operation.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user