From 740d94c6edae2eae5220c2150fa783ded856d103 Mon Sep 17 00:00:00 2001 From: Saumya Talwani <68903741+saumyatalwani@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:05:23 +0530 Subject: [PATCH 001/129] test: add tests for some base components (#32356) --- web/app/components/base/alert.spec.tsx | 96 +++++ web/app/components/base/alert.tsx | 12 +- .../components/base/app-unavailable.spec.tsx | 82 ++++ .../base/auto-height-textarea/index.spec.tsx | 201 +++++++++ web/app/components/base/badge.spec.tsx | 86 ++++ .../base/block-input/index.spec.tsx | 226 +++++++++++ web/app/components/base/block-input/index.tsx | 16 +- .../base/button/add-button.spec.tsx | 49 +++ web/app/components/base/button/add-button.tsx | 3 +- .../base/button/sync-button.spec.tsx | 56 +++ .../components/base/button/sync-button.tsx | 5 +- .../components/base/carousel/index.spec.tsx | 218 ++++++++++ .../assets/indeterminate-icon.spec.tsx | 17 + web/app/components/base/drawer/index.tsx | 3 +- .../base/error-boundary/index.spec.tsx | 383 ++++++++++++++++++ .../base/float-right-container/index.spec.tsx | 140 +++++++ .../components/base/pagination/hook.spec.ts | 155 +++++++ .../components/base/pagination/index.spec.tsx | 242 +++++++++++ .../base/pagination/pagination.spec.tsx | 376 +++++++++++++++++ .../components/base/theme-selector.spec.tsx | 103 +++++ web/app/components/base/theme-selector.tsx | 24 +- .../components/base/theme-switcher.spec.tsx | 106 +++++ web/app/components/base/theme-switcher.tsx | 18 +- web/eslint-suppressions.json | 8 - 24 files changed, 2569 insertions(+), 56 deletions(-) create mode 100644 web/app/components/base/alert.spec.tsx create mode 100644 web/app/components/base/app-unavailable.spec.tsx create mode 100644 web/app/components/base/auto-height-textarea/index.spec.tsx create mode 100644 web/app/components/base/badge.spec.tsx create mode 100644 web/app/components/base/block-input/index.spec.tsx create mode 100644 web/app/components/base/button/add-button.spec.tsx create mode 100644 web/app/components/base/button/sync-button.spec.tsx create mode 100644 web/app/components/base/carousel/index.spec.tsx create mode 100644 web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx create mode 100644 web/app/components/base/error-boundary/index.spec.tsx create mode 100644 web/app/components/base/float-right-container/index.spec.tsx create mode 100644 web/app/components/base/pagination/hook.spec.ts create mode 100644 web/app/components/base/pagination/index.spec.tsx create mode 100644 web/app/components/base/pagination/pagination.spec.tsx create mode 100644 web/app/components/base/theme-selector.spec.tsx create mode 100644 web/app/components/base/theme-switcher.spec.tsx diff --git a/web/app/components/base/alert.spec.tsx b/web/app/components/base/alert.spec.tsx new file mode 100644 index 0000000000..1ad52ea201 --- /dev/null +++ b/web/app/components/base/alert.spec.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import Alert from './alert' + +describe('Alert', () => { + const defaultProps = { + message: 'This is an alert message', + onHide: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(defaultProps.message)).toBeInTheDocument() + }) + + it('should render the info icon', () => { + render() + const icon = screen.getByTestId('info-icon') + expect(icon).toBeInTheDocument() + }) + + it('should render the close icon', () => { + render() + const closeIcon = screen.getByTestId('close-icon') + expect(closeIcon).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('my-custom-class') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('pointer-events-none', 'w-full') + }) + + it('should default type to info', () => { + render() + const gradientDiv = screen.getByTestId('alert-gradient') + expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo') + }) + + it('should render with explicit type info', () => { + render() + const gradientDiv = screen.getByTestId('alert-gradient') + expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo') + }) + + it('should display the provided message text', () => { + const msg = 'A different alert message' + render() + expect(screen.getByText(msg)).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onHide when close button is clicked', () => { + const onHide = vi.fn() + render() + const closeButton = screen.getByTestId('close-icon') + fireEvent.click(closeButton) + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should not call onHide when other parts of the alert are clicked', () => { + const onHide = vi.fn() + render() + fireEvent.click(screen.getByText(defaultProps.message)) + expect(onHide).not.toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should render with an empty message string', () => { + render() + const messageDiv = screen.getByTestId('msg-container') + expect(messageDiv).toBeInTheDocument() + expect(messageDiv).toHaveTextContent('') + }) + + it('should render with a very long message', () => { + const longMessage = 'A'.repeat(1000) + render() + expect(screen.getByText(longMessage)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/alert.tsx b/web/app/components/base/alert.tsx index cf602b541a..3c1671bb2c 100644 --- a/web/app/components/base/alert.tsx +++ b/web/app/components/base/alert.tsx @@ -1,7 +1,3 @@ -import { - RiCloseLine, - RiInformation2Fill, -} from '@remixicon/react' import { cva } from 'class-variance-authority' import { memo, @@ -35,13 +31,13 @@ const Alert: React.FC = ({
-
+
- +
-
+
{message}
@@ -49,7 +45,7 @@ const Alert: React.FC = ({ className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center" onClick={onHide} > - +
diff --git a/web/app/components/base/app-unavailable.spec.tsx b/web/app/components/base/app-unavailable.spec.tsx new file mode 100644 index 0000000000..27fb359781 --- /dev/null +++ b/web/app/components/base/app-unavailable.spec.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react' +import AppUnavailable from './app-unavailable' + +describe('AppUnavailable', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/404/)).toBeInTheDocument() + }) + + it('should render the error code in a heading', () => { + render() + const heading = screen.getByRole('heading', { level: 1 }) + expect(heading).toHaveTextContent(/404/) + }) + + it('should render the default unavailable message', () => { + render() + expect(screen.getByText(/unavailable/i)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should display custom error code', () => { + render() + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('500') + }) + + it('should accept string error code', () => { + render() + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('403') + }) + + it('should apply custom className', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('flex', 'h-screen', 'w-screen', 'items-center', 'justify-center') + }) + + it('should display unknownReason when provided', () => { + render() + expect(screen.getByText(/Custom error occurred/i)).toBeInTheDocument() + }) + + it('should display unknown error translation when isUnknownReason is true', () => { + render() + expect(screen.getByText(/share.common.appUnknownError/i)).toBeInTheDocument() + }) + + it('should prioritize unknownReason over isUnknownReason', () => { + render() + expect(screen.getByText(/My custom reason/i)).toBeInTheDocument() + }) + + it('should show appUnavailable translation when isUnknownReason is false', () => { + render() + expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render with code 0', () => { + render() + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('0') + }) + + it('should render with an empty unknownReason and fall back to translation', () => { + render() + expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/auto-height-textarea/index.spec.tsx b/web/app/components/base/auto-height-textarea/index.spec.tsx new file mode 100644 index 0000000000..2eab1ba82e --- /dev/null +++ b/web/app/components/base/auto-height-textarea/index.spec.tsx @@ -0,0 +1,201 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sleep } from '@/utils' +import AutoHeightTextarea from './index' + +vi.mock('@/utils', async () => { + const actual = await vi.importActual('@/utils') + return { + ...actual, + sleep: vi.fn(), + } +}) + +describe('AutoHeightTextarea', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + const textarea = document.querySelector('textarea') + expect(textarea).toBeInTheDocument() + }) + + it('should render with placeholder when value is empty', () => { + render() + expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument() + }) + + it('should render with value', () => { + render() + const textarea = screen.getByDisplayValue('Hello World') + expect(textarea).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className to textarea', () => { + render() + const textarea = document.querySelector('textarea') + expect(textarea).toHaveClass('custom-class') + }) + + it('should apply custom wrapperClassName to wrapper div', () => { + render() + const wrapper = document.querySelector('div.relative') + expect(wrapper).toHaveClass('wrapper-class') + }) + + it('should apply minHeight and maxHeight styles to hidden div', () => { + render() + const hiddenDiv = document.querySelector('div.invisible') + expect(hiddenDiv).toHaveStyle({ minHeight: '50px', maxHeight: '200px' }) + }) + + it('should use default minHeight and maxHeight when not provided', () => { + render() + const hiddenDiv = document.querySelector('div.invisible') + expect(hiddenDiv).toHaveStyle({ minHeight: '36px', maxHeight: '96px' }) + }) + + it('should set autoFocus on textarea', () => { + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') + render() + expect(focusSpy).toHaveBeenCalled() + }) + }) + + describe('User Interactions', () => { + it('should call onChange when textarea value changes', () => { + const handleChange = vi.fn() + render() + const textarea = screen.getByRole('textbox') + + fireEvent.change(textarea, { target: { value: 'new value' } }) + + expect(handleChange).toHaveBeenCalledTimes(1) + }) + + it('should call onKeyDown when key is pressed', () => { + const handleKeyDown = vi.fn() + render() + const textarea = screen.getByRole('textbox') + + fireEvent.keyDown(textarea, { key: 'Enter' }) + + expect(handleKeyDown).toHaveBeenCalledTimes(1) + }) + + it('should call onKeyUp when key is released', () => { + const handleKeyUp = vi.fn() + render() + const textarea = screen.getByRole('textbox') + + fireEvent.keyUp(textarea, { key: 'Enter' }) + + expect(handleKeyUp).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty string value', () => { + render() + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue('') + }) + + it('should handle whitespace-only value', () => { + render() + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue(' ') + }) + + it('should handle very long text (>10000 chars)', () => { + const longText = 'a'.repeat(10001) + render() + const textarea = screen.getByDisplayValue(longText) + expect(textarea).toBeInTheDocument() + }) + + it('should handle newlines in value', () => { + const textWithNewlines = 'line1\nline2\nline3' + render() + const textarea = document.querySelector('textarea') + expect(textarea).toHaveValue(textWithNewlines) + }) + + it('should handle special characters in value', () => { + const specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?' + render() + const textarea = screen.getByDisplayValue(specialChars) + expect(textarea).toBeInTheDocument() + }) + }) + + describe('Ref forwarding', () => { + it('should accept ref and allow focusing', () => { + const ref = { current: null as HTMLTextAreaElement | null } + render(} value="" onChange={vi.fn()} />) + + expect(ref.current).not.toBeNull() + expect(ref.current?.tagName).toBe('TEXTAREA') + }) + }) + + describe('controlFocus prop', () => { + it('should call focus when controlFocus changes', () => { + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') + const { rerender } = render() + + expect(focusSpy).toHaveBeenCalledTimes(1) + + rerender() + + expect(focusSpy).toHaveBeenCalledTimes(2) + focusSpy.mockRestore() + }) + + it('should retry focus recursively when ref is not ready during autoFocus', async () => { + const delayedRef = {} as React.RefObject + let assignedNode: HTMLTextAreaElement | null = null + let exposedNode: HTMLTextAreaElement | null = null + + Object.defineProperty(delayedRef, 'current', { + get: () => exposedNode, + set: (value: HTMLTextAreaElement | null) => { + assignedNode = value + }, + }) + + const sleepMock = vi.mocked(sleep) + let sleepCalls = 0 + sleepMock.mockImplementation(async () => { + sleepCalls += 1 + if (sleepCalls === 2) + exposedNode = assignedNode + }) + + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') + const setSelectionRangeSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'setSelectionRange') + + render() + + await waitFor(() => { + expect(sleepMock).toHaveBeenCalledTimes(2) + expect(focusSpy).toHaveBeenCalled() + expect(setSelectionRangeSpy).toHaveBeenCalledTimes(1) + }) + + focusSpy.mockRestore() + setSelectionRangeSpy.mockRestore() + }) + }) + + describe('displayName', () => { + it('should have displayName set', () => { + expect(AutoHeightTextarea.displayName).toBe('AutoHeightTextarea') + }) + }) +}) diff --git a/web/app/components/base/badge.spec.tsx b/web/app/components/base/badge.spec.tsx new file mode 100644 index 0000000000..5ca5cfe789 --- /dev/null +++ b/web/app/components/base/badge.spec.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react' +import Badge from './badge' + +describe('Badge', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/beta/i)).toBeInTheDocument() + }) + + it('should render with children instead of text', () => { + render(child content) + expect(screen.getByText(/child content/i)).toBeInTheDocument() + }) + + it('should render with no text or children', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild).toHaveTextContent('') + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('relative', 'inline-flex', 'h-5', 'items-center') + }) + + it('should apply uppercase class by default', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('system-2xs-medium-uppercase') + }) + + it('should apply non-uppercase class when uppercase is false', () => { + const { container } = render() + const badge = container.firstChild as HTMLElement + expect(badge).toHaveClass('system-xs-medium') + expect(badge).not.toHaveClass('system-2xs-medium-uppercase') + }) + + it('should render red corner mark when hasRedCornerMark is true', () => { + const { container } = render() + const mark = container.querySelector('.bg-components-badge-status-light-error-bg') + expect(mark).toBeInTheDocument() + }) + + it('should not render red corner mark by default', () => { + const { container } = render() + const mark = container.querySelector('.bg-components-badge-status-light-error-bg') + expect(mark).not.toBeInTheDocument() + }) + + it('should prioritize children over text', () => { + render(child wins) + expect(screen.getByText(/child wins/i)).toBeInTheDocument() + expect(screen.queryByText(/text content/i)).not.toBeInTheDocument() + }) + + it('should render ReactNode as text prop', () => { + render(bold badge} />) + expect(screen.getByText(/bold badge/i)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render with empty string text', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild).toHaveTextContent('') + }) + + it('should render with hasRedCornerMark false explicitly', () => { + const { container } = render() + const mark = container.querySelector('.bg-components-badge-status-light-error-bg') + expect(mark).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/block-input/index.spec.tsx b/web/app/components/base/block-input/index.spec.tsx new file mode 100644 index 0000000000..8d8729287d --- /dev/null +++ b/web/app/components/base/block-input/index.spec.tsx @@ -0,0 +1,226 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import Toast from '@/app/components/base/toast' +import BlockInput, { getInputKeys } from './index' + +vi.mock('@/utils/var', () => ({ + checkKeys: vi.fn((_keys: string[]) => ({ + isValid: true, + errorMessageKey: '', + errorKey: '', + })), +})) + +describe('BlockInput', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(Toast, 'notify') + cleanup() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + const wrapper = screen.getByTestId('block-input') + expect(wrapper).toBeInTheDocument() + }) + + it('should render with initial value', () => { + const { container } = render() + expect(container.textContent).toContain('Hello World') + }) + + it('should render variable highlights', () => { + render() + const nameElement = screen.getByText('name') + expect(nameElement).toBeInTheDocument() + expect(nameElement.parentElement).toHaveClass('text-primary-600') + }) + + it('should render multiple variable highlights', () => { + render() + expect(screen.getByText('foo')).toBeInTheDocument() + expect(screen.getByText('bar')).toBeInTheDocument() + }) + + it('should display character count in footer when not readonly', () => { + render() + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('should hide footer in readonly mode', () => { + render() + expect(screen.queryByText('5')).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + render() + const innerContent = screen.getByTestId('block-input-content') + expect(innerContent).toHaveClass('custom-class') + }) + + it('should apply readonly prop with max height', () => { + render() + const contentDiv = screen.getByTestId('block-input').firstChild as Element + expect(contentDiv).toHaveClass('max-h-[180px]') + }) + + it('should have default empty value', () => { + render() + const contentDiv = screen.getByTestId('block-input') + expect(contentDiv).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should enter edit mode when clicked', async () => { + render() + + const contentArea = screen.getByText('Hello') + fireEvent.click(contentArea) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) + + it('should update value when typing in edit mode', async () => { + const onConfirm = vi.fn() + const { checkKeys } = await import('@/utils/var') + ; (checkKeys as ReturnType).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' }) + + render() + + const contentArea = screen.getByText('Hello') + fireEvent.click(contentArea) + + const textarea = await screen.findByRole('textbox') + fireEvent.change(textarea, { target: { value: 'Hello World' } }) + + expect(textarea).toHaveValue('Hello World') + }) + + it('should call onConfirm on value change with valid keys', async () => { + const onConfirm = vi.fn() + const { checkKeys } = await import('@/utils/var') + ; (checkKeys as ReturnType).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' }) + + render() + + const contentArea = screen.getByText('initial') + fireEvent.click(contentArea) + + const textarea = await screen.findByRole('textbox') + fireEvent.change(textarea, { target: { value: '{{name}}' } }) + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith('{{name}}', ['name']) + }) + }) + + it('should show error toast on value change with invalid keys', async () => { + const onConfirm = vi.fn() + const { checkKeys } = await import('@/utils/var'); + (checkKeys as ReturnType).mockReturnValue({ + isValid: false, + errorMessageKey: 'invalidKey', + errorKey: 'test_key', + }) + + render() + + const contentArea = screen.getByText('initial') + fireEvent.click(contentArea) + + const textarea = await screen.findByRole('textbox') + fireEvent.change(textarea, { target: { value: '{{invalid}}' } }) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalled() + }) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('should not enter edit mode when readonly is true', () => { + render() + + const contentArea = screen.getByText('Hello') + fireEvent.click(contentArea) + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty string value', () => { + const { container } = render() + expect(container.textContent).toBe('0') + const span = screen.getByTestId('block-input').querySelector('span') + expect(span).toBeInTheDocument() + expect(span).toBeEmptyDOMElement() + }) + + it('should handle value without variables', () => { + render() + expect(screen.getByText('plain text')).toBeInTheDocument() + }) + + it('should handle newlines in value', () => { + render() + expect(screen.getByText(/line1/)).toBeInTheDocument() + }) + + it('should handle multiple same variables', () => { + render() + const highlights = screen.getAllByText('name') + expect(highlights).toHaveLength(2) + }) + + it('should handle value with only variables', () => { + render() + expect(screen.getByText('foo')).toBeInTheDocument() + expect(screen.getByText('bar')).toBeInTheDocument() + expect(screen.getByText('baz')).toBeInTheDocument() + }) + + it('should handle text adjacent to variables', () => { + render() + expect(screen.getByText(/prefix/)).toBeInTheDocument() + expect(screen.getByText(/suffix/)).toBeInTheDocument() + }) + }) +}) + +describe('getInputKeys', () => { + it('should extract keys from {{}} syntax', () => { + const keys = getInputKeys('Hello {{name}}') + expect(keys).toEqual(['name']) + }) + + it('should extract multiple keys', () => { + const keys = getInputKeys('{{foo}} and {{bar}}') + expect(keys).toEqual(['foo', 'bar']) + }) + + it('should remove duplicate keys', () => { + const keys = getInputKeys('{{name}} and {{name}}') + expect(keys).toEqual(['name']) + }) + + it('should return empty array for no variables', () => { + const keys = getInputKeys('plain text') + expect(keys).toEqual([]) + }) + + it('should return empty array for empty string', () => { + const keys = getInputKeys('') + expect(keys).toEqual([]) + }) + + it('should handle keys with underscores and numbers', () => { + const keys = getInputKeys('{{user_1}} and {{user_2}}') + expect(keys).toEqual(['user_1', 'user_2']) + }) +}) diff --git a/web/app/components/base/block-input/index.tsx b/web/app/components/base/block-input/index.tsx index d9057eb737..05bb95e10b 100644 --- a/web/app/components/base/block-input/index.tsx +++ b/web/app/components/base/block-input/index.tsx @@ -63,7 +63,7 @@ const BlockInput: FC = ({ }, [isEditing]) const style = cn({ - 'block px-4 py-2 w-full h-full text-sm text-gray-900 outline-0 border-0 break-all': true, + 'block h-full w-full break-all border-0 px-4 py-2 text-sm text-gray-900 outline-0': true, 'block-input--editing': isEditing, }) @@ -111,7 +111,7 @@ const BlockInput: FC = ({ // Prevent rerendering caused cursor to jump to the start of the contentEditable element const TextAreaContentView = () => { return ( -
+
{renderSafeContent(currentValue || '')}
) @@ -121,7 +121,7 @@ const BlockInput: FC = ({ const editAreaClassName = 'focus:outline-none bg-transparent text-sm' const textAreaContent = ( -
!readonly && setIsEditing(true)}> +
!readonly && setIsEditing(true)}> {isEditing ? (
@@ -134,10 +134,10 @@ const BlockInput: FC = ({ onBlur={() => { blur() setIsEditing(false) - // click confirm also make blur. Then outer value is change. So below code has problem. - // setTimeout(() => { - // handleCancel() - // }, 1000) + // click confirm also make blur. Then outer value is change. So below code has problem. + // setTimeout(() => { + // handleCancel() + // }, 1000) }} />
@@ -147,7 +147,7 @@ const BlockInput: FC = ({ ) return ( -
+
{textAreaContent} {/* footer */} {!readonly && ( diff --git a/web/app/components/base/button/add-button.spec.tsx b/web/app/components/base/button/add-button.spec.tsx new file mode 100644 index 0000000000..658c032bb7 --- /dev/null +++ b/web/app/components/base/button/add-button.spec.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render } from '@testing-library/react' +import AddButton from './add-button' + +describe('AddButton', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render an add icon', () => { + const { container } = render() + const svg = container.querySelector('span') + expect(svg).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('cursor-pointer') + expect(container.firstChild).toHaveClass('rounded-md') + expect(container.firstChild).toHaveClass('select-none') + }) + }) + + describe('User Interactions', () => { + it('should call onClick when clicked', () => { + const onClick = vi.fn() + const { container } = render() + fireEvent.click(container.firstChild!) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick multiple times on repeated clicks', () => { + const onClick = vi.fn() + const { container } = render() + fireEvent.click(container.firstChild!) + fireEvent.click(container.firstChild!) + fireEvent.click(container.firstChild!) + expect(onClick).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/web/app/components/base/button/add-button.tsx b/web/app/components/base/button/add-button.tsx index 332b52daca..50a39ffe7c 100644 --- a/web/app/components/base/button/add-button.tsx +++ b/web/app/components/base/button/add-button.tsx @@ -1,6 +1,5 @@ 'use client' import type { FC } from 'react' -import { RiAddLine } from '@remixicon/react' import * as React from 'react' import { cn } from '@/utils/classnames' @@ -15,7 +14,7 @@ const AddButton: FC = ({ }) => { return (
- +
) } diff --git a/web/app/components/base/button/sync-button.spec.tsx b/web/app/components/base/button/sync-button.spec.tsx new file mode 100644 index 0000000000..eeaf60d46e --- /dev/null +++ b/web/app/components/base/button/sync-button.spec.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import SyncButton from './sync-button' + +vi.mock('ahooks', () => ({ + useBoolean: () => [false, { setTrue: vi.fn(), setFalse: vi.fn() }], +})) + +describe('SyncButton', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render a refresh icon', () => { + const { container } = render() + const svg = container.querySelector('span') + expect(svg).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + render() + const clickableDiv = screen.getByTestId('sync-button') + expect(clickableDiv).toHaveClass('my-custom') + }) + + it('should retain base classes when custom className is applied', () => { + render() + const clickableDiv = screen.getByTestId('sync-button') + expect(clickableDiv).toHaveClass('rounded-md') + expect(clickableDiv).toHaveClass('select-none') + }) + }) + + describe('User Interactions', () => { + it('should call onClick when clicked', () => { + const onClick = vi.fn() + render() + const clickableDiv = screen.getByTestId('sync-button')! + fireEvent.click(clickableDiv) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should call onClick multiple times on repeated clicks', () => { + const onClick = vi.fn() + render() + const clickableDiv = screen.getByTestId('sync-button')! + fireEvent.click(clickableDiv) + fireEvent.click(clickableDiv) + fireEvent.click(clickableDiv) + expect(onClick).toHaveBeenCalledTimes(3) + }) + }) +}) diff --git a/web/app/components/base/button/sync-button.tsx b/web/app/components/base/button/sync-button.tsx index 12c34026cb..06c155fb1d 100644 --- a/web/app/components/base/button/sync-button.tsx +++ b/web/app/components/base/button/sync-button.tsx @@ -1,6 +1,5 @@ 'use client' import type { FC } from 'react' -import { RiRefreshLine } from '@remixicon/react' import * as React from 'react' import TooltipPlus from '@/app/components/base/tooltip' import { cn } from '@/utils/classnames' @@ -18,8 +17,8 @@ const SyncButton: FC = ({ }) => { return ( -
- +
+
) diff --git a/web/app/components/base/carousel/index.spec.tsx b/web/app/components/base/carousel/index.spec.tsx new file mode 100644 index 0000000000..06434a51aa --- /dev/null +++ b/web/app/components/base/carousel/index.spec.tsx @@ -0,0 +1,218 @@ +import type { Mock } from 'vitest' +import { act, fireEvent, render, screen } from '@testing-library/react' +import useEmblaCarousel from 'embla-carousel-react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Carousel, useCarousel } from './index' + +vi.mock('embla-carousel-react', () => ({ + default: vi.fn(), +})) + +type EmblaEventName = 'reInit' | 'select' +type EmblaListener = (api: MockEmblaApi | undefined) => void + +type MockEmblaApi = { + scrollPrev: Mock + scrollNext: Mock + scrollTo: Mock + selectedScrollSnap: Mock + canScrollPrev: Mock + canScrollNext: Mock + slideNodes: Mock + on: Mock + off: Mock +} + +let mockCanScrollPrev = false +let mockCanScrollNext = false +let mockSelectedIndex = 0 +let mockSlideCount = 3 +let listeners: Record +let mockApi: MockEmblaApi +const mockCarouselRef = vi.fn() + +const mockedUseEmblaCarousel = vi.mocked(useEmblaCarousel) + +const createMockEmblaApi = (): MockEmblaApi => ({ + scrollPrev: vi.fn(), + scrollNext: vi.fn(), + scrollTo: vi.fn(), + selectedScrollSnap: vi.fn(() => mockSelectedIndex), + canScrollPrev: vi.fn(() => mockCanScrollPrev), + canScrollNext: vi.fn(() => mockCanScrollNext), + slideNodes: vi.fn(() => + Array.from({ length: mockSlideCount }, () => document.createElement('div')), + ), + on: vi.fn((event: EmblaEventName, callback: EmblaListener) => { + listeners[event].push(callback) + }), + off: vi.fn((event: EmblaEventName, callback: EmblaListener) => { + listeners[event] = listeners[event].filter(listener => listener !== callback) + }), +}) + +const emitEmblaEvent = (event: EmblaEventName, api: MockEmblaApi | undefined = mockApi) => { + listeners[event].forEach(callback => callback(api)) +} + +const renderCarouselWithControls = (orientation: 'horizontal' | 'vertical' = 'horizontal') => { + return render( + + + Slide 1 + + Prev + Next + Dot + , + ) +} + +describe('Carousel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCanScrollPrev = false + mockCanScrollNext = false + mockSelectedIndex = 0 + mockSlideCount = 3 + listeners = { reInit: [], select: [] } + mockApi = createMockEmblaApi() + + mockedUseEmblaCarousel.mockReturnValue( + [mockCarouselRef, mockApi] as unknown as ReturnType, + ) + }) + + // Rendering and basic semantic structure. + describe('Rendering', () => { + it('should render region and slides when used with content and items', () => { + renderCarouselWithControls() + + expect(screen.getByRole('region')).toHaveAttribute('aria-roledescription', 'carousel') + expect(screen.getByTestId('carousel-content')).toHaveClass('flex') + expect(screen.getByRole('group')).toHaveAttribute('aria-roledescription', 'slide') + }) + }) + + // Props should be translated into Embla options and visible layout. + describe('Props', () => { + it('should configure embla with horizontal axis when orientation is omitted', () => { + render( + + + , + ) + + expect(mockedUseEmblaCarousel).toHaveBeenCalledWith( + { loop: true, axis: 'x' }, + ['plugin-marker'], + ) + }) + + it('should configure embla with vertical axis and vertical content classes when orientation is vertical', () => { + renderCarouselWithControls('vertical') + + expect(mockedUseEmblaCarousel).toHaveBeenCalledWith( + { axis: 'y' }, + undefined, + ) + expect(screen.getByTestId('carousel-content')).toHaveClass('flex-col') + }) + }) + + // Users can move slides through previous and next controls. + describe('User interactions', () => { + it('should call scroll handlers when previous and next buttons are clicked', () => { + mockCanScrollPrev = true + mockCanScrollNext = true + + renderCarouselWithControls() + + fireEvent.click(screen.getByRole('button', { name: 'Prev' })) + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + + expect(mockApi.scrollPrev).toHaveBeenCalledTimes(1) + expect(mockApi.scrollNext).toHaveBeenCalledTimes(1) + }) + + it('should call scrollTo with clicked index when a dot is clicked', () => { + renderCarouselWithControls() + const dots = screen.getAllByRole('button', { name: 'Dot' }) + + fireEvent.click(dots[2]) + + expect(mockApi.scrollTo).toHaveBeenCalledWith(2) + }) + }) + + // Embla events should keep control states and selected index in sync. + describe('State synchronization', () => { + it('should update disabled states and active dot when select event is emitted', () => { + renderCarouselWithControls() + + mockCanScrollPrev = true + mockCanScrollNext = true + mockSelectedIndex = 2 + + act(() => { + emitEmblaEvent('select') + }) + + const dots = screen.getAllByRole('button', { name: 'Dot' }) + expect(screen.getByRole('button', { name: 'Prev' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled() + expect(dots[2]).toHaveAttribute('data-state', 'active') + }) + + it('should subscribe to embla events and unsubscribe from select on unmount', () => { + const { unmount } = renderCarouselWithControls() + + const selectCallback = mockApi.on.mock.calls.find( + call => call[0] === 'select', + )?.[1] as EmblaListener + + expect(mockApi.on).toHaveBeenCalledWith('reInit', expect.any(Function)) + expect(mockApi.on).toHaveBeenCalledWith('select', expect.any(Function)) + + unmount() + + expect(mockApi.off).toHaveBeenCalledWith('select', selectCallback) + }) + }) + + // Edge-case behavior for missing providers or missing embla api values. + describe('Edge cases', () => { + it('should throw when useCarousel is used outside Carousel provider', () => { + const InvalidConsumer = () => { + useCarousel() + return null + } + + expect(() => render()).toThrowError( + 'useCarousel must be used within a ', + ) + }) + + it('should render with disabled controls and no dots when embla api is undefined', () => { + mockedUseEmblaCarousel.mockReturnValue( + [mockCarouselRef, undefined] as unknown as ReturnType, + ) + + renderCarouselWithControls() + + expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Dot' })).not.toBeInTheDocument() + }) + + it('should ignore select callback when embla emits an undefined api', () => { + renderCarouselWithControls() + + expect(() => { + act(() => { + emitEmblaEvent('select', undefined) + }) + }).not.toThrow() + }) + }) +}) diff --git a/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx b/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx new file mode 100644 index 0000000000..3f39dd836f --- /dev/null +++ b/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react' +import IndeterminateIcon from './indeterminate-icon' + +describe('IndeterminateIcon', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByTestId('indeterminate-icon')).toBeInTheDocument() + }) + + it('should render an svg element', () => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/drawer/index.tsx b/web/app/components/base/drawer/index.tsx index 2f44ce75af..a145f9a64d 100644 --- a/web/app/components/base/drawer/index.tsx +++ b/web/app/components/base/drawer/index.tsx @@ -1,6 +1,5 @@ 'use client' import { Dialog, DialogBackdrop, DialogTitle } from '@headlessui/react' -import { XMarkIcon } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' import { cn } from '@/utils/classnames' import Button from '../button' @@ -81,7 +80,7 @@ export default function Drawer({ )} {showClose && ( - + )}
diff --git a/web/app/components/base/error-boundary/index.spec.tsx b/web/app/components/base/error-boundary/index.spec.tsx new file mode 100644 index 0000000000..1caca84d79 --- /dev/null +++ b/web/app/components/base/error-boundary/index.spec.tsx @@ -0,0 +1,383 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ErrorBoundary, { ErrorFallback, useAsyncError, useErrorHandler, withErrorBoundary } from './index' + +const mockConfig = vi.hoisted(() => ({ + isDev: false, +})) + +vi.mock('@/config', () => ({ + get IS_DEV() { + return mockConfig.isDev + }, +})) + +type ThrowOnRenderProps = { + message?: string + shouldThrow: boolean +} + +const ThrowOnRender = ({ shouldThrow, message = 'render boom' }: ThrowOnRenderProps) => { + if (shouldThrow) + throw new Error(message) + + return
Child content rendered
+} + +let consoleErrorSpy: ReturnType + +describe('ErrorBoundary', () => { + beforeEach(() => { + vi.clearAllMocks() + mockConfig.isDev = false + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + consoleErrorSpy.mockRestore() + }) + + // Verify default render and default fallback behavior. + describe('Rendering', () => { + it('should render children when no error occurs', () => { + render( + + + , + ) + + expect(screen.getByText('Child content rendered')).toBeInTheDocument() + }) + + it('should render default fallback with title and message when child throws', async () => { + render( + + + , + ) + + expect(await screen.findByText('Something went wrong')).toBeInTheDocument() + expect(screen.getByText('An unexpected error occurred while rendering this component.')).toBeInTheDocument() + }) + + it('should render custom title, message, and className in fallback', async () => { + render( + + + , + ) + + expect(await screen.findByText('Custom crash title')).toBeInTheDocument() + expect(screen.getByText('Custom recovery message')).toBeInTheDocument() + + const fallbackRoot = document.querySelector('.custom-boundary') + expect(fallbackRoot).toBeInTheDocument() + expect(fallbackRoot).not.toHaveClass('min-h-[200px]') + }) + }) + + // Validate explicit fallback prop variants. + describe('Fallback props', () => { + it('should render node fallback when fallback prop is a React node', async () => { + render( + Node fallback content
}> + + , + ) + + expect(await screen.findByText('Node fallback content')).toBeInTheDocument() + }) + + it('should render function fallback with error message when fallback prop is a function', async () => { + render( + ( +
+ Function fallback: + {' '} + {error.message} +
+ )} + > + +
, + ) + + expect(await screen.findByText('Function fallback: function fallback boom')).toBeInTheDocument() + }) + }) + + // Validate error reporting and details panel behavior. + describe('Error reporting', () => { + it('should call onError with error and errorInfo when child throws', async () => { + const onError = vi.fn() + + render( + + + , + ) + + await screen.findByText('Something went wrong') + + expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'render boom' }), + expect.objectContaining({ componentStack: expect.any(String) }), + ) + }) + + it('should render details block when showDetails is true', async () => { + render( + + + , + ) + + expect(await screen.findByText('Error Details (Development Only)')).toBeInTheDocument() + expect(screen.getByText('Error:')).toBeInTheDocument() + expect(screen.getByText(/details boom/i)).toBeInTheDocument() + }) + + it('should log boundary errors in development mode', async () => { + mockConfig.isDev = true + + render( + + + , + ) + + await screen.findByText('Something went wrong') + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'ErrorBoundary caught an error:', + expect.objectContaining({ message: 'dev boom' }), + ) + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error Info:', + expect.objectContaining({ componentStack: expect.any(String) }), + ) + }) + }) + + // Validate recovery controls and automatic reset triggers. + describe('Recovery', () => { + it('should hide recovery actions when enableRecovery is false', async () => { + render( + + + , + ) + + await screen.findByText('Something went wrong') + + expect(screen.queryByRole('button', { name: 'Try Again' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Reload Page' })).not.toBeInTheDocument() + }) + + it('should reset and render children when Try Again is clicked', async () => { + const onReset = vi.fn() + + const RecoveryHarness = () => { + const [shouldThrow, setShouldThrow] = React.useState(true) + return ( + { + onReset() + setShouldThrow(false) + }} + > + + + ) + } + + render() + fireEvent.click(await screen.findByRole('button', { name: 'Try Again' })) + + await screen.findByText('Child content rendered') + expect(onReset).toHaveBeenCalledTimes(1) + }) + + it('should reset after resetKeys change when boundary is in error state', async () => { + const ResetKeysHarness = () => { + const [shouldThrow, setShouldThrow] = React.useState(true) + const [boundaryKey, setBoundaryKey] = React.useState(0) + + return ( + <> + + + + + + ) + } + + render() + await screen.findByText('Something went wrong') + + fireEvent.click(screen.getByRole('button', { name: 'Recover with keys' })) + + await waitFor(() => { + expect(screen.getByText('Child content rendered')).toBeInTheDocument() + }) + }) + + it('should reset after children change when resetOnPropsChange is true', async () => { + const ResetOnPropsHarness = () => { + const [shouldThrow, setShouldThrow] = React.useState(true) + const [childLabel, setChildLabel] = React.useState('first child') + + return ( + <> + + + {shouldThrow ? :
{childLabel}
} +
+ + ) + } + + render() + await screen.findByText('Something went wrong') + + fireEvent.click(screen.getByRole('button', { name: 'Replace children' })) + + await waitFor(() => { + expect(screen.getByText('second child')).toBeInTheDocument() + }) + }) + }) +}) + +describe('ErrorBoundary utility exports', () => { + beforeEach(() => { + vi.clearAllMocks() + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + consoleErrorSpy.mockRestore() + }) + + // Validate imperative error hook behavior. + describe('useErrorHandler', () => { + it('should trigger error boundary fallback when setError is called', async () => { + const HookConsumer = () => { + const setError = useErrorHandler() + return ( + + ) + } + + render( + Hook fallback shown
}> + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Trigger hook error' })) + + expect(await screen.findByText('Hook fallback shown')).toBeInTheDocument() + }) + }) + + // Validate async error bridge hook behavior. + describe('useAsyncError', () => { + it('should trigger error boundary fallback when async error callback is called', async () => { + const AsyncHookConsumer = () => { + const throwAsyncError = useAsyncError() + return ( + + ) + } + + render( + Async fallback shown
}> + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Trigger async hook error' })) + + expect(await screen.findByText('Async fallback shown')).toBeInTheDocument() + }) + }) + + // Validate HOC wrapper behavior and metadata. + describe('withErrorBoundary', () => { + it('should wrap component and render custom title when wrapped component throws', async () => { + type WrappedProps = { + shouldThrow: boolean + } + + const WrappedTarget = ({ shouldThrow }: WrappedProps) => { + if (shouldThrow) + throw new Error('wrapped boom') + return
Wrapped content
+ } + + const Wrapped = withErrorBoundary(WrappedTarget, { + customTitle: 'Wrapped boundary title', + }) + + render() + + expect(await screen.findByText('Wrapped boundary title')).toBeInTheDocument() + }) + + it('should set displayName using wrapped component name', () => { + const NamedComponent = () =>
named content
+ const Wrapped = withErrorBoundary(NamedComponent) + + expect(Wrapped.displayName).toBe('withErrorBoundary(NamedComponent)') + }) + }) + + // Validate simple fallback helper component. + describe('ErrorFallback', () => { + it('should render message and call reset action when button is clicked', () => { + const resetErrorBoundaryAction = vi.fn() + + render( + , + ) + + expect(screen.getByText('Oops! Something went wrong')).toBeInTheDocument() + expect(screen.getByText('fallback helper message')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + + expect(resetErrorBoundaryAction).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/base/float-right-container/index.spec.tsx b/web/app/components/base/float-right-container/index.spec.tsx new file mode 100644 index 0000000000..51713cc527 --- /dev/null +++ b/web/app/components/base/float-right-container/index.spec.tsx @@ -0,0 +1,140 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import FloatRightContainer from './index' + +describe('FloatRightContainer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering behavior across mobile and desktop branches. + describe('Rendering', () => { + it('should render content in drawer when isMobile is true and isOpen is true', async () => { + render( + +
Mobile content
+
, + ) + + expect(await screen.findByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Mobile panel')).toBeInTheDocument() + expect(screen.getByText('Mobile content')).toBeInTheDocument() + }) + + it('should not render content when isMobile is true and isOpen is false', () => { + render( + +
Closed mobile content
+
, + ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByText('Closed mobile content')).not.toBeInTheDocument() + }) + + it('should render content inline when isMobile is false and isOpen is true', () => { + render( + +
Desktop inline content
+
, + ) + + expect(screen.getByText('Desktop inline content')).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByText('Desktop drawer title should not render')).not.toBeInTheDocument() + }) + + it('should render nothing when isMobile is false and isOpen is false', () => { + const { container } = render( + +
Hidden desktop content
+
, + ) + + expect(container).toBeEmptyDOMElement() + expect(screen.queryByText('Hidden desktop content')).not.toBeInTheDocument() + }) + }) + + // Validate that drawer-specific props are passed through in mobile mode. + describe('Props forwarding', () => { + it('should call onClose when close icon is clicked in mobile drawer mode', async () => { + const onClose = vi.fn() + render( + +
Closable mobile content
+
, + ) + + await screen.findByRole('dialog') + const closeIcon = screen.getByTestId('close-icon') + expect(closeIcon).toBeInTheDocument() + + fireEvent.click(closeIcon!) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should apply drawer className props in mobile drawer mode', async () => { + render( + +
Class forwarding content
+
, + ) + + const dialog = await screen.findByRole('dialog') + expect(dialog).toHaveClass('custom-dialog-class') + + const panel = document.querySelector('.custom-panel-class') + expect(panel).toBeInTheDocument() + }) + }) + + // Edge-case behavior with optional children. + describe('Edge cases', () => { + it('should render without crashing when children is undefined in mobile mode', async () => { + render( + , + ) + + expect(await screen.findByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Empty mobile panel')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/pagination/hook.spec.ts b/web/app/components/base/pagination/hook.spec.ts new file mode 100644 index 0000000000..284032df47 --- /dev/null +++ b/web/app/components/base/pagination/hook.spec.ts @@ -0,0 +1,155 @@ +import { renderHook } from '@testing-library/react' +import usePagination from './hook' + +const defaultProps = { + currentPage: 0, + setCurrentPage: vi.fn(), + totalPages: 10, + edgePageCount: 2, + middlePagesSiblingCount: 1, + truncableText: '...', + truncableClassName: 'truncable', +} + +describe('usePagination', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('pages', () => { + it('should generate correct pages array', () => { + const { result } = renderHook(() => usePagination(defaultProps)) + expect(result.current.pages).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + }) + + it('should generate empty pages for totalPages 0', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 0 })) + expect(result.current.pages).toEqual([]) + }) + + it('should generate single page', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 1 })) + expect(result.current.pages).toEqual([1]) + }) + }) + + describe('hasPreviousPage / hasNextPage', () => { + it('should have no previous page on first page', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 })) + expect(result.current.hasPreviousPage).toBe(false) + }) + + it('should have previous page when not on first page', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 3 })) + expect(result.current.hasPreviousPage).toBe(true) + }) + + it('should have next page when not on last page', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 })) + expect(result.current.hasNextPage).toBe(true) + }) + + it('should have no next page on last page', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 10 })) + expect(result.current.hasNextPage).toBe(false) + }) + }) + + describe('middlePages', () => { + it('should return correct middle pages when at start', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 })) + // isReachedToFirst: currentPage(0) <= middlePagesSiblingCount(1), so slice(0, 3) + expect(result.current.middlePages).toEqual([1, 2, 3]) + }) + + it('should return correct middle pages when in the middle', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + // Not at start or end, slice(5-1, 5+1+1) = slice(4, 7) = [5, 6, 7] + expect(result.current.middlePages).toEqual([5, 6, 7]) + }) + + it('should return correct middle pages when at end', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 })) + // isReachedToLast: currentPage(9) + middlePagesSiblingCount(1) >= totalPages(10), so slice(-3) + expect(result.current.middlePages).toEqual([8, 9, 10]) + }) + }) + + describe('previousPages and nextPages', () => { + it('should return empty previousPages when at start', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 })) + expect(result.current.previousPages).toEqual([]) + }) + + it('should return previousPages when in the middle', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + // edgePageCount=2, so first 2 pages filtered by not in middlePages + expect(result.current.previousPages).toEqual([1, 2]) + }) + + it('should return empty nextPages when at end', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 })) + expect(result.current.nextPages).toEqual([]) + }) + + it('should return nextPages when in the middle', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + // Last 2 pages: [9, 10], filtered by not in middlePages [5,6,7] + expect(result.current.nextPages).toEqual([9, 10]) + }) + }) + + describe('truncation', () => { + it('should be previous truncable when middle pages are far from edge', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + // previousPages=[1,2], middlePages=[5,6,7], 5 > 2+1 = true + expect(result.current.isPreviousTruncable).toBe(true) + }) + + it('should not be previous truncable when pages are contiguous', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 2 })) + expect(result.current.isPreviousTruncable).toBe(false) + }) + + it('should be next truncable when middle pages are far from end edge', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + // middlePages=[5,6,7], nextPages=[9,10], 7+1 < 9 = true + expect(result.current.isNextTruncable).toBe(true) + }) + + it('should not be next truncable when pages are contiguous', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 7 })) + expect(result.current.isNextTruncable).toBe(false) + }) + }) + + describe('passthrough values', () => { + it('should pass through currentPage', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 })) + expect(result.current.currentPage).toBe(5) + }) + + it('should pass through setCurrentPage', () => { + const setCurrentPage = vi.fn() + const { result } = renderHook(() => usePagination({ ...defaultProps, setCurrentPage })) + result.current.setCurrentPage(3) + expect(setCurrentPage).toHaveBeenCalledWith(3) + }) + + it('should pass through truncableText', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, truncableText: '…' })) + expect(result.current.truncableText).toBe('…') + }) + + it('should pass through truncableClassName', () => { + const { result } = renderHook(() => usePagination({ ...defaultProps, truncableClassName: 'custom-trunc' })) + expect(result.current.truncableClassName).toBe('custom-trunc') + }) + + it('should use default truncableText', () => { + const { currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount } = defaultProps + const { result } = renderHook(() => usePagination({ currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount })) + expect(result.current.truncableText).toBe('...') + }) + }) +}) diff --git a/web/app/components/base/pagination/index.spec.tsx b/web/app/components/base/pagination/index.spec.tsx new file mode 100644 index 0000000000..ef924c290b --- /dev/null +++ b/web/app/components/base/pagination/index.spec.tsx @@ -0,0 +1,242 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import CustomizedPagination from './index' + +describe('CustomizedPagination', () => { + const defaultProps = { + current: 0, + onChange: vi.fn(), + total: 100, + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) + + it('should display current page and total pages', () => { + render() + // current + 1 = 1, totalPages = 10 + // The page info display shows "1 / 10" and page buttons also show numbers + expect(screen.getByText('/')).toBeInTheDocument() + expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1) + }) + + it('should render prev and next buttons', () => { + render() + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(2) + }) + + it('should render page number buttons', () => { + render() + // 5 pages total, should see page numbers + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('should display slash separator between current page and total', () => { + render() + expect(screen.getByText('/')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('my-custom') + }) + + it('should default limit to 10', () => { + render() + // totalPages = 100 / 10 = 10, displayed in the page info area + expect(screen.getAllByText('10').length).toBeGreaterThanOrEqual(1) + }) + + it('should calculate total pages based on custom limit', () => { + render() + // totalPages = 100 / 25 = 4, displayed in the page info area + expect(screen.getAllByText('4').length).toBeGreaterThanOrEqual(1) + }) + + it('should disable prev button on first page', () => { + render() + const buttons = screen.getAllByRole('button') + // First button is prev + expect(buttons[0]).toBeDisabled() + }) + + it('should disable next button on last page', () => { + render() + const buttons = screen.getAllByRole('button') + // Last button is next + expect(buttons[buttons.length - 1]).toBeDisabled() + }) + + it('should not render limit selector when onLimitChange is not provided', () => { + render() + expect(screen.queryByText(/common\.pagination\.perPage/i)).not.toBeInTheDocument() + }) + + it('should render limit selector when onLimitChange is provided', () => { + const onLimitChange = vi.fn() + render() + // Should show limit options 10, 25, 50 + expect(screen.getByText('25')).toBeInTheDocument() + expect(screen.getByText('50')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onChange when next button is clicked', () => { + const onChange = vi.fn() + render() + const buttons = screen.getAllByRole('button') + const nextButton = buttons[buttons.length - 1] + fireEvent.click(nextButton) + expect(onChange).toHaveBeenCalledWith(1) + }) + + it('should call onChange when prev button is clicked', () => { + const onChange = vi.fn() + render() + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + expect(onChange).toHaveBeenCalledWith(4) + }) + + it('should show input when page display is clicked', () => { + render() + // Click the current page display (the div containing "1 / 10") + fireEvent.click(screen.getByText('/')) + // Input should appear + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should navigate to entered page on Enter key', () => { + vi.useFakeTimers() + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '5' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(onChange).toHaveBeenCalledWith(4) // 0-indexed + }) + + it('should cancel input on Escape key', () => { + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.keyDown(input, { key: 'Escape' }) + // Input should be hidden and page display should return + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.getByText('/')).toBeInTheDocument() + }) + + it('should confirm input on blur', () => { + vi.useFakeTimers() + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '3' } }) + fireEvent.blur(input) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(onChange).toHaveBeenCalledWith(2) // 0-indexed + }) + + it('should clamp page to max when input exceeds total pages', () => { + vi.useFakeTimers() + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '999' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(onChange).toHaveBeenCalledWith(9) // last page (0-indexed) + }) + + it('should clamp page to min when input is less than 1', () => { + vi.useFakeTimers() + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '0' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + act(() => { + vi.advanceTimersByTime(500) + }) + expect(onChange).toHaveBeenCalledWith(0) + }) + + it('should ignore non-numeric input', () => { + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'abc' } }) + expect(input).toHaveValue('') + }) + + it('should call onLimitChange when limit option is clicked', () => { + const onLimitChange = vi.fn() + render() + fireEvent.click(screen.getByText('25')) + expect(onLimitChange).toHaveBeenCalledWith(25) + }) + + it('should call onLimitChange with 50 when 50 option is clicked', () => { + const onLimitChange = vi.fn() + render() + fireEvent.click(screen.getByText('50')) + expect(onLimitChange).toHaveBeenCalledWith(50) + }) + + it('should call onChange when a page button is clicked', () => { + const onChange = vi.fn() + render() + fireEvent.click(screen.getByText('3')) + expect(onChange).toHaveBeenCalledWith(2) // 0-indexed + }) + }) + + describe('Edge Cases', () => { + it('should handle total of 0', () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) + + it('should handle single page', () => { + render() + // totalPages = 1, both buttons should be disabled + const buttons = screen.getAllByRole('button') + expect(buttons[0]).toBeDisabled() + expect(buttons[buttons.length - 1]).toBeDisabled() + }) + + it('should restore input value when blurred with empty value', () => { + render() + fireEvent.click(screen.getByText('/')) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '' } }) + fireEvent.blur(input) + // Should close input without calling onChange, restoring to current + 1 + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/pagination/pagination.spec.tsx b/web/app/components/base/pagination/pagination.spec.tsx new file mode 100644 index 0000000000..2374f8257a --- /dev/null +++ b/web/app/components/base/pagination/pagination.spec.tsx @@ -0,0 +1,376 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { Pagination } from './pagination' + +// Helper to render Pagination with common defaults +function renderPagination({ + currentPage = 0, + totalPages = 10, + setCurrentPage = vi.fn(), + edgePageCount = 2, + middlePagesSiblingCount = 1, + truncableText = '...', + truncableClassName = 'truncable', + children, +}: { + currentPage?: number + totalPages?: number + setCurrentPage?: (page: number) => void + edgePageCount?: number + middlePagesSiblingCount?: number + truncableText?: string + truncableClassName?: string + children?: React.ReactNode +} = {}) { + return render( + + {children} + , + ) +} + +describe('Pagination', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = renderPagination() + expect(container).toBeInTheDocument() + }) + + it('should render children', () => { + renderPagination({ children: child content }) + expect(screen.getByText(/child content/i)).toBeInTheDocument() + }) + + it('should apply className to wrapper div', () => { + const { container } = render( + + test + , + ) + expect(container.firstChild).toHaveClass('my-pagination') + }) + + it('should apply data-testid when provided', () => { + render( + + test + , + ) + expect(screen.getByTestId('my-pagination')).toBeInTheDocument() + }) + }) + + describe('PrevButton', () => { + it('should render prev button', () => { + renderPagination({ + currentPage: 3, + children: Prev, + }) + expect(screen.getByText(/prev/i)).toBeInTheDocument() + }) + + it('should call setCurrentPage with previous page when clicked', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 3, + setCurrentPage, + children: Prev, + }) + fireEvent.click(screen.getByText(/prev/i)) + expect(setCurrentPage).toHaveBeenCalledWith(2) + }) + + it('should not navigate below page 0', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + setCurrentPage, + children: Prev, + }) + fireEvent.click(screen.getByText(/prev/i)) + expect(setCurrentPage).not.toHaveBeenCalled() + }) + + it('should be disabled on first page', () => { + renderPagination({ + currentPage: 0, + children: Prev, + }) + expect(screen.getByText(/prev/i).closest('button')).toBeDisabled() + }) + + it('should navigate on Enter key press', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 3, + setCurrentPage, + children: Prev, + }) + fireEvent.keyPress(screen.getByText(/prev/i), { key: 'Enter', charCode: 13 }) + expect(setCurrentPage).toHaveBeenCalledWith(2) + }) + + it('should not navigate on Enter when disabled', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + setCurrentPage, + children: Prev, + }) + fireEvent.keyPress(screen.getByText(/prev/i), { key: 'Enter', charCode: 13 }) + expect(setCurrentPage).not.toHaveBeenCalled() + }) + + it('should render with custom as element', () => { + renderPagination({ + currentPage: 3, + children: }>Prev, + }) + expect(screen.getByText(/prev/i)).toBeInTheDocument() + }) + + it('should apply dataTestId', () => { + renderPagination({ + currentPage: 3, + children: Prev, + }) + expect(screen.getByTestId('prev-btn')).toBeInTheDocument() + }) + }) + + describe('NextButton', () => { + it('should render next button', () => { + renderPagination({ + currentPage: 0, + children: Next, + }) + expect(screen.getByText(/next/i)).toBeInTheDocument() + }) + + it('should call setCurrentPage with next page when clicked', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + totalPages: 10, + setCurrentPage, + children: Next, + }) + fireEvent.click(screen.getByText(/next/i)) + expect(setCurrentPage).toHaveBeenCalledWith(1) + }) + + it('should not navigate beyond last page', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 9, + totalPages: 10, + setCurrentPage, + children: Next, + }) + fireEvent.click(screen.getByText(/next/i)) + expect(setCurrentPage).not.toHaveBeenCalled() + }) + + it('should be disabled on last page', () => { + renderPagination({ + currentPage: 9, + totalPages: 10, + children: Next, + }) + expect(screen.getByText(/next/i).closest('button')).toBeDisabled() + }) + + it('should navigate on Enter key press', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + totalPages: 10, + setCurrentPage, + children: Next, + }) + fireEvent.keyPress(screen.getByText(/next/i), { key: 'Enter', charCode: 13 }) + expect(setCurrentPage).toHaveBeenCalledWith(1) + }) + + it('should not navigate on Enter when disabled', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 9, + totalPages: 10, + setCurrentPage, + children: Next, + }) + fireEvent.keyPress(screen.getByText(/next/i), { key: 'Enter', charCode: 13 }) + expect(setCurrentPage).not.toHaveBeenCalled() + }) + + it('should apply dataTestId', () => { + renderPagination({ + currentPage: 0, + children: Next, + }) + expect(screen.getByTestId('next-btn')).toBeInTheDocument() + }) + }) + + describe('PageButton', () => { + it('should render page number buttons', () => { + renderPagination({ + currentPage: 0, + totalPages: 5, + children: ( + + ), + }) + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('should apply activeClassName to current page', () => { + renderPagination({ + currentPage: 2, + totalPages: 5, + children: ( + + ), + }) + // current page is 2, so page 3 (1-indexed) should be active + expect(screen.getByText('3').closest('a')).toHaveClass('active') + }) + + it('should apply inactiveClassName to non-current pages', () => { + renderPagination({ + currentPage: 2, + totalPages: 5, + children: ( + + ), + }) + expect(screen.getByText('1').closest('a')).toHaveClass('inactive') + }) + + it('should call setCurrentPage when a page button is clicked', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + totalPages: 5, + setCurrentPage, + children: ( + + ), + }) + fireEvent.click(screen.getByText('3')) + expect(setCurrentPage).toHaveBeenCalledWith(2) // 0-indexed + }) + + it('should navigate on Enter key press on a page button', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + totalPages: 5, + setCurrentPage, + children: ( + + ), + }) + fireEvent.keyPress(screen.getByText('4'), { key: 'Enter', charCode: 13 }) + expect(setCurrentPage).toHaveBeenCalledWith(3) // 0-indexed + }) + + it('should render truncable text when pages are truncated', () => { + renderPagination({ + currentPage: 5, + totalPages: 20, + edgePageCount: 2, + middlePagesSiblingCount: 1, + truncableText: '...', + children: ( + + ), + }) + // With 20 pages and current at 5, there should be truncation + expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle single page', () => { + const setCurrentPage = vi.fn() + renderPagination({ + currentPage: 0, + totalPages: 1, + setCurrentPage, + children: ( + <> + Prev + + Next + + ), + }) + expect(screen.getByText(/prev/i).closest('button')).toBeDisabled() + expect(screen.getByText(/next/i).closest('button')).toBeDisabled() + expect(screen.getByText('1')).toBeInTheDocument() + }) + + it('should handle zero total pages', () => { + const { container } = renderPagination({ + currentPage: 0, + totalPages: 0, + children: ( + + ), + }) + expect(container).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/theme-selector.spec.tsx b/web/app/components/base/theme-selector.spec.tsx new file mode 100644 index 0000000000..8cd0028acf --- /dev/null +++ b/web/app/components/base/theme-selector.spec.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import ThemeSelector from './theme-selector' + +// Mock next-themes with controllable state +let mockTheme = 'system' +const mockSetTheme = vi.fn() +vi.mock('next-themes', () => ({ + useTheme: () => ({ + theme: mockTheme, + setTheme: mockSetTheme, + }), +})) + +describe('ThemeSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = 'system' + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) + + it('should render the trigger button', () => { + render() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should not show dropdown content when closed', () => { + render() + expect(screen.queryByText(/common\.theme\.light/i)).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should show all theme options when dropdown is opened', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByText(/light/i)).toBeInTheDocument() + expect(screen.getByText(/dark/i)).toBeInTheDocument() + expect(screen.getByText(/auto/i)).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call setTheme with light when light option is clicked', () => { + render() + fireEvent.click(screen.getByRole('button')) + const lightButton = screen.getByText(/light/i).closest('button')! + fireEvent.click(lightButton) + expect(mockSetTheme).toHaveBeenCalledWith('light') + }) + + it('should call setTheme with dark when dark option is clicked', () => { + render() + fireEvent.click(screen.getByRole('button')) + const darkButton = screen.getByText(/dark/i).closest('button')! + fireEvent.click(darkButton) + expect(mockSetTheme).toHaveBeenCalledWith('dark') + }) + + it('should call setTheme with system when system option is clicked', () => { + render() + fireEvent.click(screen.getByRole('button')) + const systemButton = screen.getByText(/auto/i).closest('button')! + fireEvent.click(systemButton) + expect(mockSetTheme).toHaveBeenCalledWith('system') + }) + }) + + describe('Theme-specific rendering', () => { + it('should show checkmark for the currently active light theme', () => { + mockTheme = 'light' + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByTestId('light-icon')).toBeInTheDocument() + }) + + it('should show checkmark for the currently active dark theme', () => { + mockTheme = 'dark' + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByTestId('dark-icon')).toBeInTheDocument() + }) + + it('should show checkmark for the currently active system theme', () => { + mockTheme = 'system' + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByTestId('system-icon')).toBeInTheDocument() + }) + + it('should not show checkmark on non-active themes', () => { + mockTheme = 'light' + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.queryByTestId('dark-icon')).not.toBeInTheDocument() + expect(screen.queryByTestId('system-icon')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/theme-selector.tsx b/web/app/components/base/theme-selector.tsx index 8869407057..49fdfb4390 100644 --- a/web/app/components/base/theme-selector.tsx +++ b/web/app/components/base/theme-selector.tsx @@ -1,11 +1,5 @@ 'use client' -import { - RiCheckLine, - RiComputerLine, - RiMoonLine, - RiSunLine, -} from '@remixicon/react' import { useTheme } from 'next-themes' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -30,9 +24,9 @@ export default function ThemeSelector() { const getCurrentIcon = () => { switch (theme) { - case 'light': return - case 'dark': return - default: return + case 'light': return + case 'dark': return + default: return } } @@ -59,13 +53,13 @@ export default function ThemeSelector() { className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover" onClick={() => handleThemeChange('light')} > - +
{t('theme.light', { ns: 'common' })}
{theme === 'light' && (
- +
)} @@ -74,13 +68,13 @@ export default function ThemeSelector() { className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover" onClick={() => handleThemeChange('dark')} > - +
{t('theme.dark', { ns: 'common' })}
{theme === 'dark' && (
- +
)} @@ -89,13 +83,13 @@ export default function ThemeSelector() { className="flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover" onClick={() => handleThemeChange('system')} > - +
{t('theme.auto', { ns: 'common' })}
{theme === 'system' && (
- +
)} diff --git a/web/app/components/base/theme-switcher.spec.tsx b/web/app/components/base/theme-switcher.spec.tsx new file mode 100644 index 0000000000..e19fbd3835 --- /dev/null +++ b/web/app/components/base/theme-switcher.spec.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import ThemeSwitcher from './theme-switcher' + +let mockTheme = 'system' +const mockSetTheme = vi.fn() +vi.mock('next-themes', () => ({ + useTheme: () => ({ + theme: mockTheme, + setTheme: mockSetTheme, + }), +})) + +describe('ThemeSwitcher', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = 'system' + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render three theme option buttons', () => { + render() + expect(screen.getByTestId('system-theme-container')).toBeInTheDocument() + expect(screen.getByTestId('light-theme-container')).toBeInTheDocument() + expect(screen.getByTestId('dark-theme-container')).toBeInTheDocument() + }) + + it('should render two dividers between options', () => { + render() + const dividers = screen.getAllByTestId('divider') + expect(dividers).toHaveLength(2) + }) + }) + + describe('User Interactions', () => { + it('should call setTheme with system when system option is clicked', () => { + render() + fireEvent.click(screen.getByTestId('system-theme-container')) // system is first + expect(mockSetTheme).toHaveBeenCalledWith('system') + }) + + it('should call setTheme with light when light option is clicked', () => { + render() + fireEvent.click(screen.getByTestId('light-theme-container')) // light is second + expect(mockSetTheme).toHaveBeenCalledWith('light') + }) + + it('should call setTheme with dark when dark option is clicked', () => { + render() + fireEvent.click(screen.getByTestId('dark-theme-container')) // dark is third + expect(mockSetTheme).toHaveBeenCalledWith('dark') + }) + }) + + describe('Theme-specific rendering', () => { + it('should highlight system option when theme is system', () => { + mockTheme = 'system' + render() + expect(screen.getByTestId('system-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('light-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('dark-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + }) + + it('should highlight light option when theme is light', () => { + mockTheme = 'light' + render() + expect(screen.getByTestId('light-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('system-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('dark-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + }) + + it('should highlight dark option when theme is dark', () => { + mockTheme = 'dark' + render() + expect(screen.getByTestId('dark-theme-container')).toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('system-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + expect(screen.getByTestId('light-theme-container')).not.toHaveClass('bg-components-segmented-control-item-active-bg') + }) + + it('should show divider between system and light when dark is active', () => { + mockTheme = 'dark' + render() + const dividers = screen.getAllByTestId('divider') + expect(dividers[0]).toHaveClass('bg-divider-regular') + }) + + it('should show divider between light and dark when system is active', () => { + mockTheme = 'system' + render() + const dividers = screen.getAllByTestId('divider') + expect(dividers[1]).toHaveClass('bg-divider-regular') + }) + + it('should have transparent dividers when neither adjacent theme is active', () => { + mockTheme = 'light' + render() + const dividers = screen.getAllByTestId('divider') + expect(dividers[0]).not.toHaveClass('bg-divider-regular') + expect(dividers[1]).not.toHaveClass('bg-divider-regular') + }) + }) +}) diff --git a/web/app/components/base/theme-switcher.tsx b/web/app/components/base/theme-switcher.tsx index d223ff738e..86e24a443c 100644 --- a/web/app/components/base/theme-switcher.tsx +++ b/web/app/components/base/theme-switcher.tsx @@ -1,9 +1,4 @@ 'use client' -import { - RiComputerLine, - RiMoonLine, - RiSunLine, -} from '@remixicon/react' import { useTheme } from 'next-themes' import { cn } from '@/utils/classnames' @@ -24,33 +19,36 @@ export default function ThemeSwitcher() { theme === 'system' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only', )} onClick={() => handleThemeChange('system')} + data-testid="system-theme-container" >
- +
-
+
handleThemeChange('light')} + data-testid="light-theme-container" >
- +
-
+
handleThemeChange('dark')} + data-testid="dark-theme-container" >
- +
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 9f0104333f..b5c02271b9 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1266,14 +1266,6 @@ "count": 2 } }, - "app/components/base/alert.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 1 - }, - "tailwindcss/no-unnecessary-whitespace": { - "count": 1 - } - }, "app/components/base/amplitude/AmplitudeProvider.tsx": { "react-refresh/only-export-components": { "count": 1 From a040b9428d1df1ed78c7d21ab24658436fdec442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E5=81=9A=E4=BA=86=E7=9D=A1=E5=A4=A7=E8=A7=89?= <64798754+stakeswky@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:31:12 +0800 Subject: [PATCH 002/129] fix: correct type annotations in Langfuse trace entities to match SDK (#32498) Co-authored-by: User --- .../langfuse_trace/entities/langfuse_trace_entity.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/api/core/ops/langfuse_trace/entities/langfuse_trace_entity.py b/api/core/ops/langfuse_trace/entities/langfuse_trace_entity.py index 312c7d3676..76755bf769 100644 --- a/api/core/ops/langfuse_trace/entities/langfuse_trace_entity.py +++ b/api/core/ops/langfuse_trace/entities/langfuse_trace_entity.py @@ -129,11 +129,11 @@ class LangfuseSpan(BaseModel): default=None, description="The id of the user that triggered the execution. Used to provide user-level analytics.", ) - start_time: datetime | str | None = Field( + start_time: datetime | None = Field( default_factory=datetime.now, description="The time at which the span started, defaults to the current time.", ) - end_time: datetime | str | None = Field( + end_time: datetime | None = Field( default=None, description="The time at which the span ended. Automatically set by span.end().", ) @@ -146,7 +146,7 @@ class LangfuseSpan(BaseModel): description="Additional metadata of the span. Can be any JSON object. Metadata is merged when being updated " "via the API.", ) - level: str | None = Field( + level: LevelEnum | None = Field( default=None, description="The level of the span. Can be DEBUG, DEFAULT, WARNING or ERROR. Used for sorting/filtering of " "traces with elevated error levels and for highlighting in the UI.", @@ -222,16 +222,16 @@ class LangfuseGeneration(BaseModel): default=None, description="Identifier of the generation. Useful for sorting/filtering in the UI.", ) - start_time: datetime | str | None = Field( + start_time: datetime | None = Field( default_factory=datetime.now, description="The time at which the generation started, defaults to the current time.", ) - completion_start_time: datetime | str | None = Field( + completion_start_time: datetime | None = Field( default=None, description="The time at which the completion started (streaming). Set it to get latency analytics broken " "down into time until completion started and completion duration.", ) - end_time: datetime | str | None = Field( + end_time: datetime | None = Field( default=None, description="The time at which the generation ended. Automatically set by generation.end().", ) From 9819f7d69c660c4870290c230167782c56edeb65 Mon Sep 17 00:00:00 2001 From: Saumya Talwani <68903741+saumyatalwani@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:46:06 +0530 Subject: [PATCH 003/129] test: add tests for file-upload components (#32373) Co-authored-by: sahil --- .../base/file-uploader/audio-preview.spec.tsx | 69 ++ .../base/file-uploader/audio-preview.tsx | 3 +- .../base/file-uploader/constants.spec.ts | 71 ++ .../file-from-link-or-local/index.spec.tsx | 173 ++++ .../file-uploader/file-image-render.spec.tsx | 67 ++ .../base/file-uploader/file-input.spec.tsx | 179 ++++ .../file-uploader/file-list-in-log.spec.tsx | 142 +++ .../file-uploader/file-type-icon.spec.tsx | 85 ++ .../file-item.spec.tsx | 407 ++++++++ .../index.spec.tsx | 207 +++++ .../file-image-item.spec.tsx | 246 +++++ .../file-item.spec.tsx | 337 +++++++ .../file-uploader-in-chat-input/file-item.tsx | 20 +- .../file-list.spec.tsx | 137 +++ .../index.spec.tsx | 101 ++ .../base/file-uploader/hooks.spec.ts | 867 ++++++++++++++++++ .../file-uploader/pdf-highlighter-adapter.tsx | 7 + .../base/file-uploader/pdf-preview.spec.tsx | 142 +++ .../base/file-uploader/pdf-preview.tsx | 3 +- .../base/file-uploader/store.spec.tsx | 168 ++++ .../base/file-uploader/utils.spec.ts | 292 ++++-- .../base/file-uploader/video-preview.spec.tsx | 69 ++ .../base/file-uploader/video-preview.tsx | 3 +- .../base/image-uploader/image-preview.tsx | 4 +- web/eslint-suppressions.json | 8 - 25 files changed, 3680 insertions(+), 127 deletions(-) create mode 100644 web/app/components/base/file-uploader/audio-preview.spec.tsx create mode 100644 web/app/components/base/file-uploader/constants.spec.ts create mode 100644 web/app/components/base/file-uploader/file-from-link-or-local/index.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-image-render.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-input.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-list-in-log.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-type-icon.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-attachment/index.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-chat-input/file-image-item.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.spec.tsx create mode 100644 web/app/components/base/file-uploader/file-uploader-in-chat-input/index.spec.tsx create mode 100644 web/app/components/base/file-uploader/hooks.spec.ts create mode 100644 web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx create mode 100644 web/app/components/base/file-uploader/pdf-preview.spec.tsx create mode 100644 web/app/components/base/file-uploader/store.spec.tsx create mode 100644 web/app/components/base/file-uploader/video-preview.spec.tsx diff --git a/web/app/components/base/file-uploader/audio-preview.spec.tsx b/web/app/components/base/file-uploader/audio-preview.spec.tsx new file mode 100644 index 0000000000..a2034b202a --- /dev/null +++ b/web/app/components/base/file-uploader/audio-preview.spec.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import AudioPreview from './audio-preview' + +describe('AudioPreview', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render audio element with correct source', () => { + render() + + const audio = document.querySelector('audio') + expect(audio).toBeInTheDocument() + expect(audio).toHaveAttribute('title', 'Test Audio') + }) + + it('should render source element with correct src and type', () => { + render() + + const source = document.querySelector('source') + expect(source).toHaveAttribute('src', 'https://example.com/audio.mp3') + expect(source).toHaveAttribute('type', 'audio/mpeg') + }) + + it('should render close button with icon', () => { + render() + + const closeIcon = screen.getByTestId('close-btn') + expect(closeIcon).toBeInTheDocument() + }) + + it('should call onCancel when close button is clicked', () => { + const onCancel = vi.fn() + render() + + const closeIcon = screen.getByTestId('close-btn') + fireEvent.click(closeIcon.parentElement!) + + expect(onCancel).toHaveBeenCalled() + }) + + it('should stop propagation when backdrop is clicked', () => { + const { baseElement } = render() + + const backdrop = baseElement.querySelector('[tabindex="-1"]') + const event = new MouseEvent('click', { bubbles: true }) + const stopPropagation = vi.spyOn(event, 'stopPropagation') + backdrop!.dispatchEvent(event) + + expect(stopPropagation).toHaveBeenCalled() + }) + + it('should call onCancel when Escape key is pressed', () => { + const onCancel = vi.fn() + + render() + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + expect(onCancel).toHaveBeenCalled() + }) + + it('should render in a portal attached to document.body', () => { + render() + + const audio = document.querySelector('audio') + expect(audio?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body) + }) +}) diff --git a/web/app/components/base/file-uploader/audio-preview.tsx b/web/app/components/base/file-uploader/audio-preview.tsx index e8be22fc9f..53535359e6 100644 --- a/web/app/components/base/file-uploader/audio-preview.tsx +++ b/web/app/components/base/file-uploader/audio-preview.tsx @@ -1,5 +1,4 @@ import type { FC } from 'react' -import { RiCloseLine } from '@remixicon/react' import * as React from 'react' import { createPortal } from 'react-dom' @@ -36,7 +35,7 @@ const AudioPreview: FC = ({ className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" onClick={onCancel} > - + , document.body, diff --git a/web/app/components/base/file-uploader/constants.spec.ts b/web/app/components/base/file-uploader/constants.spec.ts new file mode 100644 index 0000000000..abe44aa842 --- /dev/null +++ b/web/app/components/base/file-uploader/constants.spec.ts @@ -0,0 +1,71 @@ +import { + AUDIO_SIZE_LIMIT, + FILE_SIZE_LIMIT, + FILE_URL_REGEX, + IMG_SIZE_LIMIT, + MAX_FILE_UPLOAD_LIMIT, + VIDEO_SIZE_LIMIT, +} from './constants' + +describe('file-uploader constants', () => { + describe('size limit constants', () => { + it('should set IMG_SIZE_LIMIT to 10 MB', () => { + expect(IMG_SIZE_LIMIT).toBe(10 * 1024 * 1024) + }) + + it('should set FILE_SIZE_LIMIT to 15 MB', () => { + expect(FILE_SIZE_LIMIT).toBe(15 * 1024 * 1024) + }) + + it('should set AUDIO_SIZE_LIMIT to 50 MB', () => { + expect(AUDIO_SIZE_LIMIT).toBe(50 * 1024 * 1024) + }) + + it('should set VIDEO_SIZE_LIMIT to 100 MB', () => { + expect(VIDEO_SIZE_LIMIT).toBe(100 * 1024 * 1024) + }) + + it('should set MAX_FILE_UPLOAD_LIMIT to 10', () => { + expect(MAX_FILE_UPLOAD_LIMIT).toBe(10) + }) + }) + + describe('FILE_URL_REGEX', () => { + it('should match http URLs', () => { + expect(FILE_URL_REGEX.test('http://example.com')).toBe(true) + expect(FILE_URL_REGEX.test('http://example.com/path/file.txt')).toBe(true) + }) + + it('should match https URLs', () => { + expect(FILE_URL_REGEX.test('https://example.com')).toBe(true) + expect(FILE_URL_REGEX.test('https://example.com/path/file.pdf')).toBe(true) + }) + + it('should match ftp URLs', () => { + expect(FILE_URL_REGEX.test('ftp://files.example.com')).toBe(true) + expect(FILE_URL_REGEX.test('ftp://files.example.com/data.csv')).toBe(true) + }) + + it('should reject URLs without a valid protocol', () => { + expect(FILE_URL_REGEX.test('example.com')).toBe(false) + expect(FILE_URL_REGEX.test('www.example.com')).toBe(false) + }) + + it('should reject empty strings', () => { + expect(FILE_URL_REGEX.test('')).toBe(false) + }) + + it('should reject unsupported protocols', () => { + expect(FILE_URL_REGEX.test('file:///local/path')).toBe(false) + expect(FILE_URL_REGEX.test('ssh://host')).toBe(false) + expect(FILE_URL_REGEX.test('data:text/plain;base64,abc')).toBe(false) + }) + + it('should reject partial protocol strings', () => { + expect(FILE_URL_REGEX.test('http:')).toBe(false) + expect(FILE_URL_REGEX.test('http:/')).toBe(false) + expect(FILE_URL_REGEX.test('https:')).toBe(false) + expect(FILE_URL_REGEX.test('ftp:')).toBe(false) + }) + }) +}) diff --git a/web/app/components/base/file-uploader/file-from-link-or-local/index.spec.tsx b/web/app/components/base/file-uploader/file-from-link-or-local/index.spec.tsx new file mode 100644 index 0000000000..5227b9b2b2 --- /dev/null +++ b/web/app/components/base/file-uploader/file-from-link-or-local/index.spec.tsx @@ -0,0 +1,173 @@ +import type { FileEntity } from '../types' +import type { FileUpload } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { FileContextProvider } from '../store' +import FileFromLinkOrLocal from './index' + +let mockFiles: FileEntity[] = [] + +function createStubFile(id: string): FileEntity { + return { id, name: `${id}.txt`, size: 0, type: '', progress: 100, transferMethod: 'local_file' as FileEntity['transferMethod'], supportFileType: 'document' } +} + +const mockHandleLoadFileFromLink = vi.fn() +vi.mock('../hooks', () => ({ + useFile: () => ({ + handleLoadFileFromLink: mockHandleLoadFileFromLink, + }), +})) + +const createFileConfig = (overrides: Partial = {}): FileUpload => ({ + enabled: true, + allowed_file_types: ['image'], + allowed_file_extensions: [], + number_limits: 5, + ...overrides, +} as FileUpload) + +function renderAndOpen(props: Partial> = {}) { + const trigger = props.trigger ?? ((open: boolean) => ) + const result = render( + + + , + ) + fireEvent.click(screen.getByTestId('trigger')) + return result +} + +describe('FileFromLinkOrLocal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFiles = [] + }) + + it('should render trigger element', () => { + const trigger = (open: boolean) => ( + + ) + render( + + + , + ) + + expect(screen.getByTestId('trigger')).toBeInTheDocument() + }) + + it('should render URL input when showFromLink is true', () => { + renderAndOpen({ showFromLink: true }) + + expect(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)).toBeInTheDocument() + }) + + it('should render upload button when showFromLocal is true', () => { + renderAndOpen({ showFromLocal: true }) + + expect(screen.getByText(/fileUploader\.uploadFromComputer/)).toBeInTheDocument() + }) + + it('should render OR divider when both link and local are shown', () => { + renderAndOpen({ showFromLink: true, showFromLocal: true }) + + expect(screen.getByText('OR')).toBeInTheDocument() + }) + + it('should not render OR divider when only link is shown', () => { + renderAndOpen({ showFromLink: true, showFromLocal: false }) + + expect(screen.queryByText('OR')).not.toBeInTheDocument() + }) + + it('should show error when invalid URL is submitted', () => { + renderAndOpen({ showFromLink: true }) + + const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) + fireEvent.change(input, { target: { value: 'invalid-url' } }) + + const okButton = screen.getByText(/operation\.ok/) + fireEvent.click(okButton) + + expect(screen.getByText(/fileUploader\.pasteFileLinkInvalid/)).toBeInTheDocument() + }) + + it('should clear error when input changes', () => { + renderAndOpen({ showFromLink: true }) + + const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) + fireEvent.change(input, { target: { value: 'invalid-url' } }) + fireEvent.click(screen.getByText(/operation\.ok/)) + + expect(screen.getByText(/fileUploader\.pasteFileLinkInvalid/)).toBeInTheDocument() + + fireEvent.change(input, { target: { value: 'https://example.com' } }) + expect(screen.queryByText(/fileUploader\.pasteFileLinkInvalid/)).not.toBeInTheDocument() + }) + + it('should disable ok button when url is empty', () => { + renderAndOpen({ showFromLink: true }) + + const okButton = screen.getByText(/operation\.ok/) + expect(okButton.closest('button')).toBeDisabled() + }) + + it('should disable inputs when file limit is reached', () => { + mockFiles = ['1', '2', '3', '4', '5'].map(createStubFile) + renderAndOpen({ fileConfig: createFileConfig({ number_limits: 5 }), showFromLink: true, showFromLocal: true }) + + const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) + expect(input).toBeDisabled() + }) + + it('should not submit when url is empty', () => { + renderAndOpen({ showFromLink: true }) + + const okButton = screen.getByText(/operation\.ok/) + fireEvent.click(okButton) + + expect(screen.queryByText(/fileUploader\.pasteFileLinkInvalid/)).not.toBeInTheDocument() + }) + + it('should call handleLoadFileFromLink when valid URL is submitted', () => { + renderAndOpen({ showFromLink: true }) + + const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) + fireEvent.change(input, { target: { value: 'https://example.com/file.pdf' } }) + fireEvent.click(screen.getByText(/operation\.ok/)) + + expect(mockHandleLoadFileFromLink).toHaveBeenCalledWith('https://example.com/file.pdf') + }) + + it('should clear URL input after successful submission', () => { + renderAndOpen({ showFromLink: true }) + + const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) as HTMLInputElement + fireEvent.change(input, { target: { value: 'https://example.com/file.pdf' } }) + fireEvent.click(screen.getByText(/operation\.ok/)) + + expect(input.value).toBe('') + }) + + it('should toggle open state when trigger is clicked', () => { + const trigger = (open: boolean) => + render( + + + , + ) + + const triggerButton = screen.getByTestId('trigger') + expect(triggerButton).toHaveTextContent('Open') + + fireEvent.click(triggerButton) + + expect(triggerButton).toHaveTextContent('Close') + }) +}) diff --git a/web/app/components/base/file-uploader/file-image-render.spec.tsx b/web/app/components/base/file-uploader/file-image-render.spec.tsx new file mode 100644 index 0000000000..fa85011f5c --- /dev/null +++ b/web/app/components/base/file-uploader/file-image-render.spec.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import FileImageRender from './file-image-render' + +describe('FileImageRender', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render an image with the given URL', () => { + render() + + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', 'https://example.com/image.png') + }) + + it('should use default alt text when alt is not provided', () => { + render() + + expect(screen.getByAltText('Preview')).toBeInTheDocument() + }) + + it('should use custom alt text when provided', () => { + render() + + expect(screen.getByAltText('Custom alt')).toBeInTheDocument() + }) + + it('should apply custom className to container', () => { + const { container } = render( + , + ) + + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should call onLoad when image loads', () => { + const onLoad = vi.fn() + render() + + fireEvent.load(screen.getByRole('img')) + + expect(onLoad).toHaveBeenCalled() + }) + + it('should call onError when image fails to load', () => { + const onError = vi.fn() + render() + + fireEvent.error(screen.getByRole('img')) + + expect(onError).toHaveBeenCalled() + }) + + it('should add cursor-pointer to image when showDownloadAction is true', () => { + render() + + const img = screen.getByRole('img') + expect(img).toHaveClass('cursor-pointer') + }) + + it('should not add cursor-pointer when showDownloadAction is false', () => { + render() + + const img = screen.getByRole('img') + expect(img).not.toHaveClass('cursor-pointer') + }) +}) diff --git a/web/app/components/base/file-uploader/file-input.spec.tsx b/web/app/components/base/file-uploader/file-input.spec.tsx new file mode 100644 index 0000000000..73c7690e29 --- /dev/null +++ b/web/app/components/base/file-uploader/file-input.spec.tsx @@ -0,0 +1,179 @@ +import type { FileEntity } from './types' +import type { FileUpload } from '@/app/components/base/features/types' +import { fireEvent, render } from '@testing-library/react' +import FileInput from './file-input' +import { FileContextProvider } from './store' + +const mockHandleLocalFileUpload = vi.fn() + +vi.mock('./hooks', () => ({ + useFile: () => ({ + handleLocalFileUpload: mockHandleLocalFileUpload, + }), +})) + +const createFileConfig = (overrides: Partial = {}): FileUpload => ({ + enabled: true, + allowed_file_types: ['image'], + allowed_file_extensions: [], + number_limits: 5, + ...overrides, +} as FileUpload) + +function createStubFile(id: string): FileEntity { + return { id, name: `${id}.txt`, size: 0, type: '', progress: 100, transferMethod: 'local_file' as FileEntity['transferMethod'], supportFileType: 'document' } +} + +function renderWithProvider(ui: React.ReactElement, fileIds: string[] = []) { + return render( + + {ui} + , + ) +} + +describe('FileInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render a file input element', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') + expect(input).toBeInTheDocument() + }) + + it('should set accept attribute based on allowed file types', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.accept).toBe('.JPG,.JPEG,.PNG,.GIF,.WEBP,.SVG') + }) + + it('should use custom extensions when file type is custom', () => { + renderWithProvider( + , + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.accept).toBe('.csv,.xlsx') + }) + + it('should allow multiple files when number_limits > 1', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.multiple).toBe(true) + }) + + it('should not allow multiple files when number_limits is 1', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.multiple).toBe(false) + }) + + it('should be disabled when file limit is reached', () => { + renderWithProvider( + , + ['1', '2', '3'], + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.disabled).toBe(true) + }) + + it('should not be disabled when file limit is not reached', () => { + renderWithProvider( + , + ['1'], + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.disabled).toBe(false) + }) + + it('should call handleLocalFileUpload when files are selected', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' }) + fireEvent.change(input, { target: { files: [file] } }) + + expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file) + }) + + it('should respect number_limits when uploading multiple files', () => { + renderWithProvider( + , + ['1', '2'], + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file1 = new File(['content'], 'test1.jpg', { type: 'image/jpeg' }) + const file2 = new File(['content'], 'test2.jpg', { type: 'image/jpeg' }) + + Object.defineProperty(input, 'files', { + value: [file1, file2], + }) + fireEvent.change(input) + + // Only 1 file should be uploaded (2 existing + 1 = 3 = limit) + expect(mockHandleLocalFileUpload).toHaveBeenCalledTimes(1) + expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file1) + }) + + it('should upload first file only when number_limits is not set', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' }) + fireEvent.change(input, { target: { files: [file] } }) + + expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file) + }) + + it('should not upload when targetFiles is null', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(input, { target: { files: null } }) + + expect(mockHandleLocalFileUpload).not.toHaveBeenCalled() + }) + + it('should handle empty allowed_file_types', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.accept).toBe('') + }) + + it('should handle custom type with undefined allowed_file_extensions', () => { + renderWithProvider( + , + ) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + expect(input.accept).toBe('') + }) + + it('should clear input value on click', () => { + renderWithProvider() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + Object.defineProperty(input, 'value', { writable: true, value: 'some-file' }) + fireEvent.click(input) + + expect(input.value).toBe('') + }) +}) diff --git a/web/app/components/base/file-uploader/file-list-in-log.spec.tsx b/web/app/components/base/file-uploader/file-list-in-log.spec.tsx new file mode 100644 index 0000000000..0c1dff8759 --- /dev/null +++ b/web/app/components/base/file-uploader/file-list-in-log.spec.tsx @@ -0,0 +1,142 @@ +import type { FileEntity } from './types' +import { fireEvent, render, screen } from '@testing-library/react' +import { TransferMethod } from '@/types/app' +import FileListInLog from './file-list-in-log' + +const createFile = (overrides: Partial = {}): FileEntity => ({ + id: `file-${Math.random()}`, + name: 'test.txt', + size: 1024, + type: 'text/plain', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + ...overrides, +}) + +describe('FileListInLog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return null when fileList is empty', () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('should render collapsed view by default', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + render() + + expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument() + }) + + it('should render expanded view when isExpanded is true', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + render() + + expect(screen.getByText(/runDetail\.fileListLabel/)).toBeInTheDocument() + expect(screen.getByText('files')).toBeInTheDocument() + }) + + it('should toggle between collapsed and expanded on click', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + render() + + expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument() + + const detailLink = screen.getByText(/runDetail\.fileListDetail/) + fireEvent.click(detailLink.parentElement!) + + expect(screen.getByText(/runDetail\.fileListLabel/)).toBeInTheDocument() + }) + + it('should render image files with an img element in collapsed view', () => { + const fileList = [{ + varName: 'files', + list: [createFile({ + name: 'photo.png', + supportFileType: 'image', + url: 'https://example.com/photo.png', + })], + }] + render() + + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://example.com/photo.png') + }) + + it('should render non-image files with an SVG icon in collapsed view', () => { + const fileList = [{ + varName: 'files', + list: [createFile({ + name: 'doc.pdf', + supportFileType: 'document', + })], + }] + render() + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + + it('should render file details in expanded view', () => { + const file = createFile({ name: 'report.txt' }) + const fileList = [{ varName: 'files', list: [file] }] + render() + + expect(screen.getByText('report.txt')).toBeInTheDocument() + }) + + it('should render multiple var groups in expanded view', () => { + const fileList = [ + { varName: 'images', list: [createFile({ name: 'a.jpg' })] }, + { varName: 'documents', list: [createFile({ name: 'b.pdf' })] }, + ] + render() + + expect(screen.getByText('images')).toBeInTheDocument() + expect(screen.getByText('documents')).toBeInTheDocument() + }) + + it('should apply noBorder class when noBorder is true', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + const { container } = render() + + expect(container.firstChild).not.toHaveClass('border-t') + }) + + it('should apply noPadding class when noPadding is true', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + const { container } = render() + + expect(container.firstChild).toHaveClass('!p-0') + }) + + it('should render image file with empty url when both base64Url and url are undefined', () => { + const fileList = [{ + varName: 'files', + list: [createFile({ + name: 'photo.png', + supportFileType: 'image', + base64Url: undefined, + url: undefined, + })], + }] + render() + + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + }) + + it('should collapse when label is clicked in expanded view', () => { + const fileList = [{ varName: 'files', list: [createFile()] }] + render() + + const label = screen.getByText(/runDetail\.fileListLabel/) + fireEvent.click(label) + + expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/file-uploader/file-type-icon.spec.tsx b/web/app/components/base/file-uploader/file-type-icon.spec.tsx new file mode 100644 index 0000000000..89b42b489d --- /dev/null +++ b/web/app/components/base/file-uploader/file-type-icon.spec.tsx @@ -0,0 +1,85 @@ +import type { FileAppearanceTypeEnum } from './types' +import { render } from '@testing-library/react' +import FileTypeIcon from './file-type-icon' + +describe('FileTypeIcon', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('icon rendering per file type', () => { + const fileTypeToColor: Array<{ type: keyof typeof FileAppearanceTypeEnum, color: string }> = [ + { type: 'pdf', color: 'text-[#EA3434]' }, + { type: 'image', color: 'text-[#00B2EA]' }, + { type: 'video', color: 'text-[#844FDA]' }, + { type: 'audio', color: 'text-[#FF3093]' }, + { type: 'document', color: 'text-[#6F8BB5]' }, + { type: 'code', color: 'text-[#BCC0D1]' }, + { type: 'markdown', color: 'text-[#309BEC]' }, + { type: 'custom', color: 'text-[#BCC0D1]' }, + { type: 'excel', color: 'text-[#01AC49]' }, + { type: 'word', color: 'text-[#2684FF]' }, + { type: 'ppt', color: 'text-[#FF650F]' }, + { type: 'gif', color: 'text-[#00B2EA]' }, + ] + + it.each(fileTypeToColor)( + 'should render $type icon with correct color', + ({ type, color }) => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass(color) + }, + ) + }) + + it('should render document icon when type is unknown', () => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass('text-[#6F8BB5]') + }) + + describe('size variants', () => { + const sizeMap: Array<{ size: 'sm' | 'md' | 'lg' | 'xl', expectedClass: string }> = [ + { size: 'sm', expectedClass: 'size-4' }, + { size: 'md', expectedClass: 'size-[18px]' }, + { size: 'lg', expectedClass: 'size-5' }, + { size: 'xl', expectedClass: 'size-6' }, + ] + + it.each(sizeMap)( + 'should apply $expectedClass when size is $size', + ({ size, expectedClass }) => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toHaveClass(expectedClass) + }, + ) + + it('should default to sm size when no size is provided', () => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toHaveClass('size-4') + }) + }) + + it('should apply custom className when provided', () => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toHaveClass('extra-class') + }) + + it('should always include shrink-0 class', () => { + const { container } = render() + + const icon = container.querySelector('svg') + expect(icon).toHaveClass('shrink-0') + }) +}) diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.spec.tsx new file mode 100644 index 0000000000..72d4643955 --- /dev/null +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.spec.tsx @@ -0,0 +1,407 @@ +import type { FileEntity } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { PreviewMode } from '@/app/components/base/features/types' +import { TransferMethod } from '@/types/app' +import FileInAttachmentItem from './file-item' + +vi.mock('@/utils/download', () => ({ + downloadUrl: vi.fn(), +})) + +vi.mock('@/utils/format', () => ({ + formatFileSize: (size: number) => `${size}B`, +})) + +const createFile = (overrides: Partial = {}): FileEntity => ({ + id: 'file-1', + name: 'document.pdf', + size: 2048, + type: 'application/pdf', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + uploadedId: 'uploaded-1', + url: 'https://example.com/document.pdf', + ...overrides, +}) + +describe('FileInAttachmentItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render file name and extension', () => { + render() + + expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument() + expect(screen.getByText(/^pdf$/i)).toBeInTheDocument() + }) + + it('should render file size', () => { + render() + + expect(screen.getByText(/2048B/)).toBeInTheDocument() + }) + + it('should render FileTypeIcon for non-image files', () => { + const { container } = render() + + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should render FileImageRender for image files', () => { + render( + , + ) + + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'data:image/png;base64,abc') + }) + + it('should render delete button when showDeleteAction is true', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should not render delete button when showDeleteAction is false', () => { + render() + + // With showDeleteAction=false, showDownloadAction defaults to true, + // so there should be exactly 1 button (the download button) + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(1) + }) + + it('should call onRemove when delete button is clicked', () => { + const onRemove = vi.fn() + // Disable download to isolate the delete button + render() + + const deleteBtn = screen.getByRole('button') + fireEvent.click(deleteBtn) + + expect(onRemove).toHaveBeenCalledWith('file-1') + }) + + it('should render download button when showDownloadAction is true', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should render progress circle when file is uploading', () => { + const { container } = render() + + // ProgressCircle renders an SVG with a and element + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + const circle = container.querySelector('circle') + expect(circle).toBeInTheDocument() + }) + + it('should render replay icon when upload failed', () => { + const { container } = render() + + // ReplayLine renders an SVG with data-icon="ReplayLine" + const replayIcon = container.querySelector('[data-icon="ReplayLine"]') + expect(replayIcon).toBeInTheDocument() + }) + + it('should call onReUpload when replay icon is clicked', () => { + const onReUpload = vi.fn() + const { container } = render() + + const replayIcon = container.querySelector('[data-icon="ReplayLine"]') + const replayBtn = replayIcon!.closest('button') + fireEvent.click(replayBtn!) + + expect(onReUpload).toHaveBeenCalledWith('file-1') + }) + + it('should indicate error state when progress is -1', () => { + const { container } = render() + + // Error state is confirmed by the presence of the replay icon + const replayIcon = container.querySelector('[data-icon="ReplayLine"]') + expect(replayIcon).toBeInTheDocument() + }) + + it('should render eye icon for previewable image files', () => { + render( + , + ) + + // canPreview + image renders an extra button for the eye icon + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(2) + }) + + it('should show image preview when eye icon is clicked', () => { + render( + , + ) + + // The eye button is rendered before the download button for image files + const buttons = screen.getAllByRole('button') + // Click the eye button (the first action button for image preview) + fireEvent.click(buttons[0]) + + // ImagePreview renders a portal with an img element + const previewImages = document.querySelectorAll('img') + // There should be at least 2 images: the file thumbnail + the preview + expect(previewImages.length).toBeGreaterThanOrEqual(2) + }) + + it('should close image preview when close is clicked', () => { + render( + , + ) + + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + + // ImagePreview renders via createPortal with class "image-preview-container" + const previewContainer = document.querySelector('.image-preview-container')! + expect(previewContainer).toBeInTheDocument() + + // Close button is the last clickable div with an SVG in the preview container + const closeIcon = screen.getByTestId('image-preview-close-button') + fireEvent.click(closeIcon.parentElement!) + + // Preview should be removed + expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument() + }) + + it('should call downloadUrl when download button is clicked', async () => { + const { downloadUrl } = await import('@/utils/download') + render() + + // Download button is the only action button when showDeleteAction is not set + const downloadBtn = screen.getByRole('button') + fireEvent.click(downloadBtn) + + expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ + fileName: expect.stringMatching(/document\.pdf/i), + })) + }) + + it('should open new page when previewMode is NewPage and clicked', () => { + const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) + render( + , + ) + + // Click the file name text to trigger the row click handler + fireEvent.click(screen.getByText(/document\.pdf/i)) + + expect(windowOpen).toHaveBeenCalledWith('https://example.com/doc.pdf', '_blank') + windowOpen.mockRestore() + }) + + it('should fallback to base64Url when url is empty for NewPage preview', () => { + const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) + render( + , + ) + + fireEvent.click(screen.getByText(/document\.pdf/i)) + + expect(windowOpen).toHaveBeenCalledWith('data:image/png;base64,abc', '_blank') + windowOpen.mockRestore() + }) + + it('should open empty string when both url and base64Url are empty for NewPage preview', () => { + const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) + render( + , + ) + + fireEvent.click(screen.getByText(/document\.pdf/i)) + + expect(windowOpen).toHaveBeenCalledWith('', '_blank') + windowOpen.mockRestore() + }) + + it('should not open new page when previewMode is not NewPage', () => { + const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) + render( + , + ) + + fireEvent.click(screen.getByText(/document\.pdf/i)) + + expect(windowOpen).not.toHaveBeenCalled() + windowOpen.mockRestore() + }) + + it('should use url for image render fallback when base64Url is empty', () => { + render( + , + ) + + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', 'https://example.com/img.png') + }) + + it('should render image element even when both urls are empty', () => { + render( + , + ) + + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + }) + + it('should not render eye icon when canPreview is false for image files', () => { + render( + , + ) + + // Without canPreview, only the download button should render + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(1) + }) + + it('should download using base64Url when url is not available', async () => { + const { downloadUrl } = await import('@/utils/download') + render( + , + ) + + const downloadBtn = screen.getByRole('button') + fireEvent.click(downloadBtn) + + expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ + url: 'data:application/pdf;base64,abc', + })) + }) + + it('should not render file size when size is 0', () => { + render() + + expect(screen.queryByText(/0B/)).not.toBeInTheDocument() + }) + + it('should not render extension when ext is empty', () => { + render() + + // The file name should still show + expect(screen.getByText(/noext/)).toBeInTheDocument() + }) + + it('should show image preview with empty url when url is undefined', () => { + render( + , + ) + + const buttons = screen.getAllByRole('button') + // Click the eye preview button + fireEvent.click(buttons[0]) + + // setImagePreviewUrl(url || '') = setImagePreviewUrl('') + // Empty string is falsy, so preview should NOT render + expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument() + }) + + it('should download with empty url when both url and base64Url are undefined', async () => { + const { downloadUrl } = await import('@/utils/download') + render( + , + ) + + const downloadBtn = screen.getByRole('button') + fireEvent.click(downloadBtn) + + expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ + url: '', + })) + }) + + it('should call downloadUrl with empty url when both url and base64Url are falsy', async () => { + const { downloadUrl } = await import('@/utils/download') + render( + , + ) + + const downloadBtn = screen.getByRole('button') + fireEvent.click(downloadBtn) + + expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({ + url: '', + })) + }) +}) diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.spec.tsx new file mode 100644 index 0000000000..81946e0d1c --- /dev/null +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.spec.tsx @@ -0,0 +1,207 @@ +import type { FileEntity } from '../types' +import type { FileUpload } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { TransferMethod } from '@/types/app' +import FileUploaderInAttachmentWrapper from './index' + +const mockHandleRemoveFile = vi.fn() +const mockHandleReUploadFile = vi.fn() +vi.mock('../hooks', () => ({ + useFile: () => ({ + handleRemoveFile: mockHandleRemoveFile, + handleReUploadFile: mockHandleReUploadFile, + }), +})) + +vi.mock('@/utils/format', () => ({ + formatFileSize: (size: number) => `${size}B`, +})) + +vi.mock('@/utils/download', () => ({ + downloadUrl: vi.fn(), +})) + +const createFileConfig = (overrides: Partial = {}): FileUpload => ({ + enabled: true, + allowed_file_types: ['image'], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + allowed_file_extensions: [], + number_limits: 5, + ...overrides, +} as unknown as FileUpload) + +const createFile = (overrides: Partial = {}): FileEntity => ({ + id: 'file-1', + name: 'test.txt', + size: 1024, + type: 'text/plain', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + ...overrides, +}) + +describe('FileUploaderInAttachmentWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render without crashing', () => { + render( + , + ) + + // FileContextProvider wraps children with a Zustand context — verify children render + expect(screen.getAllByRole('button').length).toBeGreaterThan(0) + }) + + it('should render upload buttons when not disabled', () => { + render( + , + ) + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('should not render upload buttons when disabled', () => { + render( + , + ) + + expect(screen.queryByText(/fileUploader\.uploadFromComputer/)).not.toBeInTheDocument() + }) + + it('should render file items for each file', () => { + const files = [ + createFile({ id: 'f1', name: 'a.txt' }), + createFile({ id: 'f2', name: 'b.txt' }), + ] + + render( + , + ) + + expect(screen.getByText(/a\.txt/i)).toBeInTheDocument() + expect(screen.getByText(/b\.txt/i)).toBeInTheDocument() + }) + + it('should render local upload button for local_file method', () => { + render( + )} + />, + ) + + expect(screen.getByText(/fileUploader\.uploadFromComputer/)).toBeInTheDocument() + }) + + it('should render link upload option for remote_url method', () => { + render( + )} + />, + ) + + expect(screen.getByText(/fileUploader\.pasteFileLink/)).toBeInTheDocument() + }) + + it('should call handleRemoveFile when remove button is clicked', () => { + const files = [createFile({ id: 'f1', name: 'a.txt' })] + + render( + , + ) + + // Find the file item row, then locate the delete button within it + const fileNameEl = screen.getByText(/a\.txt/i) + const fileRow = fileNameEl.closest('[title="a.txt"]')?.parentElement?.parentElement + const deleteBtn = fileRow?.querySelector('button:last-of-type') + fireEvent.click(deleteBtn!) + + expect(mockHandleRemoveFile).toHaveBeenCalledWith('f1') + }) + + it('should apply open style on remote_url trigger when portal is open', () => { + render( + )} + />, + ) + + // Click the remote_url button to open the portal + const linkButton = screen.getByText(/fileUploader\.pasteFileLink/) + fireEvent.click(linkButton) + + // The button should still be in the document + expect(linkButton.closest('button')).toBeInTheDocument() + }) + + it('should disable upload buttons when file limit is reached', () => { + const files = [ + createFile({ id: 'f1' }), + createFile({ id: 'f2' }), + createFile({ id: 'f3' }), + createFile({ id: 'f4' }), + createFile({ id: 'f5' }), + ] + + render( + , + ) + + const buttons = screen.getAllByRole('button') + const disabledButtons = buttons.filter(btn => btn.hasAttribute('disabled')) + expect(disabledButtons.length).toBeGreaterThan(0) + }) + + it('should call handleReUploadFile when reupload button is clicked', () => { + const files = [createFile({ id: 'f1', name: 'a.txt', progress: -1 })] + + const { container } = render( + , + ) + + // ReplayLine is inside ActionButton (a + + ), +})) + +const createFile = (overrides: Partial = {}): FileEntity => ({ + id: 'file-1', + name: 'document.pdf', + size: 2048, + type: 'application/pdf', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + uploadedId: 'uploaded-1', + url: 'https://example.com/document.pdf', + ...overrides, +}) + +describe('FileItem (chat-input)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render file name', () => { + render() + + expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument() + }) + + it('should render file extension and size', () => { + const { container } = render() + + // Extension and size are rendered as text nodes in the metadata div + expect(container.textContent).toContain('pdf') + expect(container.textContent).toContain('2048B') + }) + + it('should render FileTypeIcon', () => { + const { container } = render() + + const fileTypeIcon = container.querySelector('svg') + expect(fileTypeIcon).toBeInTheDocument() + }) + + it('should render delete button when showDeleteAction is true', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should call onRemove when delete button is clicked', () => { + const onRemove = vi.fn() + render() + const delete_button = screen.getByTestId('delete-button') + fireEvent.click(delete_button) + expect(onRemove).toHaveBeenCalledWith('file-1') + }) + + it('should render progress circle when file is uploading', () => { + const { container } = render( + , + ) + + const progressSvg = container.querySelector('svg circle') + expect(progressSvg).toBeInTheDocument() + }) + + it('should render replay icon when upload failed', () => { + render() + + const replayIcon = screen.getByTestId('replay-icon') + expect(replayIcon).toBeInTheDocument() + }) + + it('should call onReUpload when replay icon is clicked', () => { + const onReUpload = vi.fn() + render( + , + ) + + const replayIcon = screen.getByTestId('replay-icon') + fireEvent.click(replayIcon!) + + expect(onReUpload).toHaveBeenCalledWith('file-1') + }) + + it('should have error styling when upload failed', () => { + const { container } = render() + const fileItemContainer = container.firstChild as HTMLElement + expect(fileItemContainer).toHaveClass('border-state-destructive-border') + expect(fileItemContainer).toHaveClass('bg-state-destructive-hover-alt') + }) + + it('should show audio preview when audio file name is clicked', async () => { + render( + , + ) + + fireEvent.click(screen.getByText(/audio\.mp3/i)) + + const audioElement = document.querySelector('audio') + expect(audioElement).toBeInTheDocument() + }) + + it('should show video preview when video file name is clicked', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/video\.mp4/i)) + + const videoElement = document.querySelector('video') + expect(videoElement).toBeInTheDocument() + }) + + it('should show pdf preview when pdf file name is clicked', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/doc\.pdf/i)) + + expect(screen.getByTestId('pdf-preview')).toBeInTheDocument() + }) + + it('should close audio preview', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/audio\.mp3/i)) + expect(document.querySelector('audio')).toBeInTheDocument() + + const deleteButton = screen.getByTestId('close-btn') + fireEvent.click(deleteButton) + + expect(document.querySelector('audio')).not.toBeInTheDocument() + }) + + it('should render download button when showDownloadAction is true and url exists', () => { + render() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should call downloadUrl when download button is clicked', async () => { + const { downloadUrl } = await import('@/utils/download') + render() + + const downloadBtn = screen.getByTestId('download-button') + fireEvent.click(downloadBtn) + + expect(downloadUrl).toHaveBeenCalled() + }) + + it('should not render download button when showDownloadAction is false', () => { + render() + + const buttons = screen.queryAllByRole('button') + expect(buttons).toHaveLength(0) + }) + + it('should not show preview when canPreview is false', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/audio\.mp3/i)) + + expect(document.querySelector('audio')).not.toBeInTheDocument() + }) + + it('should close video preview', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/video\.mp4/i)) + expect(document.querySelector('video')).toBeInTheDocument() + + const closeBtn = screen.getByTestId('video-preview-close-btn') + fireEvent.click(closeBtn) + + expect(document.querySelector('video')).not.toBeInTheDocument() + }) + + it('should close pdf preview', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/doc\.pdf/i)) + expect(screen.getByTestId('pdf-preview')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('pdf-close')) + expect(screen.queryByTestId('pdf-preview')).not.toBeInTheDocument() + }) + + it('should use createObjectURL when no url or base64Url but has originalFile', () => { + const mockUrl = 'blob:http://localhost/test-blob' + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue(mockUrl) + + const file = createFile({ + name: 'audio.mp3', + type: 'audio/mpeg', + url: undefined, + base64Url: undefined, + originalFile: new File(['content'], 'audio.mp3', { type: 'audio/mpeg' }), + }) + render() + + fireEvent.click(screen.getByText(/audio\.mp3/i)) + + expect(document.querySelector('audio')).toBeInTheDocument() + expect(createObjectURLSpy).toHaveBeenCalled() + createObjectURLSpy.mockRestore() + }) + + it('should not use createObjectURL when no originalFile and no urls', () => { + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL') + const file = createFile({ + name: 'audio.mp3', + type: 'audio/mpeg', + url: undefined, + base64Url: undefined, + originalFile: undefined, + }) + render() + + fireEvent.click(screen.getByText(/audio\.mp3/i)) + expect(createObjectURLSpy).not.toHaveBeenCalled() + createObjectURLSpy.mockRestore() + expect(document.querySelector('audio')).not.toBeInTheDocument() + }) + + it('should not render download button when download_url is falsy', () => { + render( + , + ) + + const buttons = screen.queryAllByRole('button') + expect(buttons).toHaveLength(0) + }) + + it('should render download button when base64Url is available as download_url', () => { + render( + , + ) + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should not render extension separator when ext is empty', () => { + render() + + expect(screen.getByText(/noext/)).toBeInTheDocument() + }) + + it('should not render file size when size is 0', () => { + render() + + expect(screen.queryByText(/0B/)).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx index af32f917b9..09f5070f1e 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx @@ -1,15 +1,10 @@ import type { FileEntity } from '../types' -import { - RiCloseLine, - RiDownloadLine, -} from '@remixicon/react' import { useState } from 'react' import ActionButton from '@/app/components/base/action-button' import Button from '@/app/components/base/button' import AudioPreview from '@/app/components/base/file-uploader/audio-preview' import PdfPreview from '@/app/components/base/file-uploader/dynamic-pdf-preview' import VideoPreview from '@/app/components/base/file-uploader/video-preview' -import { ReplayLine } from '@/app/components/base/icons/src/vender/other' import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' import { cn } from '@/utils/classnames' import { downloadUrl } from '@/utils/download' @@ -62,20 +57,21 @@ const FileItem = ({ ) }
canPreview && setPreviewUrl(tmp_preview_url || '')} > {name}
-
+
- + ) } @@ -118,10 +115,7 @@ const FileItem = ({ } { uploadError && ( - onReUpload?.(id)} - /> + onReUpload?.(id)} data-testid="replay-icon" role="button" tabIndex={0} /> ) }
diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.spec.tsx new file mode 100644 index 0000000000..cae64eb6cb --- /dev/null +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.spec.tsx @@ -0,0 +1,137 @@ +import type { FileEntity } from '../types' +import type { FileUpload } from '@/app/components/base/features/types' +import { render, screen } from '@testing-library/react' +import { TransferMethod } from '@/types/app' +import { FileContextProvider } from '../store' +import { FileList, FileListInChatInput } from './file-list' + +vi.mock('../hooks', () => ({ + useFile: () => ({ + handleRemoveFile: vi.fn(), + handleReUploadFile: vi.fn(), + }), +})) + +vi.mock('@/utils/format', () => ({ + formatFileSize: (size: number) => `${size}B`, +})) + +vi.mock('@/utils/download', () => ({ + downloadUrl: vi.fn(), +})) + +const createFile = (overrides: Partial = {}): FileEntity => ({ + id: `file-${Math.random()}`, + name: 'document.pdf', + size: 1024, + type: 'application/pdf', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + ...overrides, +}) + +describe('FileList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render FileImageItem for image files', () => { + const files = [createFile({ + name: 'photo.png', + type: 'image/png', + supportFileType: 'image', + base64Url: 'data:image/png;base64,abc', + })] + render() + + expect(screen.getByRole('img')).toBeInTheDocument() + }) + + it('should render FileItem for non-image files', () => { + const files = [createFile({ + name: 'document.pdf', + supportFileType: 'document', + })] + render() + + expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument() + }) + + it('should render both image and non-image files', () => { + const files = [ + createFile({ + name: 'photo.png', + type: 'image/png', + supportFileType: 'image', + base64Url: 'data:image/png;base64,abc', + }), + createFile({ name: 'doc.pdf', supportFileType: 'document' }), + ] + render() + + expect(screen.getByRole('img')).toBeInTheDocument() + expect(screen.getByText(/doc\.pdf/i)).toBeInTheDocument() + }) + + it('should render empty list when no files', () => { + const { container } = render() + + expect(container.firstChild).toBeInTheDocument() + expect(screen.queryAllByRole('img')).toHaveLength(0) + }) + + it('should apply custom className', () => { + const { container } = render() + + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should render multiple files', () => { + const files = [ + createFile({ name: 'a.pdf' }), + createFile({ name: 'b.pdf' }), + createFile({ name: 'c.pdf' }), + ] + render() + + expect(screen.getByText(/a\.pdf/i)).toBeInTheDocument() + expect(screen.getByText(/b\.pdf/i)).toBeInTheDocument() + expect(screen.getByText(/c\.pdf/i)).toBeInTheDocument() + }) +}) + +describe('FileListInChatInput', () => { + let mockStoreFiles: FileEntity[] = [] + + beforeEach(() => { + vi.clearAllMocks() + mockStoreFiles = [] + }) + + it('should render FileList with files from store', () => { + mockStoreFiles = [createFile({ name: 'test.pdf' })] + const fileConfig = { enabled: true, allowed_file_types: ['document'] } as FileUpload + + render( + + + , + ) + + expect(screen.getByText(/test\.pdf/i)).toBeInTheDocument() + }) + + it('should render empty FileList when store has no files', () => { + const fileConfig = { enabled: true, allowed_file_types: ['document'] } as FileUpload + + render( + + + , + ) + + expect(screen.queryAllByRole('img')).toHaveLength(0) + expect(screen.queryByText(/\.pdf/i)).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.spec.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.spec.tsx new file mode 100644 index 0000000000..0cdde4835d --- /dev/null +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.spec.tsx @@ -0,0 +1,101 @@ +import type { FileUpload } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { FileContextProvider } from '../store' +import FileUploaderInChatInput from './index' + +vi.mock('@/types/app', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + TransferMethod: { + local_file: 'local_file', + remote_url: 'remote_url', + }, + } +}) + +vi.mock('../hooks', () => ({ + useFile: () => ({ + handleLoadFileFromLink: vi.fn(), + }), +})) + +function renderWithProvider(ui: React.ReactElement) { + return render( + + {ui} + , + ) +} + +const createFileConfig = (overrides: Partial = {}): FileUpload => ({ + enabled: true, + allowed_file_types: ['image'], + allowed_file_upload_methods: ['local_file', 'remote_url'], + allowed_file_extensions: [], + number_limits: 5, + ...overrides, +} as unknown as FileUpload) + +describe('FileUploaderInChatInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render an attachment icon SVG', () => { + renderWithProvider() + + const button = screen.getByRole('button') + expect(button.querySelector('svg')).toBeInTheDocument() + }) + + it('should render FileFromLinkOrLocal when not readonly', () => { + renderWithProvider() + + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(button).not.toBeDisabled() + }) + + it('should render only the trigger button when readonly', () => { + renderWithProvider() + + const button = screen.getByRole('button') + expect(button).toBeDisabled() + }) + + it('should render button with attachment icon for local_file upload method', () => { + renderWithProvider( + )} + />, + ) + + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(button.querySelector('svg')).toBeInTheDocument() + }) + + it('should render button with attachment icon for remote_url upload method', () => { + renderWithProvider( + )} + />, + ) + + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(button.querySelector('svg')).toBeInTheDocument() + }) + + it('should apply open state styling when trigger is activated', () => { + renderWithProvider() + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(button).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/file-uploader/hooks.spec.ts b/web/app/components/base/file-uploader/hooks.spec.ts new file mode 100644 index 0000000000..5577b87649 --- /dev/null +++ b/web/app/components/base/file-uploader/hooks.spec.ts @@ -0,0 +1,867 @@ +import type { FileEntity } from './types' +import type { FileUpload } from '@/app/components/base/features/types' +import type { FileUploadConfigResponse } from '@/models/common' +import { act, renderHook } from '@testing-library/react' +import { useFile, useFileSizeLimit } from './hooks' + +const mockNotify = vi.fn() + +vi.mock('next/navigation', () => ({ + useParams: () => ({ token: undefined }), +})) + +// Exception: hook requires toast context that isn't available without a provider wrapper +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +const mockSetFiles = vi.fn() +let mockStoreFiles: FileEntity[] = [] +vi.mock('./store', () => ({ + useFileStore: () => ({ + getState: () => ({ + files: mockStoreFiles, + setFiles: mockSetFiles, + }), + }), +})) + +const mockFileUpload = vi.fn() +const mockIsAllowedFileExtension = vi.fn().mockReturnValue(true) +const mockGetSupportFileType = vi.fn().mockReturnValue('document') +vi.mock('./utils', () => ({ + fileUpload: (...args: unknown[]) => mockFileUpload(...args), + getFileUploadErrorMessage: vi.fn().mockReturnValue('Upload error'), + getSupportFileType: (...args: unknown[]) => mockGetSupportFileType(...args), + isAllowedFileExtension: (...args: unknown[]) => mockIsAllowedFileExtension(...args), +})) + +const mockUploadRemoteFileInfo = vi.fn() +vi.mock('@/service/common', () => ({ + uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args), +})) + +vi.mock('uuid', () => ({ + v4: () => 'mock-uuid', +})) + +describe('useFileSizeLimit', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return default limits when no config is provided', () => { + const { result } = renderHook(() => useFileSizeLimit()) + + expect(result.current.imgSizeLimit).toBe(10 * 1024 * 1024) + expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024) + expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024) + expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024) + expect(result.current.maxFileUploadLimit).toBe(10) + }) + + it('should use config values when provided', () => { + const config: FileUploadConfigResponse = { + image_file_size_limit: 20, + file_size_limit: 30, + audio_file_size_limit: 100, + video_file_size_limit: 200, + workflow_file_upload_limit: 20, + } as FileUploadConfigResponse + + const { result } = renderHook(() => useFileSizeLimit(config)) + + expect(result.current.imgSizeLimit).toBe(20 * 1024 * 1024) + expect(result.current.docSizeLimit).toBe(30 * 1024 * 1024) + expect(result.current.audioSizeLimit).toBe(100 * 1024 * 1024) + expect(result.current.videoSizeLimit).toBe(200 * 1024 * 1024) + expect(result.current.maxFileUploadLimit).toBe(20) + }) + + it('should fall back to defaults when config values are zero', () => { + const config = { + image_file_size_limit: 0, + file_size_limit: 0, + audio_file_size_limit: 0, + video_file_size_limit: 0, + workflow_file_upload_limit: 0, + } as FileUploadConfigResponse + + const { result } = renderHook(() => useFileSizeLimit(config)) + + expect(result.current.imgSizeLimit).toBe(10 * 1024 * 1024) + expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024) + expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024) + expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024) + expect(result.current.maxFileUploadLimit).toBe(10) + }) +}) + +describe('useFile', () => { + const defaultFileConfig: FileUpload = { + enabled: true, + allowed_file_types: ['image', 'document'], + allowed_file_extensions: [], + number_limits: 5, + } as FileUpload + + beforeEach(() => { + vi.clearAllMocks() + mockStoreFiles = [] + mockIsAllowedFileExtension.mockReturnValue(true) + mockGetSupportFileType.mockReturnValue('document') + }) + + it('should return all file handler functions', () => { + const { result } = renderHook(() => useFile(defaultFileConfig)) + + expect(result.current.handleAddFile).toBeDefined() + expect(result.current.handleUpdateFile).toBeDefined() + expect(result.current.handleRemoveFile).toBeDefined() + expect(result.current.handleReUploadFile).toBeDefined() + expect(result.current.handleLoadFileFromLink).toBeDefined() + expect(result.current.handleLoadFileFromLinkSuccess).toBeDefined() + expect(result.current.handleLoadFileFromLinkError).toBeDefined() + expect(result.current.handleClearFiles).toBeDefined() + expect(result.current.handleLocalFileUpload).toBeDefined() + expect(result.current.handleClipboardPasteFile).toBeDefined() + expect(result.current.handleDragFileEnter).toBeDefined() + expect(result.current.handleDragFileOver).toBeDefined() + expect(result.current.handleDragFileLeave).toBeDefined() + expect(result.current.handleDropFile).toBeDefined() + expect(result.current.isDragActive).toBe(false) + }) + + it('should add a file via handleAddFile', () => { + const { result } = renderHook(() => useFile(defaultFileConfig)) + + result.current.handleAddFile({ + id: 'test-id', + name: 'test.txt', + type: 'text/plain', + size: 100, + progress: 0, + transferMethod: 'local_file', + supportFileType: 'document', + } as FileEntity) + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should update a file via handleUpdateFile', () => { + mockStoreFiles = [{ id: 'file-1', name: 'a.txt', progress: 0 }] as FileEntity[] + const { result } = renderHook(() => useFile(defaultFileConfig)) + + result.current.handleUpdateFile({ id: 'file-1', name: 'a.txt', progress: 50 } as FileEntity) + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should not update file when id is not found', () => { + mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[] + const { result } = renderHook(() => useFile(defaultFileConfig)) + + result.current.handleUpdateFile({ id: 'nonexistent' } as FileEntity) + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should remove a file via handleRemoveFile', () => { + mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[] + const { result } = renderHook(() => useFile(defaultFileConfig)) + + result.current.handleRemoveFile('file-1') + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should clear all files via handleClearFiles', () => { + mockStoreFiles = [{ id: 'a' }] as FileEntity[] + const { result } = renderHook(() => useFile(defaultFileConfig)) + + result.current.handleClearFiles() + expect(mockSetFiles).toHaveBeenCalledWith([]) + }) + + describe('handleReUploadFile', () => { + it('should re-upload a file and call fileUpload', () => { + const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) + mockStoreFiles = [{ + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 100, + progress: -1, + transferMethod: 'local_file', + supportFileType: 'document', + originalFile, + }] as FileEntity[] + + const { result } = renderHook(() => useFile(defaultFileConfig)) + + result.current.handleReUploadFile('file-1') + expect(mockSetFiles).toHaveBeenCalled() + expect(mockFileUpload).toHaveBeenCalled() + }) + + it('should not re-upload when file id is not found', () => { + mockStoreFiles = [] + const { result } = renderHook(() => useFile(defaultFileConfig)) + + result.current.handleReUploadFile('nonexistent') + expect(mockFileUpload).not.toHaveBeenCalled() + }) + + it('should handle progress callback during re-upload', () => { + const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) + mockStoreFiles = [{ + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 100, + progress: -1, + transferMethod: 'local_file', + supportFileType: 'document', + originalFile, + }] as FileEntity[] + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleReUploadFile('file-1') + + const uploadCall = mockFileUpload.mock.calls[0][0] + uploadCall.onProgressCallback(50) + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should handle success callback during re-upload', () => { + const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) + mockStoreFiles = [{ + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 100, + progress: -1, + transferMethod: 'local_file', + supportFileType: 'document', + originalFile, + }] as FileEntity[] + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleReUploadFile('file-1') + + const uploadCall = mockFileUpload.mock.calls[0][0] + uploadCall.onSuccessCallback({ id: 'uploaded-1' }) + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should handle error callback during re-upload', () => { + const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) + mockStoreFiles = [{ + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 100, + progress: -1, + transferMethod: 'local_file', + supportFileType: 'document', + originalFile, + }] as FileEntity[] + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleReUploadFile('file-1') + + const uploadCall = mockFileUpload.mock.calls[0][0] + uploadCall.onErrorCallback(new Error('fail')) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + }) + + describe('handleLoadFileFromLink', () => { + it('should run startProgressTimer to increment file progress', () => { + vi.useFakeTimers() + mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) // never resolves + + // Set up a file in the store that has progress 0 + mockStoreFiles = [{ + id: 'mock-uuid', + name: 'https://example.com/file.txt', + type: '', + size: 0, + progress: 0, + transferMethod: 'remote_url', + supportFileType: '', + }] as FileEntity[] + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLoadFileFromLink('https://example.com/file.txt') + + // Advance timer to trigger the interval + vi.advanceTimersByTime(200) + expect(mockSetFiles).toHaveBeenCalled() + + vi.useRealTimers() + }) + + it('should add file and call uploadRemoteFileInfo', () => { + mockUploadRemoteFileInfo.mockResolvedValue({ + id: 'remote-1', + mime_type: 'text/plain', + size: 100, + name: 'remote.txt', + url: 'https://example.com/remote.txt', + }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLoadFileFromLink('https://example.com/file.txt') + + expect(mockSetFiles).toHaveBeenCalled() + expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/file.txt', false) + }) + + it('should remove file when extension is not allowed', async () => { + mockIsAllowedFileExtension.mockReturnValue(false) + mockUploadRemoteFileInfo.mockResolvedValue({ + id: 'remote-1', + mime_type: 'text/plain', + size: 100, + name: 'remote.txt', + url: 'https://example.com/remote.txt', + }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + await act(async () => { + result.current.handleLoadFileFromLink('https://example.com/file.txt') + await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled()) + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', async () => { + mockIsAllowedFileExtension.mockReturnValue(false) + mockUploadRemoteFileInfo.mockResolvedValue({ + id: 'remote-1', + mime_type: 'text/plain', + size: 100, + name: 'remote.txt', + url: 'https://example.com/remote.txt', + }) + + const configWithUndefined = { + ...defaultFileConfig, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } as unknown as FileUpload + + const { result } = renderHook(() => useFile(configWithUndefined)) + await act(async () => { + result.current.handleLoadFileFromLink('https://example.com/file.txt') + await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled()) + }) + + expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('remote.txt', 'text/plain', [], []) + }) + + it('should remove file when remote upload fails', async () => { + mockUploadRemoteFileInfo.mockRejectedValue(new Error('network error')) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + await act(async () => { + result.current.handleLoadFileFromLink('https://example.com/file.txt') + await vi.waitFor(() => expect(mockNotify).toHaveBeenCalled()) + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should remove file when size limit is exceeded on remote upload', async () => { + mockGetSupportFileType.mockReturnValue('image') + mockUploadRemoteFileInfo.mockResolvedValue({ + id: 'remote-1', + mime_type: 'image/png', + size: 20 * 1024 * 1024, + name: 'large.png', + url: 'https://example.com/large.png', + }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + await act(async () => { + result.current.handleLoadFileFromLink('https://example.com/large.png') + await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled()) + }) + + // File should be removed because image exceeds 10MB limit + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should update file on successful remote upload within limits', async () => { + mockUploadRemoteFileInfo.mockResolvedValue({ + id: 'remote-1', + mime_type: 'text/plain', + size: 100, + name: 'remote.txt', + url: 'https://example.com/remote.txt', + }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + await act(async () => { + result.current.handleLoadFileFromLink('https://example.com/remote.txt') + await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled()) + }) + + // setFiles should be called: once for add, once for update + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should stop progress timer when file reaches 80 percent', () => { + vi.useFakeTimers() + mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) + + // Set up a file already at 80% progress + mockStoreFiles = [{ + id: 'mock-uuid', + name: 'https://example.com/file.txt', + type: '', + size: 0, + progress: 80, + transferMethod: 'remote_url', + supportFileType: '', + }] as FileEntity[] + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLoadFileFromLink('https://example.com/file.txt') + + // At progress 80, the timer should stop (clearTimeout path) + vi.advanceTimersByTime(200) + + vi.useRealTimers() + }) + + it('should stop progress timer when progress is negative', () => { + vi.useFakeTimers() + mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) + + // Set up a file with negative progress (error state) + mockStoreFiles = [{ + id: 'mock-uuid', + name: 'https://example.com/file.txt', + type: '', + size: 0, + progress: -1, + transferMethod: 'remote_url', + supportFileType: '', + }] as FileEntity[] + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLoadFileFromLink('https://example.com/file.txt') + + vi.advanceTimersByTime(200) + + vi.useRealTimers() + }) + }) + + describe('handleLocalFileUpload', () => { + let capturedListeners: Record void)[]> + let mockReaderResult: string | null + + beforeEach(() => { + capturedListeners = {} + mockReaderResult = 'data:text/plain;base64,Y29udGVudA==' + + class MockFileReader { + result: string | null = null + addEventListener(event: string, handler: () => void) { + if (!capturedListeners[event]) + capturedListeners[event] = [] + capturedListeners[event].push(handler) + } + + readAsDataURL() { + this.result = mockReaderResult + capturedListeners.load?.forEach(handler => handler()) + } + } + vi.stubGlobal('FileReader', MockFileReader) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should upload a local file', () => { + const file = new File(['content'], 'test.txt', { type: 'text/plain' }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should reject file with unsupported extension', () => { + mockIsAllowedFileExtension.mockReturnValue(false) + const file = new File(['content'], 'test.xyz', { type: 'application/xyz' }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + expect(mockSetFiles).not.toHaveBeenCalled() + }) + + it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', () => { + mockIsAllowedFileExtension.mockReturnValue(false) + const file = new File(['content'], 'test.xyz', { type: 'application/xyz' }) + + const configWithUndefined = { + ...defaultFileConfig, + allowed_file_types: undefined, + allowed_file_extensions: undefined, + } as unknown as FileUpload + + const { result } = renderHook(() => useFile(configWithUndefined)) + result.current.handleLocalFileUpload(file) + + expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('test.xyz', 'application/xyz', [], []) + }) + + it('should reject file when upload is disabled and noNeedToCheckEnable is false', () => { + const disabledConfig = { ...defaultFileConfig, enabled: false } as FileUpload + const file = new File(['content'], 'test.txt', { type: 'text/plain' }) + + const { result } = renderHook(() => useFile(disabledConfig, false)) + result.current.handleLocalFileUpload(file) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should reject image file exceeding size limit', () => { + mockGetSupportFileType.mockReturnValue('image') + const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.png', { type: 'image/png' }) + Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(largeFile) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should reject audio file exceeding size limit', () => { + mockGetSupportFileType.mockReturnValue('audio') + const largeFile = new File([], 'large.mp3', { type: 'audio/mpeg' }) + Object.defineProperty(largeFile, 'size', { value: 60 * 1024 * 1024 }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(largeFile) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should reject video file exceeding size limit', () => { + mockGetSupportFileType.mockReturnValue('video') + const largeFile = new File([], 'large.mp4', { type: 'video/mp4' }) + Object.defineProperty(largeFile, 'size', { value: 200 * 1024 * 1024 }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(largeFile) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should reject document file exceeding size limit', () => { + mockGetSupportFileType.mockReturnValue('document') + const largeFile = new File([], 'large.pdf', { type: 'application/pdf' }) + Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(largeFile) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should reject custom file exceeding document size limit', () => { + mockGetSupportFileType.mockReturnValue('custom') + const largeFile = new File([], 'large.xyz', { type: 'application/octet-stream' }) + Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(largeFile) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should allow custom file within document size limit', () => { + mockGetSupportFileType.mockReturnValue('custom') + const file = new File(['content'], 'file.xyz', { type: 'application/octet-stream' }) + Object.defineProperty(file, 'size', { value: 1024 }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockNotify).not.toHaveBeenCalled() + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should allow document file within size limit', () => { + mockGetSupportFileType.mockReturnValue('document') + const file = new File(['content'], 'small.pdf', { type: 'application/pdf' }) + Object.defineProperty(file, 'size', { value: 1024 }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockNotify).not.toHaveBeenCalled() + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should allow file with unknown type (default case)', () => { + mockGetSupportFileType.mockReturnValue('unknown') + const file = new File(['content'], 'test.bin', { type: 'application/octet-stream' }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + // Should not be rejected - unknown type passes checkSizeLimit + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('should allow image file within size limit', () => { + mockGetSupportFileType.mockReturnValue('image') + const file = new File(['content'], 'small.png', { type: 'image/png' }) + Object.defineProperty(file, 'size', { value: 1024 }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockNotify).not.toHaveBeenCalled() + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should allow audio file within size limit', () => { + mockGetSupportFileType.mockReturnValue('audio') + const file = new File(['content'], 'small.mp3', { type: 'audio/mpeg' }) + Object.defineProperty(file, 'size', { value: 1024 }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockNotify).not.toHaveBeenCalled() + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should allow video file within size limit', () => { + mockGetSupportFileType.mockReturnValue('video') + const file = new File(['content'], 'small.mp4', { type: 'video/mp4' }) + Object.defineProperty(file, 'size', { value: 1024 }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockNotify).not.toHaveBeenCalled() + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should set base64Url for image files during upload', () => { + mockGetSupportFileType.mockReturnValue('image') + const file = new File(['content'], 'photo.png', { type: 'image/png' }) + Object.defineProperty(file, 'size', { value: 1024 }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockSetFiles).toHaveBeenCalled() + // The file should have been added with base64Url set (for image type) + const addedFiles = mockSetFiles.mock.calls[0][0] + expect(addedFiles[0].base64Url).toBe('data:text/plain;base64,Y29udGVudA==') + }) + + it('should set empty base64Url for non-image files during upload', () => { + mockGetSupportFileType.mockReturnValue('document') + const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockSetFiles).toHaveBeenCalled() + const addedFiles = mockSetFiles.mock.calls[0][0] + expect(addedFiles[0].base64Url).toBe('') + }) + + it('should call fileUpload with callbacks after FileReader loads', () => { + const file = new File(['content'], 'test.txt', { type: 'text/plain' }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockFileUpload).toHaveBeenCalled() + const uploadCall = mockFileUpload.mock.calls[0][0] + + // Test progress callback + uploadCall.onProgressCallback(50) + expect(mockSetFiles).toHaveBeenCalled() + + // Test success callback + uploadCall.onSuccessCallback({ id: 'uploaded-1' }) + expect(mockSetFiles).toHaveBeenCalled() + }) + + it('should handle fileUpload error callback', () => { + const file = new File(['content'], 'test.txt', { type: 'text/plain' }) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + const uploadCall = mockFileUpload.mock.calls[0][0] + uploadCall.onErrorCallback(new Error('upload failed')) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should handle FileReader error event', () => { + capturedListeners = {} + const errorListeners: (() => void)[] = [] + + class ErrorFileReader { + result: string | null = null + addEventListener(event: string, handler: () => void) { + if (event === 'error') + errorListeners.push(handler) + if (!capturedListeners[event]) + capturedListeners[event] = [] + capturedListeners[event].push(handler) + } + + readAsDataURL() { + // Simulate error instead of load + errorListeners.forEach(handler => handler()) + } + } + vi.stubGlobal('FileReader', ErrorFileReader) + + const file = new File(['content'], 'test.txt', { type: 'text/plain' }) + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + }) + + describe('handleClipboardPasteFile', () => { + it('should handle file paste from clipboard', () => { + const file = new File(['content'], 'pasted.png', { type: 'image/png' }) + const { result } = renderHook(() => useFile(defaultFileConfig)) + + const event = { + clipboardData: { + files: [file], + getData: () => '', + }, + preventDefault: vi.fn(), + } as unknown as React.ClipboardEvent + + result.current.handleClipboardPasteFile(event) + expect(event.preventDefault).toHaveBeenCalled() + }) + + it('should not handle paste when text is present', () => { + const file = new File(['content'], 'pasted.png', { type: 'image/png' }) + const { result } = renderHook(() => useFile(defaultFileConfig)) + + const event = { + clipboardData: { + files: [file], + getData: () => 'some text', + }, + preventDefault: vi.fn(), + } as unknown as React.ClipboardEvent + + result.current.handleClipboardPasteFile(event) + expect(event.preventDefault).not.toHaveBeenCalled() + }) + }) + + describe('drag and drop handlers', () => { + it('should set isDragActive on drag enter', () => { + const { result } = renderHook(() => useFile(defaultFileConfig)) + + const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent + act(() => { + result.current.handleDragFileEnter(event) + }) + + expect(result.current.isDragActive).toBe(true) + }) + + it('should call preventDefault on drag over', () => { + const { result } = renderHook(() => useFile(defaultFileConfig)) + + const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent + result.current.handleDragFileOver(event) + + expect(event.preventDefault).toHaveBeenCalled() + }) + + it('should unset isDragActive on drag leave', () => { + const { result } = renderHook(() => useFile(defaultFileConfig)) + + const enterEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent + act(() => { + result.current.handleDragFileEnter(enterEvent) + }) + expect(result.current.isDragActive).toBe(true) + + const leaveEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent + act(() => { + result.current.handleDragFileLeave(leaveEvent) + }) + expect(result.current.isDragActive).toBe(false) + }) + + it('should handle file drop', () => { + const file = new File(['content'], 'dropped.txt', { type: 'text/plain' }) + const { result } = renderHook(() => useFile(defaultFileConfig)) + + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { files: [file] }, + } as unknown as React.DragEvent + + act(() => { + result.current.handleDropFile(event) + }) + + expect(event.preventDefault).toHaveBeenCalled() + expect(result.current.isDragActive).toBe(false) + }) + + it('should not upload when no file is dropped', () => { + const { result } = renderHook(() => useFile(defaultFileConfig)) + + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { files: [] }, + } as unknown as React.DragEvent + + act(() => { + result.current.handleDropFile(event) + }) + + // No file upload should be triggered + expect(mockSetFiles).not.toHaveBeenCalled() + }) + }) + + describe('noop handlers', () => { + it('should have handleLoadFileFromLinkSuccess as noop', () => { + const { result } = renderHook(() => useFile(defaultFileConfig)) + + expect(() => result.current.handleLoadFileFromLinkSuccess()).not.toThrow() + }) + + it('should have handleLoadFileFromLinkError as noop', () => { + const { result } = renderHook(() => useFile(defaultFileConfig)) + + expect(() => result.current.handleLoadFileFromLinkError()).not.toThrow() + }) + }) +}) diff --git a/web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx b/web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx new file mode 100644 index 0000000000..c2fb780ca8 --- /dev/null +++ b/web/app/components/base/file-uploader/pdf-highlighter-adapter.tsx @@ -0,0 +1,7 @@ +import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter' +import 'react-pdf-highlighter/dist/style.css' + +export { + PdfHighlighter, + PdfLoader, +} diff --git a/web/app/components/base/file-uploader/pdf-preview.spec.tsx b/web/app/components/base/file-uploader/pdf-preview.spec.tsx new file mode 100644 index 0000000000..df07a592ef --- /dev/null +++ b/web/app/components/base/file-uploader/pdf-preview.spec.tsx @@ -0,0 +1,142 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import PdfPreview from './pdf-preview' + +vi.mock('./pdf-highlighter-adapter', () => ({ + PdfLoader: ({ children, beforeLoad }: { children: (doc: unknown) => ReactNode, beforeLoad: ReactNode }) => ( +
+ {beforeLoad} + {children({ numPages: 1 })} +
+ ), + PdfHighlighter: ({ enableAreaSelection, highlightTransform, scrollRef, onScrollChange, onSelectionFinished }: { + enableAreaSelection?: (event: MouseEvent) => boolean + highlightTransform?: () => ReactNode + scrollRef?: (ref: unknown) => void + onScrollChange?: () => void + onSelectionFinished?: () => unknown + }) => { + enableAreaSelection?.(new MouseEvent('click')) + highlightTransform?.() + scrollRef?.(null) + onScrollChange?.() + onSelectionFinished?.() + return
+ }, +})) + +describe('PdfPreview', () => { + const mockOnCancel = vi.fn() + + const getScaleContainer = () => { + const container = document.querySelector('div[style*="transform"]') as HTMLDivElement | null + expect(container).toBeInTheDocument() + return container! + } + + const getControl = (rightClass: 'right-24' | 'right-16' | 'right-6') => { + const control = document.querySelector(`div.absolute.${rightClass}.top-6`) as HTMLDivElement | null + expect(control).toBeInTheDocument() + return control! + } + + beforeEach(() => { + vi.clearAllMocks() + window.innerWidth = 1024 + fireEvent(window, new Event('resize')) + }) + + it('should render the pdf preview portal with overlay and loading indicator', () => { + render() + + expect(document.querySelector('[tabindex="-1"]')).toBeInTheDocument() + expect(screen.getByTestId('pdf-loader')).toBeInTheDocument() + expect(screen.getByTestId('pdf-highlighter')).toBeInTheDocument() + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should render zoom in, zoom out, and close icon SVGs', () => { + render() + + const svgs = document.querySelectorAll('svg') + expect(svgs.length).toBeGreaterThanOrEqual(3) + }) + + it('should zoom in when zoom in control is clicked', () => { + render() + + fireEvent.click(getControl('right-16')) + + expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)') + }) + + it('should zoom out when zoom out control is clicked', () => { + render() + + fireEvent.click(getControl('right-24')) + + expect(getScaleContainer().getAttribute('style')).toMatch(/scale\(0\.8333/) + }) + + it('should keep non-1 scale when zooming out from a larger scale', () => { + render() + + fireEvent.click(getControl('right-16')) + fireEvent.click(getControl('right-16')) + fireEvent.click(getControl('right-24')) + + expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)') + }) + + it('should reset scale back to 1 when zooming in then out', () => { + render() + + fireEvent.click(getControl('right-16')) + fireEvent.click(getControl('right-24')) + + expect(getScaleContainer().getAttribute('style')).toContain('scale(1)') + }) + + it('should zoom in when ArrowUp key is pressed', () => { + render() + + fireEvent.keyDown(document, { key: 'ArrowUp', code: 'ArrowUp' }) + + expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)') + }) + + it('should zoom out when ArrowDown key is pressed', () => { + render() + + fireEvent.keyDown(document, { key: 'ArrowDown', code: 'ArrowDown' }) + + expect(getScaleContainer().getAttribute('style')).toMatch(/scale\(0\.8333/) + }) + + it('should call onCancel when close control is clicked', () => { + render() + + fireEvent.click(getControl('right-6')) + + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('should call onCancel when Escape key is pressed', () => { + render() + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('should render the overlay and stop click propagation', () => { + render() + + const overlay = document.querySelector('[tabindex="-1"]') + expect(overlay).toBeInTheDocument() + const event = new MouseEvent('click', { bubbles: true }) + const stopPropagation = vi.spyOn(event, 'stopPropagation') + overlay!.dispatchEvent(event) + expect(stopPropagation).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/base/file-uploader/pdf-preview.tsx b/web/app/components/base/file-uploader/pdf-preview.tsx index aab8bcd9d1..32b2528cf8 100644 --- a/web/app/components/base/file-uploader/pdf-preview.tsx +++ b/web/app/components/base/file-uploader/pdf-preview.tsx @@ -6,11 +6,10 @@ import * as React from 'react' import { useState } from 'react' import { createPortal } from 'react-dom' import { useHotkeys } from 'react-hotkeys-hook' -import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter' import Loading from '@/app/components/base/loading' import Tooltip from '@/app/components/base/tooltip' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import 'react-pdf-highlighter/dist/style.css' +import { PdfHighlighter, PdfLoader } from './pdf-highlighter-adapter' type PdfPreviewProps = { url: string diff --git a/web/app/components/base/file-uploader/store.spec.tsx b/web/app/components/base/file-uploader/store.spec.tsx new file mode 100644 index 0000000000..96053498d9 --- /dev/null +++ b/web/app/components/base/file-uploader/store.spec.tsx @@ -0,0 +1,168 @@ +import type { FileEntity } from './types' +import { render, renderHook, screen } from '@testing-library/react' +import { TransferMethod } from '@/types/app' +import { createFileStore, FileContext, FileContextProvider, useFileStore, useStore } from './store' + +const createMockFile = (overrides: Partial = {}): FileEntity => ({ + id: 'file-1', + name: 'test.txt', + size: 1024, + type: 'text/plain', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'document', + ...overrides, +}) + +describe('createFileStore', () => { + it('should create a store with empty files by default', () => { + const store = createFileStore() + expect(store.getState().files).toEqual([]) + }) + + it('should create a store with empty array when value is falsy', () => { + const store = createFileStore(undefined) + expect(store.getState().files).toEqual([]) + }) + + it('should create a store with initial files', () => { + const files = [createMockFile()] + const store = createFileStore(files) + expect(store.getState().files).toEqual(files) + }) + + it('should spread initial value to create a new array', () => { + const files = [createMockFile()] + const store = createFileStore(files) + expect(store.getState().files).not.toBe(files) + expect(store.getState().files).toEqual(files) + }) + + it('should update files via setFiles', () => { + const store = createFileStore() + const newFiles = [createMockFile()] + store.getState().setFiles(newFiles) + expect(store.getState().files).toEqual(newFiles) + }) + + it('should call onChange when setFiles is called', () => { + const onChange = vi.fn() + const store = createFileStore([], onChange) + const newFiles = [createMockFile()] + store.getState().setFiles(newFiles) + expect(onChange).toHaveBeenCalledWith(newFiles) + }) + + it('should not throw when onChange is not provided', () => { + const store = createFileStore() + expect(() => store.getState().setFiles([])).not.toThrow() + }) +}) + +describe('useStore', () => { + it('should return selected state from the store', () => { + const files = [createMockFile()] + const store = createFileStore(files) + + const { result } = renderHook(() => useStore(s => s.files), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current).toEqual(files) + }) + + it('should throw when used without FileContext.Provider', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(() => { + renderHook(() => useStore(s => s.files)) + }).toThrow('Missing FileContext.Provider in the tree') + + consoleError.mockRestore() + }) +}) + +describe('useFileStore', () => { + it('should return the store from context', () => { + const store = createFileStore() + + const { result } = renderHook(() => useFileStore(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current).toBe(store) + }) +}) + +describe('FileContextProvider', () => { + it('should render children', () => { + render( + +
Hello
+
, + ) + + expect(screen.getByTestId('child')).toBeInTheDocument() + }) + + it('should provide a store to children', () => { + const TestChild = () => { + const files = useStore(s => s.files) + return
{files.length}
+ } + + render( + + + , + ) + + expect(screen.getByTestId('files')).toHaveTextContent('0') + }) + + it('should initialize store with value prop', () => { + const files = [createMockFile()] + const TestChild = () => { + const storeFiles = useStore(s => s.files) + return
{storeFiles.length}
+ } + + render( + + + , + ) + + expect(screen.getByTestId('files')).toHaveTextContent('1') + }) + + it('should reuse store on re-render instead of creating a new one', () => { + const TestChild = () => { + const storeFiles = useStore(s => s.files) + return
{storeFiles.length}
+ } + + const { rerender } = render( + + + , + ) + + expect(screen.getByTestId('files')).toHaveTextContent('0') + + // Re-render with new value prop - store should be reused (storeRef.current exists) + rerender( + + + , + ) + + // Store was created once on first render, so the value prop change won't create a new store + // The files count should still be 0 since storeRef.current is already set + expect(screen.getByTestId('files')).toHaveTextContent('0') + }) +}) diff --git a/web/app/components/base/file-uploader/utils.spec.ts b/web/app/components/base/file-uploader/utils.spec.ts index f69b3c27f5..358fc586eb 100644 --- a/web/app/components/base/file-uploader/utils.spec.ts +++ b/web/app/components/base/file-uploader/utils.spec.ts @@ -1,4 +1,4 @@ -import mime from 'mime' +import type { FileEntity } from './types' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { upload } from '@/service/base' import { TransferMethod } from '@/types/app' @@ -11,6 +11,7 @@ import { getFileExtension, getFileNameFromUrl, getFilesInLogs, + getFileUploadErrorMessage, getProcessedFiles, getProcessedFilesFromResponse, getSupportFileExtensionList, @@ -18,23 +19,40 @@ import { isAllowedFileExtension, } from './utils' -vi.mock('mime', () => ({ - default: { - getAllExtensions: vi.fn(), - }, -})) - vi.mock('@/service/base', () => ({ upload: vi.fn(), })) describe('file-uploader utils', () => { beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() + }) + + describe('getFileUploadErrorMessage', () => { + const createMockT = () => vi.fn().mockImplementation((key: string) => key) as unknown as import('i18next').TFunction + + it('should return forbidden message when error code is forbidden', () => { + const error = { response: { code: 'forbidden', message: 'Access denied' } } + expect(getFileUploadErrorMessage(error, 'default', createMockT())).toBe('Access denied') + }) + + it('should return file_extension_blocked translation when error code matches', () => { + const error = { response: { code: 'file_extension_blocked' } } + expect(getFileUploadErrorMessage(error, 'default', createMockT())).toBe('fileUploader.fileExtensionBlocked') + }) + + it('should return default message for other errors', () => { + const error = { response: { code: 'unknown_error' } } + expect(getFileUploadErrorMessage(error, 'Upload failed', createMockT())).toBe('Upload failed') + }) + + it('should return default message when error has no response', () => { + expect(getFileUploadErrorMessage(null, 'Upload failed', createMockT())).toBe('Upload failed') + }) }) describe('fileUpload', () => { - it('should handle successful file upload', () => { + it('should handle successful file upload', async () => { const mockFile = new File(['test'], 'test.txt') const mockCallbacks = { onProgressCallback: vi.fn(), @@ -50,32 +68,102 @@ describe('file-uploader utils', () => { }) expect(upload).toHaveBeenCalled() + + // Wait for the promise to resolve and call onSuccessCallback + await vi.waitFor(() => { + expect(mockCallbacks.onSuccessCallback).toHaveBeenCalledWith({ id: '123' }) + }) + }) + + it('should call onErrorCallback when upload fails', async () => { + const mockFile = new File(['test'], 'test.txt') + const mockCallbacks = { + onProgressCallback: vi.fn(), + onSuccessCallback: vi.fn(), + onErrorCallback: vi.fn(), + } + + const uploadError = new Error('Upload failed') + vi.mocked(upload).mockRejectedValue(uploadError) + + fileUpload({ + file: mockFile, + ...mockCallbacks, + }) + + await vi.waitFor(() => { + expect(mockCallbacks.onErrorCallback).toHaveBeenCalledWith(uploadError) + }) + }) + + it('should call onProgressCallback when progress event is computable', () => { + const mockFile = new File(['test'], 'test.txt') + const mockCallbacks = { + onProgressCallback: vi.fn(), + onSuccessCallback: vi.fn(), + onErrorCallback: vi.fn(), + } + + vi.mocked(upload).mockImplementation(({ onprogress }) => { + // Simulate a progress event + if (onprogress) + onprogress.call({} as XMLHttpRequest, { lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent) + + return Promise.resolve({ id: '123' }) + }) + + fileUpload({ + file: mockFile, + ...mockCallbacks, + }) + + expect(mockCallbacks.onProgressCallback).toHaveBeenCalledWith(50) + }) + + it('should not call onProgressCallback when progress event is not computable', () => { + const mockFile = new File(['test'], 'test.txt') + const mockCallbacks = { + onProgressCallback: vi.fn(), + onSuccessCallback: vi.fn(), + onErrorCallback: vi.fn(), + } + + vi.mocked(upload).mockImplementation(({ onprogress }) => { + if (onprogress) + onprogress.call({} as XMLHttpRequest, { lengthComputable: false, loaded: 0, total: 0 } as ProgressEvent) + + return Promise.resolve({ id: '123' }) + }) + + fileUpload({ + file: mockFile, + ...mockCallbacks, + }) + + expect(mockCallbacks.onProgressCallback).not.toHaveBeenCalled() }) }) describe('getFileExtension', () => { it('should get extension from mimetype', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf'])) expect(getFileExtension('file', 'application/pdf')).toBe('pdf') }) - it('should get extension from mimetype and file name 1', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf'])) + it('should get extension from mimetype and file name', () => { expect(getFileExtension('file.pdf', 'application/pdf')).toBe('pdf') }) it('should get extension from mimetype with multiple ext candidates with filename hint', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem'])) expect(getFileExtension('file.pem', 'application/x-x509-ca-cert')).toBe('pem') }) it('should get extension from mimetype with multiple ext candidates without filename hint', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem'])) - expect(getFileExtension('file', 'application/x-x509-ca-cert')).toBe('der') + const ext = getFileExtension('file', 'application/x-x509-ca-cert') + // mime returns Set(['der', 'crt', 'pem']), first value is used when no filename hint + expect(['der', 'crt', 'pem']).toContain(ext) }) - it('should get extension from filename if mimetype fails', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(null) + it('should get extension from filename when mimetype is empty', () => { expect(getFileExtension('file.txt', '')).toBe('txt') expect(getFileExtension('file.txt.docx', '')).toBe('docx') expect(getFileExtension('file', '')).toBe('') @@ -84,164 +172,123 @@ describe('file-uploader utils', () => { it('should return empty string for remote files', () => { expect(getFileExtension('file.txt', '', true)).toBe('') }) + + it('should fall back to filename extension for unknown mimetype', () => { + expect(getFileExtension('file.txt', 'application/unknown')).toBe('txt') + }) + + it('should return empty string for unknown mimetype without filename extension', () => { + expect(getFileExtension('file', 'application/unknown')).toBe('') + }) }) describe('getFileAppearanceType', () => { it('should identify gif files', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['gif'])) expect(getFileAppearanceType('image.gif', 'image/gif')) .toBe(FileAppearanceTypeEnum.gif) }) it('should identify image files', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpg'])) expect(getFileAppearanceType('image.jpg', 'image/jpeg')) .toBe(FileAppearanceTypeEnum.image) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpeg'])) expect(getFileAppearanceType('image.jpeg', 'image/jpeg')) .toBe(FileAppearanceTypeEnum.image) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['png'])) expect(getFileAppearanceType('image.png', 'image/png')) .toBe(FileAppearanceTypeEnum.image) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webp'])) expect(getFileAppearanceType('image.webp', 'image/webp')) .toBe(FileAppearanceTypeEnum.image) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['svg'])) - expect(getFileAppearanceType('image.svg', 'image/svgxml')) + expect(getFileAppearanceType('image.svg', 'image/svg+xml')) .toBe(FileAppearanceTypeEnum.image) }) it('should identify video files', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp4'])) expect(getFileAppearanceType('video.mp4', 'video/mp4')) .toBe(FileAppearanceTypeEnum.video) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mov'])) expect(getFileAppearanceType('video.mov', 'video/quicktime')) .toBe(FileAppearanceTypeEnum.video) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpeg'])) expect(getFileAppearanceType('video.mpeg', 'video/mpeg')) .toBe(FileAppearanceTypeEnum.video) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webm'])) - expect(getFileAppearanceType('video.web', 'video/webm')) + expect(getFileAppearanceType('video.webm', 'video/webm')) .toBe(FileAppearanceTypeEnum.video) }) it('should identify audio files', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp3'])) expect(getFileAppearanceType('audio.mp3', 'audio/mpeg')) .toBe(FileAppearanceTypeEnum.audio) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['m4a'])) expect(getFileAppearanceType('audio.m4a', 'audio/mp4')) .toBe(FileAppearanceTypeEnum.audio) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['wav'])) - expect(getFileAppearanceType('audio.wav', 'audio/vnd.wav')) + expect(getFileAppearanceType('audio.wav', 'audio/wav')) .toBe(FileAppearanceTypeEnum.audio) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['amr'])) expect(getFileAppearanceType('audio.amr', 'audio/AMR')) .toBe(FileAppearanceTypeEnum.audio) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpga'])) expect(getFileAppearanceType('audio.mpga', 'audio/mpeg')) .toBe(FileAppearanceTypeEnum.audio) }) it('should identify code files', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['html'])) expect(getFileAppearanceType('index.html', 'text/html')) .toBe(FileAppearanceTypeEnum.code) }) it('should identify PDF files', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf'])) expect(getFileAppearanceType('doc.pdf', 'application/pdf')) .toBe(FileAppearanceTypeEnum.pdf) }) it('should identify markdown files', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['md'])) expect(getFileAppearanceType('file.md', 'text/markdown')) .toBe(FileAppearanceTypeEnum.markdown) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['markdown'])) expect(getFileAppearanceType('file.markdown', 'text/markdown')) .toBe(FileAppearanceTypeEnum.markdown) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mdx'])) expect(getFileAppearanceType('file.mdx', 'text/mdx')) .toBe(FileAppearanceTypeEnum.markdown) }) it('should identify excel files', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xlsx'])) expect(getFileAppearanceType('doc.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')) .toBe(FileAppearanceTypeEnum.excel) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xls'])) expect(getFileAppearanceType('doc.xls', 'application/vnd.ms-excel')) .toBe(FileAppearanceTypeEnum.excel) }) it('should identify word files', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['doc'])) expect(getFileAppearanceType('doc.doc', 'application/msword')) .toBe(FileAppearanceTypeEnum.word) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['docx'])) expect(getFileAppearanceType('doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')) .toBe(FileAppearanceTypeEnum.word) }) - it('should identify word files', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['ppt'])) + it('should identify ppt files', () => { expect(getFileAppearanceType('doc.ppt', 'application/vnd.ms-powerpoint')) .toBe(FileAppearanceTypeEnum.ppt) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pptx'])) expect(getFileAppearanceType('doc.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation')) .toBe(FileAppearanceTypeEnum.ppt) }) it('should identify document files', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['txt'])) expect(getFileAppearanceType('file.txt', 'text/plain')) .toBe(FileAppearanceTypeEnum.document) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['csv'])) expect(getFileAppearanceType('file.csv', 'text/csv')) .toBe(FileAppearanceTypeEnum.document) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['msg'])) expect(getFileAppearanceType('file.msg', 'application/vnd.ms-outlook')) .toBe(FileAppearanceTypeEnum.document) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['eml'])) expect(getFileAppearanceType('file.eml', 'message/rfc822')) .toBe(FileAppearanceTypeEnum.document) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xml'])) - expect(getFileAppearanceType('file.xml', 'application/rssxml')) + expect(getFileAppearanceType('file.xml', 'application/xml')) .toBe(FileAppearanceTypeEnum.document) - - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['epub'])) - expect(getFileAppearanceType('file.epub', 'application/epubzip')) + expect(getFileAppearanceType('file.epub', 'application/epub+zip')) .toBe(FileAppearanceTypeEnum.document) }) - it('should handle null mime extension', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(null) - expect(getFileAppearanceType('file.txt', 'text/plain')) + it('should fall back to filename extension for unknown mimetype', () => { + expect(getFileAppearanceType('file.txt', 'application/unknown')) .toBe(FileAppearanceTypeEnum.document) }) + + it('should return custom type for unrecognized extensions', () => { + expect(getFileAppearanceType('file.xyz', 'application/xyz')) + .toBe(FileAppearanceTypeEnum.custom) + }) }) describe('getSupportFileType', () => { @@ -278,25 +325,70 @@ describe('file-uploader utils', () => { upload_file_id: '123', }) }) + + it('should fallback to empty string when url is missing', () => { + const files = [{ + id: '123', + name: 'test.txt', + size: 1024, + type: 'text/plain', + progress: 100, + supportFileType: 'document', + transferMethod: TransferMethod.local_file, + url: undefined, + uploadedId: '123', + }] as unknown as FileEntity[] + + const result = getProcessedFiles(files) + expect(result[0].url).toBe('') + }) + + it('should fallback to empty string when uploadedId is missing', () => { + const files = [{ + id: '123', + name: 'test.txt', + size: 1024, + type: 'text/plain', + progress: 100, + supportFileType: 'document', + transferMethod: TransferMethod.local_file, + url: 'http://example.com', + uploadedId: undefined, + }] as unknown as FileEntity[] + + const result = getProcessedFiles(files) + expect(result[0].upload_file_id).toBe('') + }) + + it('should filter out files with progress -1', () => { + const files = [ + { + id: '1', + name: 'good.txt', + progress: 100, + supportFileType: 'document', + transferMethod: TransferMethod.local_file, + url: 'http://example.com', + uploadedId: '1', + }, + { + id: '2', + name: 'bad.txt', + progress: -1, + supportFileType: 'document', + transferMethod: TransferMethod.local_file, + url: 'http://example.com', + uploadedId: '2', + }, + ] as unknown as FileEntity[] + + const result = getProcessedFiles(files) + expect(result).toHaveLength(1) + expect(result[0].upload_file_id).toBe('1') + }) }) describe('getProcessedFilesFromResponse', () => { - beforeEach(() => { - vi.mocked(mime.getAllExtensions).mockImplementation((mimeType: string) => { - const mimeMap: Record> = { - 'image/jpeg': new Set(['jpg', 'jpeg']), - 'image/png': new Set(['png']), - 'image/gif': new Set(['gif']), - 'video/mp4': new Set(['mp4']), - 'audio/mp3': new Set(['mp3']), - 'application/pdf': new Set(['pdf']), - 'text/plain': new Set(['txt']), - 'application/json': new Set(['json']), - } - return mimeMap[mimeType] || new Set() - }) - }) - it('should process files correctly without type correction', () => { const files = [{ related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9', @@ -367,7 +459,7 @@ describe('file-uploader utils', () => { extension: '.mp3', filename: 'audio.mp3', size: 1024, - mime_type: 'audio/mp3', + mime_type: 'audio/mpeg', transfer_method: TransferMethod.local_file, type: 'document', url: 'https://example.com/audio.mp3', @@ -415,7 +507,7 @@ describe('file-uploader utils', () => { expect(result[0].supportFileType).toBe('document') }) - it('should NOT correct when filename and MIME type both point to wrong type', () => { + it('should NOT correct when filename and MIME type both point to same type', () => { const files = [{ related_id: '123', extension: '.jpg', @@ -540,6 +632,11 @@ describe('file-uploader utils', () => { expect(getFileNameFromUrl('http://example.com/path/file.txt')) .toBe('file.txt') }) + + it('should return empty string for URL ending with slash', () => { + expect(getFileNameFromUrl('http://example.com/path/')) + .toBe('') + }) }) describe('getSupportFileExtensionList', () => { @@ -599,7 +696,6 @@ describe('file-uploader utils', () => { describe('isAllowedFileExtension', () => { it('should validate allowed file extensions', () => { - vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf'])) expect(isAllowedFileExtension( 'test.pdf', 'application/pdf', diff --git a/web/app/components/base/file-uploader/video-preview.spec.tsx b/web/app/components/base/file-uploader/video-preview.spec.tsx new file mode 100644 index 0000000000..2384281c8e --- /dev/null +++ b/web/app/components/base/file-uploader/video-preview.spec.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render } from '@testing-library/react' +import VideoPreview from './video-preview' + +describe('VideoPreview', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render video element with correct title', () => { + render() + + const video = document.querySelector('video') + expect(video).toBeInTheDocument() + expect(video).toHaveAttribute('title', 'Test Video') + }) + + it('should render source element with correct src and type', () => { + render() + + const source = document.querySelector('source') + expect(source).toHaveAttribute('src', 'https://example.com/video.mp4') + expect(source).toHaveAttribute('type', 'video/mp4') + }) + + it('should render close button with icon', () => { + const { getByTestId } = render() + + const closeIcon = getByTestId('video-preview-close-btn') + expect(closeIcon).toBeInTheDocument() + }) + + it('should call onCancel when close button is clicked', () => { + const onCancel = vi.fn() + const { getByTestId } = render() + + const closeIcon = getByTestId('video-preview-close-btn') + fireEvent.click(closeIcon.parentElement!) + + expect(onCancel).toHaveBeenCalled() + }) + + it('should stop propagation when backdrop is clicked', () => { + const { baseElement } = render() + + const backdrop = baseElement.querySelector('[tabindex="-1"]') + const event = new MouseEvent('click', { bubbles: true }) + const stopPropagation = vi.spyOn(event, 'stopPropagation') + backdrop!.dispatchEvent(event) + + expect(stopPropagation).toHaveBeenCalled() + }) + + it('should call onCancel when Escape key is pressed', () => { + const onCancel = vi.fn() + + render() + + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + expect(onCancel).toHaveBeenCalled() + }) + + it('should render in a portal attached to document.body', () => { + render() + + const video = document.querySelector('video') + expect(video?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body) + }) +}) diff --git a/web/app/components/base/file-uploader/video-preview.tsx b/web/app/components/base/file-uploader/video-preview.tsx index 94d9a94c58..e328f58770 100644 --- a/web/app/components/base/file-uploader/video-preview.tsx +++ b/web/app/components/base/file-uploader/video-preview.tsx @@ -1,5 +1,4 @@ import type { FC } from 'react' -import { RiCloseLine } from '@remixicon/react' import * as React from 'react' import { createPortal } from 'react-dom' import { useHotkeys } from 'react-hotkeys-hook' @@ -35,7 +34,7 @@ const VideoPreview: FC = ({ className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]" onClick={onCancel} > - +
, document.body, diff --git a/web/app/components/base/image-uploader/image-preview.tsx b/web/app/components/base/image-uploader/image-preview.tsx index 0641af3d79..cffbde2755 100644 --- a/web/app/components/base/image-uploader/image-preview.tsx +++ b/web/app/components/base/image-uploader/image-preview.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' +import { RiAddBoxLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { t } from 'i18next' import * as React from 'react' @@ -256,7 +256,7 @@ const ImagePreview: FC = ({ className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]" onClick={onCancel} > - +
, diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index b5c02271b9..c1b87efac3 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1953,11 +1953,6 @@ "count": 1 } }, - "app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - } - }, "app/components/base/file-uploader/hooks.ts": { "ts/no-explicit-any": { "count": 3 @@ -1969,9 +1964,6 @@ } }, "app/components/base/file-uploader/utils.spec.ts": { - "test/no-identical-title": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } From 0eaae4f573b0baa4a5e8abddac8364d93fa32cd5 Mon Sep 17 00:00:00 2001 From: Saumya Talwani <68903741+saumyatalwani@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:52:43 +0530 Subject: [PATCH 004/129] test: added tests for some base components (#32370) --- .../base/auto-height-textarea/index.spec.tsx | 24 ++++----- .../base/button/add-button.spec.tsx | 8 +-- web/app/components/base/button/add-button.tsx | 2 +- .../base/button/sync-button.spec.tsx | 10 ++-- .../components/base/carousel/index.spec.tsx | 23 +++++++-- web/app/components/base/drawer/index.tsx | 13 ++++- .../base/float-right-container/index.spec.tsx | 51 +++++++++++++++++-- web/eslint-suppressions.json | 6 --- 8 files changed, 98 insertions(+), 39 deletions(-) diff --git a/web/app/components/base/auto-height-textarea/index.spec.tsx b/web/app/components/base/auto-height-textarea/index.spec.tsx index 2eab1ba82e..f6ac0670df 100644 --- a/web/app/components/base/auto-height-textarea/index.spec.tsx +++ b/web/app/components/base/auto-height-textarea/index.spec.tsx @@ -1,5 +1,4 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' import { sleep } from '@/utils' import AutoHeightTextarea from './index' @@ -18,8 +17,8 @@ describe('AutoHeightTextarea', () => { describe('Rendering', () => { it('should render without crashing', () => { - render() - const textarea = document.querySelector('textarea') + const { container } = render() + const textarea = container.querySelector('textarea') expect(textarea).toBeInTheDocument() }) @@ -37,26 +36,26 @@ describe('AutoHeightTextarea', () => { describe('Props', () => { it('should apply custom className to textarea', () => { - render() - const textarea = document.querySelector('textarea') + const { container } = render() + const textarea = container.querySelector('textarea') expect(textarea).toHaveClass('custom-class') }) it('should apply custom wrapperClassName to wrapper div', () => { - render() - const wrapper = document.querySelector('div.relative') + const { container } = render() + const wrapper = container.querySelector('div.relative') expect(wrapper).toHaveClass('wrapper-class') }) it('should apply minHeight and maxHeight styles to hidden div', () => { - render() - const hiddenDiv = document.querySelector('div.invisible') + const { container } = render() + const hiddenDiv = container.querySelector('div.invisible') expect(hiddenDiv).toHaveStyle({ minHeight: '50px', maxHeight: '200px' }) }) it('should use default minHeight and maxHeight when not provided', () => { - render() - const hiddenDiv = document.querySelector('div.invisible') + const { container } = render() + const hiddenDiv = container.querySelector('div.invisible') expect(hiddenDiv).toHaveStyle({ minHeight: '36px', maxHeight: '96px' }) }) @@ -64,6 +63,7 @@ describe('AutoHeightTextarea', () => { const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') render() expect(focusSpy).toHaveBeenCalled() + focusSpy.mockRestore() }) }) @@ -122,7 +122,7 @@ describe('AutoHeightTextarea', () => { it('should handle newlines in value', () => { const textWithNewlines = 'line1\nline2\nline3' render() - const textarea = document.querySelector('textarea') + const textarea = screen.getByRole('textbox') expect(textarea).toHaveValue(textWithNewlines) }) diff --git a/web/app/components/base/button/add-button.spec.tsx b/web/app/components/base/button/add-button.spec.tsx index 658c032bb7..ad27753211 100644 --- a/web/app/components/base/button/add-button.spec.tsx +++ b/web/app/components/base/button/add-button.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import AddButton from './add-button' describe('AddButton', () => { @@ -9,9 +9,9 @@ describe('AddButton', () => { }) it('should render an add icon', () => { - const { container } = render() - const svg = container.querySelector('span') - expect(svg).toBeInTheDocument() + render() + const iconSpan = screen.getByTestId('add-button').querySelector('span') + expect(iconSpan).toBeInTheDocument() }) }) diff --git a/web/app/components/base/button/add-button.tsx b/web/app/components/base/button/add-button.tsx index 50a39ffe7c..92f9ae6800 100644 --- a/web/app/components/base/button/add-button.tsx +++ b/web/app/components/base/button/add-button.tsx @@ -13,7 +13,7 @@ const AddButton: FC = ({ onClick, }) => { return ( -
+
) diff --git a/web/app/components/base/button/sync-button.spec.tsx b/web/app/components/base/button/sync-button.spec.tsx index eeaf60d46e..8876229c28 100644 --- a/web/app/components/base/button/sync-button.spec.tsx +++ b/web/app/components/base/button/sync-button.spec.tsx @@ -13,9 +13,9 @@ describe('SyncButton', () => { }) it('should render a refresh icon', () => { - const { container } = render() - const svg = container.querySelector('span') - expect(svg).toBeInTheDocument() + render() + const iconSpan = screen.getByTestId('sync-button').querySelector('span') + expect(iconSpan).toBeInTheDocument() }) }) @@ -38,7 +38,7 @@ describe('SyncButton', () => { it('should call onClick when clicked', () => { const onClick = vi.fn() render() - const clickableDiv = screen.getByTestId('sync-button')! + const clickableDiv = screen.getByTestId('sync-button') fireEvent.click(clickableDiv) expect(onClick).toHaveBeenCalledTimes(1) }) @@ -46,7 +46,7 @@ describe('SyncButton', () => { it('should call onClick multiple times on repeated clicks', () => { const onClick = vi.fn() render() - const clickableDiv = screen.getByTestId('sync-button')! + const clickableDiv = screen.getByTestId('sync-button') fireEvent.click(clickableDiv) fireEvent.click(clickableDiv) fireEvent.click(clickableDiv) diff --git a/web/app/components/base/carousel/index.spec.tsx b/web/app/components/base/carousel/index.spec.tsx index 06434a51aa..6bce414ee7 100644 --- a/web/app/components/base/carousel/index.spec.tsx +++ b/web/app/components/base/carousel/index.spec.tsx @@ -1,7 +1,6 @@ import type { Mock } from 'vitest' import { act, fireEvent, render, screen } from '@testing-library/react' import useEmblaCarousel from 'embla-carousel-react' -import { beforeEach, describe, expect, it, vi } from 'vitest' import { Carousel, useCarousel } from './index' vi.mock('embla-carousel-react', () => ({ @@ -52,7 +51,9 @@ const createMockEmblaApi = (): MockEmblaApi => ({ }) const emitEmblaEvent = (event: EmblaEventName, api: MockEmblaApi | undefined = mockApi) => { - listeners[event].forEach(callback => callback(api)) + listeners[event].forEach((callback) => { + callback(api) + }) } const renderCarouselWithControls = (orientation: 'horizontal' | 'vertical' = 'horizontal') => { @@ -60,6 +61,8 @@ const renderCarouselWithControls = (orientation: 'horizontal' | 'vertical' = 'ho Slide 1 + Slide 2 + Slide 3 Prev Next @@ -68,6 +71,13 @@ const renderCarouselWithControls = (orientation: 'horizontal' | 'vertical' = 'ho ) } +const mockPlugin = () => ({ + name: 'mock', + options: {}, + init: vi.fn(), + destroy: vi.fn(), +}) + describe('Carousel', () => { beforeEach(() => { vi.clearAllMocks() @@ -90,22 +100,25 @@ describe('Carousel', () => { expect(screen.getByRole('region')).toHaveAttribute('aria-roledescription', 'carousel') expect(screen.getByTestId('carousel-content')).toHaveClass('flex') - expect(screen.getByRole('group')).toHaveAttribute('aria-roledescription', 'slide') + screen.getAllByRole('group').forEach((slide) => { + expect(slide).toHaveAttribute('aria-roledescription', 'slide') + }) }) }) // Props should be translated into Embla options and visible layout. describe('Props', () => { it('should configure embla with horizontal axis when orientation is omitted', () => { + const plugin = mockPlugin() render( - + , ) expect(mockedUseEmblaCarousel).toHaveBeenCalledWith( { loop: true, axis: 'x' }, - ['plugin-marker'], + [plugin], ) }) diff --git a/web/app/components/base/drawer/index.tsx b/web/app/components/base/drawer/index.tsx index a145f9a64d..ab01a4114d 100644 --- a/web/app/components/base/drawer/index.tsx +++ b/web/app/components/base/drawer/index.tsx @@ -80,7 +80,18 @@ export default function Drawer({ )} {showClose && ( - + { + if (e.key === 'Enter' || e.key === ' ') + onClose() + }} + role="button" + tabIndex={0} + aria-label={t('operation.close', { ns: 'common' })} + data-testid="close-icon" + /> )}
diff --git a/web/app/components/base/float-right-container/index.spec.tsx b/web/app/components/base/float-right-container/index.spec.tsx index 51713cc527..4cf87b189c 100644 --- a/web/app/components/base/float-right-container/index.spec.tsx +++ b/web/app/components/base/float-right-container/index.spec.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import FloatRightContainer from './index' describe('FloatRightContainer', () => { @@ -94,7 +94,47 @@ describe('FloatRightContainer', () => { const closeIcon = screen.getByTestId('close-icon') expect(closeIcon).toBeInTheDocument() - fireEvent.click(closeIcon!) + await userEvent.click(closeIcon) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when close is done using escape key', async () => { + const onClose = vi.fn() + render( + +
Closable content
+
, + ) + + const closeIcon = screen.getByTestId('close-icon') + closeIcon.focus() + await userEvent.keyboard('{Enter}') + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when close is done using space key', async () => { + const onClose = vi.fn() + render( + +
Closable content
+
, + ) + + const closeIcon = screen.getByTestId('close-icon') + closeIcon.focus() + await userEvent.keyboard(' ') expect(onClose).toHaveBeenCalledTimes(1) }) @@ -129,8 +169,9 @@ describe('FloatRightContainer', () => { isOpen={true} onClose={vi.fn()} title="Empty mobile panel" - children={undefined} - />, + > + {undefined} + , ) expect(await screen.findByRole('dialog')).toBeInTheDocument() diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index c1b87efac3..285cef2018 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1342,12 +1342,6 @@ }, "react/no-nested-component-definitions": { "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 1 - }, - "tailwindcss/no-unnecessary-whitespace": { - "count": 1 } }, "app/components/base/button/add-button.stories.tsx": { From 84533cbfe05138eee8a271e3bf7402b292d0194a Mon Sep 17 00:00:00 2001 From: Tyson Cung <45380903+tysoncung@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:29:17 +0800 Subject: [PATCH 005/129] fix: resolve pyright bad-index errors in parser.py (#32507) --- api/core/tools/utils/parser.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/api/core/tools/utils/parser.py b/api/core/tools/utils/parser.py index 584975de05..67079665e6 100644 --- a/api/core/tools/utils/parser.py +++ b/api/core/tools/utils/parser.py @@ -2,7 +2,7 @@ import re from json import dumps as json_dumps from json import loads as json_loads from json.decoder import JSONDecodeError -from typing import Any +from typing import Any, TypedDict import httpx from flask import request @@ -14,6 +14,12 @@ from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolParamet from core.tools.errors import ToolApiSchemaError, ToolNotSupportedError, ToolProviderNotFoundError +class _OpenAPIInterface(TypedDict): + path: str + method: str + operation: dict[str, Any] + + class ApiBasedToolSchemaParser: @staticmethod def parse_openapi_to_tool_bundle( @@ -35,17 +41,17 @@ class ApiBasedToolSchemaParser: server_url = matched_servers[0] if matched_servers else server_url # list all interfaces - interfaces = [] + interfaces: list[_OpenAPIInterface] = [] for path, path_item in openapi["paths"].items(): methods = ["get", "post", "put", "delete", "patch", "head", "options", "trace"] for method in methods: if method in path_item: interfaces.append( - { - "path": path, - "method": method, - "operation": path_item[method], - } + _OpenAPIInterface( + path=path, + method=method, + operation=path_item[method], + ) ) # get all parameters From ad3a195734c93bd3f2aa9f6acf522b376566ceb4 Mon Sep 17 00:00:00 2001 From: akashseth-ifp Date: Tue, 24 Feb 2026 15:58:12 +0530 Subject: [PATCH 006/129] test(web): increase test coverage for model-provider-page folder (#32374) --- .../base/button/sync-button.spec.tsx | 4 - .../base/voice-input/index.spec.tsx | 52 +--- .../model-provider-page/hooks.spec.ts | 183 +++++++++----- .../model-provider-page/index.spec.tsx | 199 +++++++++++++++ .../install-from-marketplace.spec.tsx | 109 ++++++++ .../deprecated-model-trigger.spec.tsx | 61 +++++ .../model-selector/empty-trigger.spec.tsx | 13 + .../model-selector/feature-icon.spec.tsx | 50 ++++ .../model-selector/index.spec.tsx | 126 ++++++++++ .../model-selector/model-trigger.spec.tsx | 91 +++++++ .../model-selector/popup-item.spec.tsx | 147 +++++++++++ .../model-selector/popup.spec.tsx | 199 +++++++++++++++ .../provider-icon/index.spec.tsx | 97 +++++++ .../system-model-selector/index.spec.tsx | 160 ++++++++++++ .../model-provider-page/utils.spec.ts | 238 ++++++++++++++++++ 15 files changed, 1617 insertions(+), 112 deletions(-) create mode 100644 web/app/components/header/account-setting/model-provider-page/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/install-from-marketplace.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/deprecated-model-trigger.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/empty-trigger.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/model-trigger.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-icon/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/system-model-selector/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/utils.spec.ts diff --git a/web/app/components/base/button/sync-button.spec.tsx b/web/app/components/base/button/sync-button.spec.tsx index 8876229c28..116aaaa7b0 100644 --- a/web/app/components/base/button/sync-button.spec.tsx +++ b/web/app/components/base/button/sync-button.spec.tsx @@ -1,10 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import SyncButton from './sync-button' -vi.mock('ahooks', () => ({ - useBoolean: () => [false, { setTrue: vi.fn(), setFalse: vi.fn() }], -})) - describe('SyncButton', () => { describe('Rendering', () => { it('should render without crashing', () => { diff --git a/web/app/components/base/voice-input/index.spec.tsx b/web/app/components/base/voice-input/index.spec.tsx index 959665cd97..fa32f0425f 100644 --- a/web/app/components/base/voice-input/index.spec.tsx +++ b/web/app/components/base/voice-input/index.spec.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { audioToText } from '@/service/share' @@ -8,7 +8,6 @@ const { mockState, MockRecorder } = vi.hoisted(() => { const state = { params: {} as Record, pathname: '/test', - rafCallback: undefined as (() => void) | undefined, recorderInstances: [] as unknown[], startOverride: null as (() => Promise) | null, analyseData: new Uint8Array(1024).fill(150) as Uint8Array, @@ -55,13 +54,6 @@ vi.mock('./utils', () => ({ convertToMp3: vi.fn(() => new Blob(['test'], { type: 'audio/mp3' })), })) -vi.mock('ahooks', () => ({ - useRafInterval: vi.fn((fn: () => void) => { - mockState.rafCallback = fn - return vi.fn() - }), -})) - describe('VoiceInput', () => { const onConverted = vi.fn() const onCancel = vi.fn() @@ -70,7 +62,6 @@ describe('VoiceInput', () => { vi.clearAllMocks() mockState.params = {} mockState.pathname = '/test' - mockState.rafCallback = undefined mockState.recorderInstances = [] mockState.startOverride = null @@ -101,21 +92,6 @@ describe('VoiceInput', () => { expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:00') }) - it('should increment timer via useRafInterval callback', async () => { - render() - await screen.findByText('common.voiceInput.speaking') - - act(() => { - mockState.rafCallback?.() - }) - expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:01') - - act(() => { - mockState.rafCallback?.() - }) - expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:02') - }) - it('should call onCancel when recording start fails', async () => { mockState.startOverride = () => Promise.reject(new Error('Permission denied')) @@ -177,32 +153,6 @@ describe('VoiceInput', () => { expect(onCancel).toHaveBeenCalled() }) - it('should automatically stop recording after 600 seconds', async () => { - vi.mocked(audioToText).mockResolvedValueOnce({ text: 'auto stopped' }) - mockState.params = { token: 'abc' } - - render() - await screen.findByTestId('voice-input-stop') - - for (let i = 0; i < 600; i++) - act(() => { mockState.rafCallback?.() }) - - await waitFor(() => { - expect(onConverted).toHaveBeenCalledWith('auto stopped') - }) - }) - - it('should show red timer text after 500 seconds', async () => { - render() - await screen.findByTestId('voice-input-stop') - - for (let i = 0; i < 501; i++) - act(() => { mockState.rafCallback?.() }) - - const timer = screen.getByTestId('voice-input-timer') - expect(timer.className).toContain('text-[#F04438]') - }) - it('should draw on canvas with low data values triggering v < 128 clamp', async () => { mockState.analyseData = new Uint8Array(1024).fill(50) diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts b/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts index b264324374..bbcc352144 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts @@ -1,8 +1,22 @@ import type { Mock } from 'vitest' -import { renderHook } from '@testing-library/react' +import type { + DefaultModelResponse, + Model, +} from './declarations' +import { act, renderHook } from '@testing-library/react' import { useLocale } from '@/context/i18n' -import { useLanguage } from './hooks' +import { + ConfigurationMethodEnum, + ModelTypeEnum, +} from './declarations' +import { + useLanguage, + useModelList, + useProviderCredentialsAndLoadBalancing, + useSystemDefaultModelAndModelList, +} from './hooks' +// Mock dependencies vi.mock('@tanstack/react-query', () => ({ useQuery: vi.fn(), useQueryClient: vi.fn(() => ({ @@ -10,17 +24,6 @@ vi.mock('@tanstack/react-query', () => ({ })), })) -// mock use-context-selector -vi.mock('use-context-selector', () => ({ - useContext: vi.fn(), - createContext: () => ({ - Provider: ({ children }: any) => children, - Consumer: ({ children }: any) => children(null), - }), - useContextSelector: vi.fn(), -})) - -// mock service/common functions vi.mock('@/service/common', () => ({ fetchDefaultModal: vi.fn(), fetchModelList: vi.fn(), @@ -30,63 +33,129 @@ vi.mock('@/service/common', () => ({ vi.mock('@/service/use-common', () => ({ commonQueryKeys: { - modelProviders: ['common', 'model-providers'], + modelList: (type: string) => ['model-list', type], + modelProviders: ['model-providers'], + defaultModel: (type: string) => ['default-model', type], }, })) -// mock context hooks vi.mock('@/context/i18n', () => ({ useLocale: vi.fn(() => 'en-US'), })) -vi.mock('@/context/provider-context', () => ({ - useProviderContext: vi.fn(), -})) +const { useQuery } = await import('@tanstack/react-query') +const { fetchModelList, fetchModelProviderCredentials } = await import('@/service/common') -vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: vi.fn(), -})) - -vi.mock('@/context/event-emitter', () => ({ - useEventEmitterContextContext: vi.fn(), -})) - -// mock plugins -vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ - useMarketplacePlugins: vi.fn(), -})) - -vi.mock('@/app/components/plugins/marketplace/utils', () => ({ - getMarketplacePluginsByCollectionId: vi.fn(), -})) - -vi.mock('./provider-added-card', () => ({ - default: vi.fn(), -})) - -afterAll(() => { - vi.resetModules() - vi.clearAllMocks() -}) - -describe('useLanguage', () => { - it('should replace hyphen with underscore in locale', () => { - ;(useLocale as Mock).mockReturnValue('en-US') - const { result } = renderHook(() => useLanguage()) - expect(result.current).toBe('en_US') +describe('hooks', () => { + afterEach(() => { + vi.clearAllMocks() }) - it('should return locale as is if no hyphen exists', () => { - ;(useLocale as Mock).mockReturnValue('enUS') + describe('useLanguage', () => { + it('should replace hyphen with underscore in locale', () => { + ;(useLocale as Mock).mockReturnValue('en-US') + const { result } = renderHook(() => useLanguage()) + expect(result.current).toBe('en_US') + }) - const { result } = renderHook(() => useLanguage()) - expect(result.current).toBe('enUS') + it('should return locale as is if no hyphen exists', () => { + ;(useLocale as Mock).mockReturnValue('enUS') + const { result } = renderHook(() => useLanguage()) + expect(result.current).toBe('enUS') + }) }) - it('should handle multiple hyphens', () => { - ;(useLocale as Mock).mockReturnValue('zh-Hans-CN') + describe('useSystemDefaultModelAndModelList', () => { + it('should return default model state', () => { + const defaultModel = { + provider: { + provider: 'openai', + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + }, + model: 'gpt-3.5', + model_type: ModelTypeEnum.textGeneration, + } as unknown as DefaultModelResponse + const modelList = [{ provider: 'openai', models: [{ model: 'gpt-3.5' }] }] as unknown as Model[] + const { result } = renderHook(() => useSystemDefaultModelAndModelList(defaultModel, modelList)) - const { result } = renderHook(() => useLanguage()) - expect(result.current).toBe('zh_Hans-CN') + expect(result.current[0]).toEqual({ model: 'gpt-3.5', provider: 'openai' }) + }) + + it('should update default model state', () => { + const defaultModel = { + provider: { + provider: 'openai', + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + }, + model: 'gpt-3.5', + model_type: ModelTypeEnum.textGeneration, + } as any + const modelList = [{ provider: 'openai', models: [{ model: 'gpt-3.5' }] }] as any + const { result } = renderHook(() => useSystemDefaultModelAndModelList(defaultModel, modelList)) + + const newModel = { model: 'gpt-4', provider: 'openai' } + act(() => { + result.current[1](newModel) + }) + + expect(result.current[0]).toEqual(newModel) + }) + }) + + describe('useProviderCredentialsAndLoadBalancing', () => { + it('should fetch predefined credentials', async () => { + (useQuery as Mock).mockReturnValue({ + data: { credentials: { key: 'value' }, load_balancing: { enabled: true } }, + isPending: false, + }) + + const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing( + 'openai', + ConfigurationMethodEnum.predefinedModel, + true, + undefined, + 'cred-id', + )) + + expect(result.current.credentials).toEqual({ key: 'value' }) + expect(result.current.loadBalancing).toEqual({ enabled: true }) + expect(fetchModelProviderCredentials).not.toHaveBeenCalled() // useQuery calls it, but we blocked it with mockReturnValue + }) + + it('should fetch custom credentials', () => { + (useQuery as Mock).mockReturnValue({ + data: { credentials: { key: 'value' }, load_balancing: { enabled: true } }, + isPending: false, + }) + + const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing( + 'openai', + ConfigurationMethodEnum.customizableModel, + true, + { __model_name: 'gpt-4', __model_type: ModelTypeEnum.textGeneration }, + 'cred-id', + )) + + expect(result.current.credentials).toEqual({ + key: 'value', + __model_name: 'gpt-4', + __model_type: ModelTypeEnum.textGeneration, + }) + }) + }) + + describe('useModelList', () => { + it('should fetch model list', () => { + (useQuery as Mock).mockReturnValue({ + data: { data: [{ model: 'gpt-4' }] }, + isPending: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => useModelList(ModelTypeEnum.textGeneration)) + + expect(result.current.data).toEqual([{ model: 'gpt-4' }]) + expect(fetchModelList).not.toHaveBeenCalled() + }) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/index.spec.tsx new file mode 100644 index 0000000000..1f1832628c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/index.spec.tsx @@ -0,0 +1,199 @@ +import { act, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { + CurrentSystemQuotaTypeEnum, + CustomConfigurationStatusEnum, + QuotaUnitEnum, +} from './declarations' +import ModelProviderPage from './index' + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + mutateCurrentWorkspace: vi.fn(), + isValidatingCurrentWorkspace: false, + }), +})) + +const mockGlobalState = { + systemFeatures: { enable_marketplace: true }, +} + +const mockQuotaConfig = { + quota_type: CurrentSystemQuotaTypeEnum.free, + quota_unit: QuotaUnitEnum.times, + quota_limit: 100, + quota_used: 1, + last_used: 0, + is_valid: true, +} + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (s: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector(mockGlobalState), +})) + +const mockProviders = [ + { + provider: 'openai', + label: { en_US: 'OpenAI' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }, + { + provider: 'anthropic', + label: { en_US: 'Anthropic' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }, +] + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + modelProviders: mockProviders, + }), +})) + +const mockDefaultModelState = { + data: null, + isLoading: false, +} + +vi.mock('./hooks', () => ({ + useDefaultModel: () => mockDefaultModelState, +})) + +vi.mock('./install-from-marketplace', () => ({ + default: () =>
, +})) + +vi.mock('./provider-added-card', () => ({ + default: ({ provider }: { provider: { provider: string } }) =>
{provider.provider}
, +})) + +vi.mock('./provider-added-card/quota-panel', () => ({ + default: () =>
, +})) + +vi.mock('./system-model-selector', () => ({ + default: () =>
, +})) + +describe('ModelProviderPage', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mockGlobalState.systemFeatures.enable_marketplace = true + mockDefaultModelState.data = null + mockDefaultModelState.isLoading = false + mockProviders.splice(0, mockProviders.length, { + provider: 'openai', + label: { en_US: 'OpenAI' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }, { + provider: 'anthropic', + label: { en_US: 'Anthropic' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should render main elements', () => { + render() + expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument() + expect(screen.getByTestId('system-model-selector')).toBeInTheDocument() + expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument() + }) + + it('should render configured and not configured providers sections', () => { + render() + expect(screen.getByText('openai')).toBeInTheDocument() + expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument() + expect(screen.getByText('anthropic')).toBeInTheDocument() + }) + + it('should filter providers based on search text', () => { + render() + act(() => { + vi.advanceTimersByTime(600) + }) + expect(screen.getByText('openai')).toBeInTheDocument() + expect(screen.queryByText('anthropic')).not.toBeInTheDocument() + }) + + it('should show empty state if no configured providers match', () => { + render() + act(() => { + vi.advanceTimersByTime(600) + }) + expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument() + }) + + it('should hide marketplace section when marketplace feature is disabled', () => { + mockGlobalState.systemFeatures.enable_marketplace = false + + render() + + expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument() + }) + + it('should prioritize fixed providers in visible order', () => { + mockProviders.splice(0, mockProviders.length, { + provider: 'zeta-provider', + label: { en_US: 'Zeta Provider' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }, { + provider: 'langgenius/anthropic/anthropic', + label: { en_US: 'Anthropic Fixed' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }, { + provider: 'langgenius/openai/openai', + label: { en_US: 'OpenAI Fixed' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }) + + render() + + const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent) + expect(renderedProviders).toEqual([ + 'langgenius/openai/openai', + 'langgenius/anthropic/anthropic', + 'zeta-provider', + ]) + expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.spec.tsx b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.spec.tsx new file mode 100644 index 0000000000..e15e082045 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.spec.tsx @@ -0,0 +1,109 @@ +import type { Mock } from 'vitest' +import type { ModelProvider } from './declarations' +import { fireEvent, render, screen } from '@testing-library/react' + +import { describe, expect, it, vi } from 'vitest' +import { useMarketplaceAllPlugins } from './hooks' +import InstallFromMarketplace from './install-from-marketplace' + +// Mock dependencies +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode, href: string }) => {children}, +})) + +vi.mock('next-themes', () => ({ + useTheme: () => ({ theme: 'light' }), +})) + +vi.mock('@/app/components/base/divider', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/plugins/marketplace/list', () => ({ + default: ({ plugins, cardRender }: { plugins: { plugin_id: string, name: string, type?: string }[], cardRender: (plugin: { plugin_id: string, name: string, type?: string }) => React.ReactNode }) => ( +
+ {plugins.map(p => ( +
+ {cardRender(p)} +
+ ))} +
+ ), +})) + +vi.mock('@/app/components/plugins/provider-card', () => ({ + default: ({ payload }: { payload: { name: string } }) =>
{payload.name}
, +})) + +vi.mock('./hooks', () => ({ + useMarketplaceAllPlugins: vi.fn(() => ({ + plugins: [], + isLoading: false, + })), +})) + +describe('InstallFromMarketplace', () => { + const mockProviders = [] as ModelProvider[] + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render expanded by default', () => { + render() + expect(screen.getByText('common.modelProvider.installProvider')).toBeInTheDocument() + expect(screen.getByTestId('plugin-list')).toBeInTheDocument() + }) + + it('should collapse when clicked', () => { + render() + fireEvent.click(screen.getByText('common.modelProvider.installProvider')) + expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument() + }) + + it('should show loading state', () => { + (useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ + plugins: [], + isLoading: true, + }) + + render() + // It's expanded by default, so loading should show immediately + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('should list plugins', () => { + (useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ + plugins: [{ plugin_id: '1', name: 'Plugin 1' }], + isLoading: false, + }) + + render() + // Expanded by default + expect(screen.getByText('Plugin 1')).toBeInTheDocument() + }) + + it('should hide bundle plugins from the list', () => { + (useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ + plugins: [ + { plugin_id: '1', name: 'Plugin 1', type: 'plugin' }, + { plugin_id: '2', name: 'Bundle 1', type: 'bundle' }, + ], + isLoading: false, + }) + + render() + + expect(screen.getByText('Plugin 1')).toBeInTheDocument() + expect(screen.queryByText('Bundle 1')).not.toBeInTheDocument() + }) + + it('should render discovery link', () => { + render() + expect(screen.getByText('plugin.marketplace.difyMarketplace')).toHaveAttribute('href') + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/deprecated-model-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/deprecated-model-trigger.spec.tsx new file mode 100644 index 0000000000..ea31ae192c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/deprecated-model-trigger.spec.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import DeprecatedModelTrigger from './deprecated-model-trigger' + +vi.mock('../model-icon', () => ({ + default: ({ modelName }: { modelName: string }) => {modelName}, +})) + +const mockUseProviderContext = vi.hoisted(() => vi.fn()) +vi.mock('@/context/provider-context', () => ({ + useProviderContext: mockUseProviderContext, +})) + +describe('DeprecatedModelTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseProviderContext.mockReturnValue({ + modelProviders: [{ provider: 'someone-else' }, { provider: 'openai' }], + }) + }) + + it('should render model name', () => { + render() + expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0) + }) + + it('should show deprecated tooltip when warn icon is hovered', async () => { + const { container } = render( + , + ) + + const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement + fireEvent.mouseEnter(tooltipTrigger) + + expect(await screen.findByText('common.modelProvider.deprecated')).toBeInTheDocument() + }) + + it('should render when provider is not found', () => { + mockUseProviderContext.mockReturnValue({ + modelProviders: [{ provider: 'someone-else' }], + }) + + render() + expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0) + }) + + it('should not show deprecated tooltip when warn icon is disabled', async () => { + render( + , + ) + + expect(screen.queryByText('common.modelProvider.deprecated')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/empty-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/empty-trigger.spec.tsx new file mode 100644 index 0000000000..0c35e87ebe --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/empty-trigger.spec.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@testing-library/react' +import EmptyTrigger from './empty-trigger' + +describe('EmptyTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render configure model text', () => { + render() + expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.spec.tsx new file mode 100644 index 0000000000..e785ec58c7 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.spec.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { + ModelFeatureEnum, + ModelFeatureTextEnum, +} from '../declarations' +import FeatureIcon from './feature-icon' + +describe('FeatureIcon', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should show feature label when showFeaturesLabel is true', () => { + render( + <> + + + + + , + ) + + expect(screen.getByText(ModelFeatureTextEnum.vision)).toBeInTheDocument() + expect(screen.getByText(ModelFeatureTextEnum.document)).toBeInTheDocument() + expect(screen.getByText(ModelFeatureTextEnum.audio)).toBeInTheDocument() + expect(screen.getByText(ModelFeatureTextEnum.video)).toBeInTheDocument() + }) + + it('should show tooltip content on hover when showFeaturesLabel is false', async () => { + const cases: Array<{ feature: ModelFeatureEnum, text: string }> = [ + { feature: ModelFeatureEnum.vision, text: ModelFeatureTextEnum.vision }, + { feature: ModelFeatureEnum.document, text: ModelFeatureTextEnum.document }, + { feature: ModelFeatureEnum.audio, text: ModelFeatureTextEnum.audio }, + { feature: ModelFeatureEnum.video, text: ModelFeatureTextEnum.video }, + ] + + for (const { feature, text } of cases) { + const { container, unmount } = render() + fireEvent.mouseEnter(container.firstElementChild as HTMLElement) + expect(await screen.findByText(`common.modelProvider.featureSupported:{"feature":"${text}"}`)) + .toBeInTheDocument() + unmount() + } + }) + + it('should render nothing for unsupported feature', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/index.spec.tsx new file mode 100644 index 0000000000..0491bb0849 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/index.spec.tsx @@ -0,0 +1,126 @@ +import type { Model, ModelItem } from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { + ConfigurationMethodEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '../declarations' +import ModelSelector from './index' + +vi.mock('./model-trigger', () => ({ + default: () =>
model-trigger
, +})) + +vi.mock('./deprecated-model-trigger', () => ({ + default: ({ modelName }: { modelName: string }) =>
{`deprecated:${modelName}`}
, +})) + +vi.mock('./empty-trigger', () => ({ + default: () =>
empty-trigger
, +})) + +vi.mock('./popup', () => ({ + default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (provider: string, model: ModelItem) => void }) => ( + <> + + + + ), +})) + +const makeModelItem = (overrides: Partial = {}): ModelItem => ({ + model: 'gpt-4', + label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + ...overrides, +}) + +const makeModel = (overrides: Partial = {}): Model => ({ + provider: 'openai', + icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [makeModelItem()], + status: ModelStatusEnum.active, + ...overrides, +}) + +describe('ModelSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should toggle popup and close it after selecting a model', () => { + render() + + fireEvent.click(screen.getByText('empty-trigger')) + expect(screen.getByText('select')).toBeInTheDocument() + + fireEvent.click(screen.getByText('select')) + expect(screen.queryByText('select')).not.toBeInTheDocument() + }) + + it('should call onSelect when provided', () => { + const onSelect = vi.fn() + render() + + fireEvent.click(screen.getByText('empty-trigger')) + fireEvent.click(screen.getByText('select')) + + expect(onSelect).toHaveBeenCalledWith({ provider: 'openai', model: 'gpt-4' }) + }) + + it('should close popup when popup requests hide', () => { + render() + + fireEvent.click(screen.getByText('empty-trigger')) + expect(screen.getByText('hide')).toBeInTheDocument() + + fireEvent.click(screen.getByText('hide')) + expect(screen.queryByText('hide')).not.toBeInTheDocument() + }) + + it('should not open popup when readonly', () => { + render() + + fireEvent.click(screen.getByText('empty-trigger')) + expect(screen.queryByText('select')).not.toBeInTheDocument() + }) + + it('should render deprecated trigger when defaultModel is not in list', () => { + const { rerender } = render( + , + ) + + expect(screen.getByText('deprecated:missing-model')).toBeInTheDocument() + + rerender( + , + ) + expect(screen.getByText('deprecated:')).toBeInTheDocument() + }) + + it('should render model trigger when defaultModel matches', () => { + render( + , + ) + + expect(screen.getByText('model-trigger')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/model-trigger.spec.tsx new file mode 100644 index 0000000000..8bcf362faf --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-trigger.spec.tsx @@ -0,0 +1,91 @@ +import type { Model, ModelItem } from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { + ConfigurationMethodEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '../declarations' +import ModelTrigger from './model-trigger' + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks') + return { + ...actual, + useLanguage: () => 'en_US', + } +}) + +vi.mock('../model-icon', () => ({ + default: ({ modelName }: { modelName: string }) => {modelName}, +})) + +vi.mock('../model-name', () => ({ + default: ({ modelItem }: { modelItem: ModelItem }) => {modelItem.label.en_US}, +})) + +const makeModelItem = (overrides: Partial = {}): ModelItem => ({ + model: 'gpt-4', + label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + ...overrides, +}) + +const makeModel = (overrides: Partial = {}): Model => ({ + provider: 'openai', + icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [makeModelItem()], + status: ModelStatusEnum.active, + ...overrides, +}) + +describe('ModelTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should show model name', () => { + render( + , + ) + + expect(screen.getByText('GPT-4')).toBeInTheDocument() + }) + + it('should show status tooltip content when model is not active', async () => { + const { container } = render( + , + ) + + const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement + fireEvent.mouseEnter(tooltipTrigger) + + expect(await screen.findByText('No Configure')).toBeInTheDocument() + }) + + it('should not show status icon when readonly', () => { + render( + , + ) + + expect(screen.getByText('GPT-4')).toBeInTheDocument() + expect(screen.queryByText('No Configure')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx new file mode 100644 index 0000000000..af398f83ba --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx @@ -0,0 +1,147 @@ +import type { DefaultModel, Model, ModelItem } from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { + ConfigurationMethodEnum, + ModelFeatureEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '../declarations' +import PopupItem from './popup-item' + +const mockUpdateModelList = vi.hoisted(() => vi.fn()) +const mockUpdateModelProviders = vi.hoisted(() => vi.fn()) + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks') + return { + ...actual, + useLanguage: () => 'en_US', + useUpdateModelList: () => mockUpdateModelList, + useUpdateModelProviders: () => mockUpdateModelProviders, + } +}) + +vi.mock('../model-badge', () => ({ + default: ({ children }: { children: React.ReactNode }) => {children}, +})) + +vi.mock('../model-icon', () => ({ + default: ({ modelName }: { modelName: string }) => {modelName}, +})) + +vi.mock('../model-name', () => ({ + default: ({ modelItem }: { modelItem: ModelItem }) => {modelItem.label.en_US}, +})) + +const mockSetShowModelModal = vi.hoisted(() => vi.fn()) +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowModelModal: mockSetShowModelModal, + }), +})) + +const mockUseProviderContext = vi.hoisted(() => vi.fn()) +vi.mock('@/context/provider-context', () => ({ + useProviderContext: mockUseProviderContext, +})) + +const makeModelItem = (overrides: Partial = {}): ModelItem => ({ + model: 'gpt-4', + label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, + model_type: ModelTypeEnum.textGeneration, + features: [ModelFeatureEnum.vision], + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: { mode: 'chat', context_size: 4096 }, + load_balancing_enabled: false, + ...overrides, +}) + +const makeModel = (overrides: Partial = {}): Model => ({ + provider: 'openai', + icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [makeModelItem()], + status: ModelStatusEnum.active, + ...overrides, +}) + +describe('PopupItem', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseProviderContext.mockReturnValue({ + modelProviders: [{ provider: 'openai' }], + }) + }) + + it('should call onSelect when clicking an active model', () => { + const onSelect = vi.fn() + render() + + fireEvent.click(screen.getByText('GPT-4')) + + expect(onSelect).toHaveBeenCalledWith('openai', expect.objectContaining({ model: 'gpt-4' })) + }) + + it('should not call onSelect when model is not active', () => { + const onSelect = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('GPT-4')) + + expect(onSelect).not.toHaveBeenCalled() + }) + + it('should open model modal when clicking add on unconfigured model', () => { + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByText('COMMON.OPERATION.ADD')) + + expect(mockSetShowModelModal).toHaveBeenCalled() + + const call = mockSetShowModelModal.mock.calls[0][0] as { onSaveCallback?: () => void } + call.onSaveCallback?.() + + expect(mockUpdateModelProviders).toHaveBeenCalled() + expect(mockUpdateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration) + + rerender( + , + ) + + fireEvent.click(screen.getByText('COMMON.OPERATION.ADD')) + const call2 = mockSetShowModelModal.mock.calls.at(-1)?.[0] as { onSaveCallback?: () => void } | undefined + call2?.onSaveCallback?.() + + expect(mockUpdateModelProviders).toHaveBeenCalled() + expect(mockUpdateModelList).toHaveBeenCalledTimes(1) + }) + + it('should show selected state when defaultModel matches', () => { + const defaultModel: DefaultModel = { provider: 'openai', model: 'gpt-4' } + render( + , + ) + + expect(screen.getByText('GPT-4')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx new file mode 100644 index 0000000000..4083f4a37c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx @@ -0,0 +1,199 @@ +import type { Model, ModelItem } from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { + ConfigurationMethodEnum, + ModelFeatureEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '../declarations' +import Popup from './popup' + +let mockLanguage = 'en_US' + +const mockSetShowAccountSettingModal = vi.hoisted(() => vi.fn()) +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +const mockSupportFunctionCall = vi.hoisted(() => vi.fn()) +vi.mock('@/utils/tool-call', () => ({ + supportFunctionCall: mockSupportFunctionCall, +})) + +const mockCloseActiveTooltip = vi.hoisted(() => vi.fn()) +vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({ + tooltipManager: { + closeActiveTooltip: mockCloseActiveTooltip, + register: vi.fn(), + clear: vi.fn(), + }, +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({ + XCircle: ({ onClick }: { onClick?: () => void }) => ( + + ), +})) + +const mockModel: DefaultModelResponse = { + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + provider: { + provider: 'openai', + icon_small: { en_US: '', zh_Hans: '' }, + }, +} + +const defaultProps = { + textGenerationDefaultModel: mockModel, + embeddingsDefaultModel: undefined, + rerankDefaultModel: undefined, + speech2textDefaultModel: undefined, + ttsDefaultModel: undefined, + notConfigured: false, + isLoading: false, +} + +describe('SystemModel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceManager = true + }) + + it('should render settings button', () => { + render() + expect(screen.getByRole('button', { name: /system model settings/i })).toBeInTheDocument() + }) + + it('should open modal when button is clicked', async () => { + render() + const button = screen.getByRole('button', { name: /system model settings/i }) + fireEvent.click(button) + await waitFor(() => { + expect(screen.getByText(/system reasoning model/i)).toBeInTheDocument() + }) + }) + + it('should disable button when loading', () => { + render() + expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled() + }) + + it('should close modal when cancel is clicked', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: /system model settings/i })) + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + await waitFor(() => { + expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument() + }) + }) + + it('should save selected models and show success feedback', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /system model settings/i })) + await waitFor(() => { + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() + }) + + const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' }) + selectorButtons.forEach(button => fireEvent.click(button)) + + fireEvent.click(screen.getByRole('button', { name: /save/i })) + + await waitFor(() => { + expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'Modified successfully', + }) + expect(mockUpdateModelList).toHaveBeenCalledTimes(5) + }) + }) + + it('should disable save when user is not workspace manager', async () => { + mockIsCurrentWorkspaceManager = false + render() + + fireEvent.click(screen.getByRole('button', { name: /system model settings/i })) + await waitFor(() => { + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/utils.spec.ts b/web/app/components/header/account-setting/model-provider-page/utils.spec.ts new file mode 100644 index 0000000000..9ed1663d0c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/utils.spec.ts @@ -0,0 +1,238 @@ +import type { Mock } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { + deleteModelProvider, + setModelProvider, + validateModelLoadBalancingCredentials, + validateModelProvider, +} from '@/service/common' +import { ValidatedStatus } from '../key-validator/declarations' +import { + ConfigurationMethodEnum, + FormTypeEnum, + ModelTypeEnum, +} from './declarations' +import { + genModelNameFormSchema, + genModelTypeFormSchema, + modelTypeFormat, + removeCredentials, + saveCredentials, + savePredefinedLoadBalancingConfig, + sizeFormat, + validateCredentials, + validateLoadBalancingCredentials, +} from './utils' + +// Mock service/common functions +vi.mock('@/service/common', () => ({ + deleteModelProvider: vi.fn(), + setModelProvider: vi.fn(), + validateModelLoadBalancingCredentials: vi.fn(), + validateModelProvider: vi.fn(), +})) + +describe('utils', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + describe('sizeFormat', () => { + it('should format size less than 1000', () => { + expect(sizeFormat(500)).toBe('500') + }) + + it('should format size greater than 1000', () => { + expect(sizeFormat(1500)).toBe('1K') + }) + }) + + describe('modelTypeFormat', () => { + it('should format text embedding type', () => { + expect(modelTypeFormat(ModelTypeEnum.textEmbedding)).toBe('TEXT EMBEDDING') + }) + + it('should format other types', () => { + expect(modelTypeFormat(ModelTypeEnum.textGeneration)).toBe('LLM') + }) + }) + + describe('validateCredentials', () => { + it('should validate predefined credentials successfully', async () => { + (validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'success' }) + const result = await validateCredentials(true, 'provider', { key: 'value' }) + expect(result).toEqual({ status: ValidatedStatus.Success }) + expect(validateModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/credentials/validate', + body: { credentials: { key: 'value' } }, + }) + }) + + it('should validate custom credentials successfully', async () => { + (validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'success' }) + const result = await validateCredentials(false, 'provider', { + __model_name: 'model', + __model_type: 'type', + key: 'value', + }) + expect(result).toEqual({ status: ValidatedStatus.Success }) + expect(validateModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models/credentials/validate', + body: { + model: 'model', + model_type: 'type', + credentials: { key: 'value' }, + }, + }) + }) + + it('should handle validation failure', async () => { + (validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'error', error: 'failed' }) + const result = await validateCredentials(true, 'provider', {}) + expect(result).toEqual({ status: ValidatedStatus.Error, message: 'failed' }) + }) + + it('should handle exception', async () => { + (validateModelProvider as unknown as Mock).mockRejectedValue(new Error('network error')) + const result = await validateCredentials(true, 'provider', {}) + expect(result).toEqual({ status: ValidatedStatus.Error, message: 'network error' }) + }) + }) + + describe('validateLoadBalancingCredentials', () => { + it('should validate load balancing credentials successfully', async () => { + (validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'success' }) + const result = await validateLoadBalancingCredentials(true, 'provider', { + __model_name: 'model', + __model_type: 'type', + key: 'value', + }) + expect(result).toEqual({ status: ValidatedStatus.Success }) + expect(validateModelLoadBalancingCredentials).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models/load-balancing-configs/credentials-validate', + body: { + model: 'model', + model_type: 'type', + credentials: { key: 'value' }, + }, + }) + }) + it('should validate load balancing credentials successfully with id', async () => { + (validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'success' }) + const result = await validateLoadBalancingCredentials(true, 'provider', { + __model_name: 'model', + __model_type: 'type', + key: 'value', + }, 'id') + expect(result).toEqual({ status: ValidatedStatus.Success }) + expect(validateModelLoadBalancingCredentials).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models/load-balancing-configs/id/credentials-validate', + body: { + model: 'model', + model_type: 'type', + credentials: { key: 'value' }, + }, + }) + }) + + it('should handle validation failure', async () => { + (validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'error', error: 'failed' }) + const result = await validateLoadBalancingCredentials(true, 'provider', {}) + expect(result).toEqual({ status: ValidatedStatus.Error, message: 'failed' }) + }) + }) + + describe('saveCredentials', () => { + it('should save predefined credentials', async () => { + await saveCredentials(true, 'provider', { __authorization_name__: 'name', key: 'value' }) + expect(setModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/credentials', + body: { + config_from: ConfigurationMethodEnum.predefinedModel, + credentials: { key: 'value' }, + load_balancing: undefined, + name: 'name', + }, + }) + }) + + it('should save custom credentials', async () => { + await saveCredentials(false, 'provider', { + __model_name: 'model', + __model_type: 'type', + key: 'value', + }) + expect(setModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models', + body: { + model: 'model', + model_type: 'type', + credentials: { key: 'value' }, + load_balancing: undefined, + }, + }) + }) + }) + + describe('savePredefinedLoadBalancingConfig', () => { + it('should save predefined load balancing config', async () => { + await savePredefinedLoadBalancingConfig('provider', { + __model_name: 'model', + __model_type: 'type', + key: 'value', + }) + expect(setModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models', + body: { + config_from: ConfigurationMethodEnum.predefinedModel, + model: 'model', + model_type: 'type', + credentials: { key: 'value' }, + load_balancing: undefined, + }, + }) + }) + }) + + describe('removeCredentials', () => { + it('should remove predefined credentials', async () => { + await removeCredentials(true, 'provider', {}, 'id') + expect(deleteModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/credentials', + body: { credential_id: 'id' }, + }) + }) + + it('should remove custom credentials', async () => { + await removeCredentials(false, 'provider', { + __model_name: 'model', + __model_type: 'type', + }) + expect(deleteModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models', + body: { + model: 'model', + model_type: 'type', + }, + }) + }) + }) + + describe('genModelTypeFormSchema', () => { + it('should generate form schema', () => { + const schema = genModelTypeFormSchema([ModelTypeEnum.textGeneration]) + expect(schema.type).toBe(FormTypeEnum.select) + expect(schema.variable).toBe('__model_type') + expect(schema.options[0].value).toBe(ModelTypeEnum.textGeneration) + }) + }) + + describe('genModelNameFormSchema', () => { + it('should generate form schema', () => { + const schema = genModelNameFormSchema() + expect(schema.type).toBe(FormTypeEnum.textInput) + expect(schema.variable).toBe('__model_name') + expect(schema.required).toBe(true) + }) + }) +}) From b2fa6cb4d3c14a08f6fa7218014a23ae2af4fafe Mon Sep 17 00:00:00 2001 From: Poojan Date: Tue, 24 Feb 2026 15:59:21 +0530 Subject: [PATCH 007/129] test: add unit tests for chat components (#32367) --- .../chat/chat/answer/agent-content.spec.tsx | 114 +++ .../base/chat/chat/answer/agent-content.tsx | 26 +- .../chat/chat/answer/basic-content.spec.tsx | 91 +++ .../base/chat/chat/answer/basic-content.tsx | 11 +- .../human-input-content/content-item.spec.tsx | 111 +++ .../human-input-content/content-item.tsx | 1 + .../content-wrapper.spec.tsx | 46 ++ .../human-input-content/content-wrapper.tsx | 18 +- .../executed-action.spec.tsx | 23 + .../human-input-content/executed-action.tsx | 9 +- .../expiration-time.spec.tsx | 38 + .../human-input-content/expiration-time.tsx | 8 +- .../human-input-form.spec.tsx | 132 ++++ .../human-input-content/human-input-form.tsx | 1 + .../submitted-content.spec.tsx | 17 + .../human-input-content/submitted-content.tsx | 4 +- .../human-input-content/submitted.spec.tsx | 31 + .../answer/human-input-content/tips.spec.tsx | 83 ++ .../chat/answer/human-input-content/tips.tsx | 8 +- .../human-input-content/unsubmitted.spec.tsx | 212 +++++ .../human-input-filled-form-list.spec.tsx | 58 ++ .../answer/human-input-form-list.spec.tsx | 131 ++++ .../chat/answer/human-input-form-list.tsx | 30 +- .../base/chat/chat/answer/more.spec.tsx | 65 ++ .../components/base/chat/chat/answer/more.tsx | 9 +- .../base/chat/chat/answer/operation.spec.tsx | 726 ++++++++++++++++++ .../base/chat/chat/answer/operation.tsx | 57 +- .../chat/answer/suggested-questions.spec.tsx | 83 ++ .../chat/chat/answer/suggested-questions.tsx | 3 +- .../chat/chat/answer/tool-detail.spec.tsx | 74 ++ .../chat/answer/workflow-process.spec.tsx | 109 +++ .../chat/chat/answer/workflow-process.tsx | 37 +- .../chat/chat/chat-input-area/index.spec.tsx | 568 ++++++++++++++ .../chat/chat-input-area/operation.spec.tsx | 170 ++++ .../chat/chat/chat-input-area/operation.tsx | 2 + .../base/chat/chat/citation/index.spec.tsx | 364 +++++++++ .../base/chat/chat/citation/index.tsx | 36 +- .../base/chat/chat/citation/popup.spec.tsx | 609 +++++++++++++++ .../base/chat/chat/citation/popup.tsx | 127 ++- .../chat/citation/progress-tooltip.spec.tsx | 144 ++++ .../chat/chat/citation/progress-tooltip.tsx | 11 +- .../base/chat/chat/citation/tooltip.spec.tsx | 155 ++++ .../base/chat/chat/citation/tooltip.tsx | 4 +- .../base/chat/chat/content-switch.spec.tsx | 79 ++ .../base/chat/chat/content-switch.tsx | 2 + .../base/chat/chat/context.spec.tsx | 94 +++ .../components/base/chat/chat/index.spec.tsx | 606 +++++++++++++++ web/app/components/base/chat/chat/index.tsx | 21 +- .../chat/chat/loading-anim/index.spec.tsx | 22 + .../base/chat/chat/log/index.spec.tsx | 129 ++++ .../base/chat/chat/question.spec.tsx | 267 +++++++ .../components/base/chat/chat/question.tsx | 24 +- .../base/chat/chat/thought/index.spec.tsx | 345 +++++++++ .../base/chat/chat/try-to-ask.spec.tsx | 102 +++ web/eslint-suppressions.json | 64 -- 55 files changed, 6044 insertions(+), 267 deletions(-) create mode 100644 web/app/components/base/chat/chat/answer/agent-content.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/basic-content.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/content-item.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/executed-action.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/expiration-time.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/human-input-form.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/submitted-content.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/submitted.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/tips.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-filled-form-list.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-form-list.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/more.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/operation.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/suggested-questions.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/tool-detail.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/workflow-process.spec.tsx create mode 100644 web/app/components/base/chat/chat/chat-input-area/index.spec.tsx create mode 100644 web/app/components/base/chat/chat/chat-input-area/operation.spec.tsx create mode 100644 web/app/components/base/chat/chat/citation/index.spec.tsx create mode 100644 web/app/components/base/chat/chat/citation/popup.spec.tsx create mode 100644 web/app/components/base/chat/chat/citation/progress-tooltip.spec.tsx create mode 100644 web/app/components/base/chat/chat/citation/tooltip.spec.tsx create mode 100644 web/app/components/base/chat/chat/content-switch.spec.tsx create mode 100644 web/app/components/base/chat/chat/context.spec.tsx create mode 100644 web/app/components/base/chat/chat/index.spec.tsx create mode 100644 web/app/components/base/chat/chat/loading-anim/index.spec.tsx create mode 100644 web/app/components/base/chat/chat/log/index.spec.tsx create mode 100644 web/app/components/base/chat/chat/question.spec.tsx create mode 100644 web/app/components/base/chat/chat/thought/index.spec.tsx create mode 100644 web/app/components/base/chat/chat/try-to-ask.spec.tsx 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 = ({ >
-