diff --git a/web/app/components/base/chat/chat/answer/agent-content.spec.tsx b/web/app/components/base/chat/chat/answer/agent-content.spec.tsx new file mode 100644 index 0000000000..ef4143fa6f --- /dev/null +++ b/web/app/components/base/chat/chat/answer/agent-content.spec.tsx @@ -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 }) => ( +
+ {String(props.content)} +
+ ), +})) + +// Mock Thought +vi.mock('@/app/components/base/chat/chat/thought', () => ({ + default: ({ thought, isFinished }: IThoughtProps) => ( +
+ {thought.thought} +
+ ), +})) + +// Mock FileList and Utils +vi.mock('@/app/components/base/file-uploader', () => ({ + FileList: ({ files }: { files: FileEntity[] }) => ( +
+ {files.map(f => f.name).join(', ')} +
+ ), +})) + +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() + expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Log Annotation Content') + }) + + it('renders content prop if provided and no annotation', () => { + render() + 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() + 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() + const thoughts = screen.getAllByTestId('thought-component') + expect(thoughts[0]).toHaveAttribute('data-finished', 'true') + expect(thoughts[1]).toHaveAttribute('data-finished', 'false') + + rerender() + 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() + expect(screen.getByTestId('file-list-component')).toHaveTextContent('processed-file1, processed-file2') + }) + + it('renders nothing if no annotation, content, or thoughts', () => { + render() + expect(screen.getByTestId('agent-content-container')).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/agent-content.tsx b/web/app/components/base/chat/chat/answer/agent-content.tsx index d8009f13d4..579c1836e9 100644 --- a/web/app/components/base/chat/chat/answer/agent-content.tsx +++ b/web/app/components/base/chat/chat/answer/agent-content.tsx @@ -23,15 +23,29 @@ const AgentContent: FC = ({ agent_thoughts, } = item - if (annotation?.logAnnotation) - return + if (annotation?.logAnnotation) { + return ( + + ) + } return ( -
- {content ? : agent_thoughts?.map((thought, index) => ( -
+
+ {content ? ( + + ) : agent_thoughts?.map((thought, index) => ( +
{thought.thought && ( - + )} {/* {item.tool} */} {/* perhaps not use tool */} diff --git a/web/app/components/base/chat/chat/answer/basic-content.spec.tsx b/web/app/components/base/chat/chat/answer/basic-content.spec.tsx new file mode 100644 index 0000000000..9a03ea9d40 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/basic-content.spec.tsx @@ -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) => ( +
+ {String(content)} +
+ ), +})) + +describe('BasicContent', () => { + const mockItem = { + id: '1', + content: 'Hello World', + isAnswer: true, + } + + it('renders content correctly', () => { + render() + 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() + 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() + 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() + 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() + 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() + 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() + const markdown = screen.getByTestId('basic-content-markdown') + expect(markdown).toHaveAttribute('data-content', '12345') + }) +}) diff --git a/web/app/components/base/chat/chat/answer/basic-content.tsx b/web/app/components/base/chat/chat/answer/basic-content.tsx index cda2dd6ffb..15c1125b0f 100644 --- a/web/app/components/base/chat/chat/answer/basic-content.tsx +++ b/web/app/components/base/chat/chat/answer/basic-content.tsx @@ -15,8 +15,14 @@ const BasicContent: FC = ({ content, } = item - if (annotation?.logAnnotation) - return + if (annotation?.logAnnotation) { + return ( + + ) + } // 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 = ({ item.isError && '!text-[#F04438]', )} content={displayContent} + data-testid="basic-content-markdown" /> ) } diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-item.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-item.spec.tsx new file mode 100644 index 0000000000..2c762f37b5 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-item.spec.tsx @@ -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 }) =>
{content}
, +})) + +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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(container.firstChild).toBeNull() + }) + + it('should render nothing if input type is not supported', () => { + const { container } = render( + , + ) + + expect(container.querySelector('[data-testid="content-item-textarea"]')).not.toBeInTheDocument() + expect(container.querySelector('.py-3')?.textContent).toBe('') + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx index 3c9cd617d0..9649a92167 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx @@ -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" /> )}
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.spec.tsx new file mode 100644 index 0000000000..36f264a834 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.spec.tsx @@ -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:
Child Content
, + } + + it('should render node title and children by default when not collapsible', () => { + render() + + 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() + + // 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() + + expect(screen.getByTestId('child-content')).toBeInTheDocument() + const expandToggle = screen.getByTestId('expand-icon') + expect(expandToggle.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx index acd154e30a..85d8affb71 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx @@ -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 ( -
+
{/* node icon */} {/* node name */}
{nodeTitle}
{showExpandIcon && ( -
+
{ isExpanded ? ( - +
) : ( - +
) }
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/executed-action.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.spec.tsx new file mode 100644 index 0000000000..3f2e6e4beb --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.spec.tsx @@ -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() + + 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() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx index ccdfcb624b..a063fee777 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx @@ -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 ( -
+
-
- +
+
}} + components={{ strong: }} values={{ actionName: executedAction.id }} />
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.spec.tsx new file mode 100644 index 0000000000..fdf3a3244b --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.spec.tsx @@ -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() + 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() + + 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() + + expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-warning') + expect(screen.getByText('share.humanInput.expiredTip')).toBeInTheDocument() + expect(container.querySelector('.i-ri-alert-fill')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx index 786440dc6b..c3a2f2fdfa 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx @@ -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 (
@@ -28,13 +28,13 @@ const ExpirationTime = ({ isSameOrAfter ? ( <> - +
{t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })} ) : ( <> - +
{t('humanInput.expiredTip', { ns: 'share' })} ) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.spec.tsx new file mode 100644 index 0000000000..e9d6fdee3c --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.spec.tsx @@ -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 }) => ( +
+ {content} + +
+ ), +})) + +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() + + // 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() + + // 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 + const submitPromise = new Promise((resolve) => { + resolveSubmit = resolve + }) + const mockOnSubmit = vi.fn().mockReturnValue(submitPromise) + + render() + + 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() + 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() + expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx index 0b5d54ab7e..2c22fabdb5 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx @@ -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} diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.spec.tsx new file mode 100644 index 0000000000..f56b081370 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.spec.tsx @@ -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 }) =>
{content}
, +})) + +describe('SubmittedContent', () => { + it('should render Markdown with the provided content', () => { + const content = '## Test Content' + render() + + expect(screen.getByTestId('submitted-content')).toBeInTheDocument() + expect(screen.getByTestId('mock-markdown')).toHaveTextContent(content) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx index 68d55f7d64..d56ca4676d 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx @@ -9,7 +9,9 @@ const SubmittedContent = ({ content, }: SubmittedContentProps) => { return ( - +
+ +
) } diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted.spec.tsx new file mode 100644 index 0000000000..3ea4a25fcd --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted.spec.tsx @@ -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 }) =>
{content}
, +})) + +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() + + // 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() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/tips.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/tips.spec.tsx new file mode 100644 index 0000000000..44a92f0e0b --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/tips.spec.tsx @@ -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() + 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( + , + ) + + 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( + , + ) + + expect(screen.getByText('common.humanInputEmailTipInDebugMode')).toBeInTheDocument() + expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument() + }) + + it('should render debug mode tip', () => { + render( + , + ) + + 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( + , + ) + + expect(screen.queryByTestId('tips')).toBeEmptyDOMElement() + // Divider is outside of tips container, but within the fragment + expect(container.querySelector('.v-divider')).toBeDefined() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx b/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx index 54cfc8c5a5..9fac47a4a6 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx @@ -20,12 +20,12 @@ const Tips = ({ return ( <> -
+
{showEmailTip && !isEmailDebugMode && ( -
{t('common.humanInputEmailTip', { ns: 'workflow' })}
+
{t('common.humanInputEmailTip', { ns: 'workflow' })}
)} {showEmailTip && isEmailDebugMode && ( -
+
)} - {showDebugModeTip &&
{t('common.humanInputWebappTip', { ns: 'workflow' })}
} + {showDebugModeTip &&
{t('common.humanInputWebappTip', { ns: 'workflow' })}
}
) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.spec.tsx new file mode 100644 index 0000000000..192b4f08b4 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.spec.tsx @@ -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() + 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( + , + ) + + 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() + + expect(screen.queryByTestId('expiration-time')).not.toBeInTheDocument() + }) + + it('should hide Tips when both tip flags are false', () => { + render( + , + ) + + expect(screen.queryByTestId('tips')).not.toBeInTheDocument() + }) + + it('should render different email tips based on debug mode', () => { + const { rerender } = render( + , + ) + + expect(screen.getByText('workflow.common.humanInputEmailTip')).toBeInTheDocument() + + rerender( + , + ) + + 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() + + 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() + + 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 + const handleSubmit = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveSubmit = resolve + })) + const data = createMockFormData() + + render() + + 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() + 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() + // 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() + // initializeInputs is tested indirectly here. + // We can't easily assert the internal state of HumanInputForm, but we can verify it doesn't crash. + }) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-filled-form-list.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-filled-form-list.spec.tsx new file mode 100644 index 0000000000..5eceddd444 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-filled-form-list.spec.tsx @@ -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 => ({ + 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() + + expect(screen.queryByText('Node Title')).not.toBeInTheDocument() + }) + + it('renders one form item', () => { + const data = [createFormData()] + + render() + + 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() + + expect(screen.getByText('First')).toBeInTheDocument() + expect(screen.getByText('Second')).toBeInTheDocument() + }) + + it('renders wrapper container', () => { + const { container } = render( + , + ) + + expect(container.firstChild).toHaveClass('flex') + expect(container.firstChild).toHaveClass('flex-col') + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-form-list.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-form-list.spec.tsx new file mode 100644 index 0000000000..4bfd3a7d97 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-form-list.spec.tsx @@ -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 }) => ( +
+ {children} +
+ ), +})) + +vi.mock('./human-input-content/unsubmitted', () => ({ + UnsubmittedHumanInputContent: ({ showEmailTip, isEmailDebugMode, showDebugModeTip }: { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }) => ( +
+ {showEmailTip ? 'true' : 'false'} + {isEmailDebugMode ? 'true' : 'false'} + {showDebugModeTip ? 'true' : 'false'} +
+ ), +})) + +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() + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + expect(screen.getByTestId('email-tip')).toHaveTextContent('false') + expect(screen.getByTestId('debug-tip')).toHaveTextContent('true') + }) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-form-list.tsx b/web/app/components/base/chat/chat/answer/human-input-form-list.tsx index 1403bcb600..47dcd72094 100644 --- a/web/app/components/base/chat/chat/answer/human-input-form-list.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-form-list.tsx @@ -45,22 +45,28 @@ const HumanInputFormList = ({ const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui) return ( -
+
{ filteredHumanInputFormDataList.map(formData => ( - - - + + + +
)) }
diff --git a/web/app/components/base/chat/chat/answer/more.spec.tsx b/web/app/components/base/chat/chat/answer/more.spec.tsx new file mode 100644 index 0000000000..551c15e659 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/more.spec.tsx @@ -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() + + 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() + + expect(screen.queryByTestId('more-tps')).not.toBeInTheDocument() + }) + + it('should render nothing inside container if more prop is missing', () => { + render() + const containerDiv = screen.getByTestId('more-container') + expect(containerDiv).toBeInTheDocument() + expect(containerDiv.children.length).toBe(0) + }) + + it('should apply group-hover opacity classes', () => { + render() + 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() + + // formatNumber(1234567) should return '1,234,567' + expect(screen.getByText(/1,234,567/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/chat/chat/answer/more.tsx b/web/app/components/base/chat/chat/answer/more.tsx index c091418cef..700c548ee4 100644 --- a/web/app/components/base/chat/chat/answer/more.tsx +++ b/web/app/components/base/chat/chat/answer/more.tsx @@ -13,19 +13,24 @@ const More: FC = ({ const { t } = useTranslation() return ( -
+
{ more && ( <>
{`${t('detail.timeConsuming', { ns: 'appLog' })} ${more.latency}${t('detail.second', { ns: 'appLog' })}`}
{`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
@@ -33,6 +38,7 @@ const More: FC = ({
{`${more.tokens_per_second} tokens/s`}
@@ -41,6 +47,7 @@ const More: FC = ({
{more.time}
diff --git a/web/app/components/base/chat/chat/answer/operation.spec.tsx b/web/app/components/base/chat/chat/answer/operation.spec.tsx new file mode 100644 index 0000000000..eb52dffe8f --- /dev/null +++ b/web/app/components/base/chat/chat/answer/operation.spec.tsx @@ -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 + ? ( +
+ + + + +
+ ) + : 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 ( +
+ {cached + ? ( + + ) + : ( + + )} +
+ ) + }, +})) + +vi.mock('@/app/components/base/new-audio-button', () => ({ + default: () => , +})) + +vi.mock('@/app/components/base/chat/chat/log', () => ({ + default: () => , +})) + +vi.mock('next/navigation', () => ({ + useParams: vi.fn(() => ({ appId: 'test-app' })), + usePathname: vi.fn(() => '/apps/test-app'), +})) + +const makeChatConfig = (overrides: Partial = {}): 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( +
+ +
, + ) + } + + 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') + }) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx index 4acf107232..f0d077975c 100644 --- a/web/app/components/base/chat/chat/answer/operation.tsx +++ b/web/app/components/base/chat/chat/answer/operation.tsx @@ -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 = ({ } 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 = ({ !hasWorkflowProcess && positionRight && '!top-[9px]', )} style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}} + data-testid="operation-bar" > {shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
= ({ onClick={() => handleFeedback(null, undefined, 'user')} > {displayUserFeedback?.rating === 'like' - ? - : } + ?
+ :
} ) @@ -215,13 +200,13 @@ const Operation: FC = ({ state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default} onClick={() => handleLikeClick('user')} > - +
handleDislikeClick('user')} > - +
)} @@ -242,12 +227,12 @@ const Operation: FC = ({ {displayUserFeedback.rating === 'like' ? ( - +
) : ( - +
)} @@ -266,8 +251,8 @@ const Operation: FC = ({ onClick={() => handleFeedback(null, undefined, 'admin')} > {adminLocalFeedback?.rating === 'like' - ? - : } + ?
+ :
} ) @@ -281,7 +266,7 @@ const Operation: FC = ({ state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default} onClick={() => handleLikeClick('admin')} > - +
= ({ state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default} onClick={() => handleDislikeClick('admin')} > - +
@@ -305,7 +290,7 @@ const Operation: FC = ({
)} {!isOpeningStatement && ( -
+
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && ( = ({ /> )} {!humanInputFormDataList?.length && ( - { - copy(content) - Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) }) - }} + { + copy(content) + Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) }) + }} + data-testid="copy-btn" > - +
)} {!noChatInput && ( - onRegenerate?.(item)}> - + onRegenerate?.(item)} data-testid="regenerate-btn"> +
)} {config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && ( @@ -366,7 +353,7 @@ const Operation: FC = ({ >
-