+
)
@@ -53,21 +53,17 @@ const NavLink = ({
key={name}
type='button'
disabled
- className={classNames(
- 'system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
- 'pl-3 pr-1',
- )}
+ className={cn('system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
+ 'pl-3 pr-1')}
title={mode === 'collapse' ? name : ''}
aria-disabled
>
{renderIcon()}
{name}
@@ -79,22 +75,18 @@ const NavLink = ({
{renderIcon()}
{name}
diff --git a/web/app/components/app-sidebar/toggle-button.tsx b/web/app/components/app-sidebar/toggle-button.tsx
index 8de6f887f6..4f69adfc34 100644
--- a/web/app/components/app-sidebar/toggle-button.tsx
+++ b/web/app/components/app-sidebar/toggle-button.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import Button from '../base/button'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Tooltip from '../base/tooltip'
import { useTranslation } from 'react-i18next'
import { getKeyboardKeyNameBySystem } from '../workflow/utils'
diff --git a/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx
new file mode 100644
index 0000000000..f226adf22b
--- /dev/null
+++ b/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx
@@ -0,0 +1,47 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import EditItem, { EditItemType } from './index'
+
+describe('AddAnnotationModal/EditItem', () => {
+ test('should render query inputs with user avatar and placeholder strings', () => {
+ render(
+
,
+ )
+
+ expect(screen.getByText('appAnnotation.addModal.queryName')).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toBeInTheDocument()
+ expect(screen.getByText('Why?')).toBeInTheDocument()
+ })
+
+ test('should render answer name and placeholder text', () => {
+ render(
+
,
+ )
+
+ expect(screen.getByText('appAnnotation.addModal.answerName')).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('Existing answer')).toBeInTheDocument()
+ })
+
+ test('should propagate changes when answer content updates', () => {
+ const handleChange = jest.fn()
+ render(
+
,
+ )
+
+ fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), { target: { value: 'Because' } })
+ expect(handleChange).toHaveBeenCalledWith('Because')
+ })
+})
diff --git a/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx
new file mode 100644
index 0000000000..3103e3c96d
--- /dev/null
+++ b/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx
@@ -0,0 +1,155 @@
+import React from 'react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import AddAnnotationModal from './index'
+import { useProviderContext } from '@/context/provider-context'
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: jest.fn(),
+}))
+
+const mockToastNotify = jest.fn()
+jest.mock('@/app/components/base/toast', () => ({
+ __esModule: true,
+ default: {
+ notify: jest.fn(args => mockToastNotify(args)),
+ },
+}))
+
+jest.mock('@/app/components/billing/annotation-full', () => () =>
)
+
+const mockUseProviderContext = useProviderContext as jest.Mock
+
+const getProviderContext = ({ usage = 0, total = 10, enableBilling = false } = {}) => ({
+ plan: {
+ usage: { annotatedResponse: usage },
+ total: { annotatedResponse: total },
+ },
+ enableBilling,
+})
+
+describe('AddAnnotationModal', () => {
+ const baseProps = {
+ isShow: true,
+ onHide: jest.fn(),
+ onAdd: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseProviderContext.mockReturnValue(getProviderContext())
+ })
+
+ const typeQuestion = (value: string) => {
+ fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder'), {
+ target: { value },
+ })
+ }
+
+ const typeAnswer = (value: string) => {
+ fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), {
+ target: { value },
+ })
+ }
+
+ test('should render modal title when drawer is visible', () => {
+ render(
)
+
+ expect(screen.getByText('appAnnotation.addModal.title')).toBeInTheDocument()
+ })
+
+ test('should capture query input text when typing', () => {
+ render(
)
+ typeQuestion('Sample question')
+ expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('Sample question')
+ })
+
+ test('should capture answer input text when typing', () => {
+ render(
)
+ typeAnswer('Sample answer')
+ expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('Sample answer')
+ })
+
+ test('should show annotation full notice and disable submit when quota exceeded', () => {
+ mockUseProviderContext.mockReturnValue(getProviderContext({ usage: 10, total: 10, enableBilling: true }))
+ render(
)
+
+ expect(screen.getByTestId('annotation-full')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled()
+ })
+
+ test('should call onAdd with form values when create next enabled', async () => {
+ const onAdd = jest.fn().mockResolvedValue(undefined)
+ render(
)
+
+ typeQuestion('Question value')
+ typeAnswer('Answer value')
+ fireEvent.click(screen.getByTestId('checkbox-create-next-checkbox'))
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
+ })
+
+ expect(onAdd).toHaveBeenCalledWith({ question: 'Question value', answer: 'Answer value' })
+ })
+
+ test('should reset fields after saving when create next enabled', async () => {
+ const onAdd = jest.fn().mockResolvedValue(undefined)
+ render(
)
+
+ typeQuestion('Question value')
+ typeAnswer('Answer value')
+ const createNextToggle = screen.getByText('appAnnotation.addModal.createNext').previousElementSibling as HTMLElement
+ fireEvent.click(createNextToggle)
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
+ })
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('')
+ expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('')
+ })
+ })
+
+ test('should show toast when validation fails for missing question', () => {
+ render(
)
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'appAnnotation.errorMessage.queryRequired',
+ }))
+ })
+
+ test('should show toast when validation fails for missing answer', () => {
+ render(
)
+ typeQuestion('Filled question')
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
+
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'appAnnotation.errorMessage.answerRequired',
+ }))
+ })
+
+ test('should close modal when save completes and create next unchecked', async () => {
+ const onAdd = jest.fn().mockResolvedValue(undefined)
+ render(
)
+
+ typeQuestion('Q')
+ typeAnswer('A')
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
+ })
+
+ expect(baseProps.onHide).toHaveBeenCalled()
+ })
+
+ test('should allow cancel button to close the drawer', () => {
+ render(
)
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+ expect(baseProps.onHide).toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/app/annotation/add-annotation-modal/index.tsx b/web/app/components/app/annotation/add-annotation-modal/index.tsx
index 274a57adf1..0ae4439531 100644
--- a/web/app/components/app/annotation/add-annotation-modal/index.tsx
+++ b/web/app/components/app/annotation/add-annotation-modal/index.tsx
@@ -101,7 +101,7 @@ const AddAnnotationModal: FC
= ({
-
setIsCreateNext(!isCreateNext)} />
+ setIsCreateNext(!isCreateNext)} />
{t('appAnnotation.addModal.createNext')}
diff --git a/web/app/components/app/annotation/batch-action.spec.tsx b/web/app/components/app/annotation/batch-action.spec.tsx
new file mode 100644
index 0000000000..36440fc044
--- /dev/null
+++ b/web/app/components/app/annotation/batch-action.spec.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import BatchAction from './batch-action'
+
+describe('BatchAction', () => {
+ const baseProps = {
+ selectedIds: ['1', '2', '3'],
+ onBatchDelete: jest.fn(),
+ onCancel: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should show the selected count and trigger cancel action', () => {
+ render(
)
+
+ expect(screen.getByText('3')).toBeInTheDocument()
+ expect(screen.getByText('appAnnotation.batchAction.selected')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+
+ expect(baseProps.onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should confirm before running batch delete', async () => {
+ const onBatchDelete = jest.fn().mockResolvedValue(undefined)
+ render(
)
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.delete' }))
+ await screen.findByText('appAnnotation.list.delete.title')
+
+ await act(async () => {
+ fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.delete' })[1])
+ })
+
+ await waitFor(() => {
+ expect(onBatchDelete).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/web/app/components/app/annotation/batch-action.tsx b/web/app/components/app/annotation/batch-action.tsx
index 6e80d0c4c8..6ff392d17e 100644
--- a/web/app/components/app/annotation/batch-action.tsx
+++ b/web/app/components/app/annotation/batch-action.tsx
@@ -3,7 +3,7 @@ import { RiDeleteBinLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import Divider from '@/app/components/base/divider'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Confirm from '@/app/components/base/confirm'
const i18nPrefix = 'appAnnotation.batchAction'
@@ -38,7 +38,7 @@ const BatchAction: FC
= ({
setIsNotDeleting()
}
return (
-
+
diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx
new file mode 100644
index 0000000000..7d360cfc1b
--- /dev/null
+++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx
@@ -0,0 +1,72 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import CSVDownload from './csv-downloader'
+import I18nContext from '@/context/i18n'
+import { LanguagesSupported } from '@/i18n-config/language'
+import type { Locale } from '@/i18n-config'
+
+const downloaderProps: any[] = []
+
+jest.mock('react-papaparse', () => ({
+ useCSVDownloader: jest.fn(() => ({
+ CSVDownloader: ({ children, ...props }: any) => {
+ downloaderProps.push(props)
+ return {children}
+ },
+ Type: { Link: 'link' },
+ })),
+}))
+
+const renderWithLocale = (locale: Locale) => {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('CSVDownload', () => {
+ const englishTemplate = [
+ ['question', 'answer'],
+ ['question1', 'answer1'],
+ ['question2', 'answer2'],
+ ]
+ const chineseTemplate = [
+ ['问题', '答案'],
+ ['问题 1', '答案 1'],
+ ['问题 2', '答案 2'],
+ ]
+
+ beforeEach(() => {
+ downloaderProps.length = 0
+ })
+
+ it('should render the structure preview and pass English template data by default', () => {
+ renderWithLocale('en-US' as Locale)
+
+ expect(screen.getByText('share.generation.csvStructureTitle')).toBeInTheDocument()
+ expect(screen.getByText('appAnnotation.batchModal.template')).toBeInTheDocument()
+
+ expect(downloaderProps[0]).toMatchObject({
+ filename: 'template-en-US',
+ type: 'link',
+ bom: true,
+ data: englishTemplate,
+ })
+ })
+
+ it('should switch to the Chinese template when locale matches the secondary language', () => {
+ const locale = LanguagesSupported[1] as Locale
+ renderWithLocale(locale)
+
+ expect(downloaderProps[0]).toMatchObject({
+ filename: `template-${locale}`,
+ data: chineseTemplate,
+ })
+ })
+})
diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx
index ccad46b860..c9766135df 100644
--- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx
+++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiDeleteBinLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
import { ToastContext } from '@/app/components/base/toast'
import Button from '@/app/components/base/button'
diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx
new file mode 100644
index 0000000000..5527340895
--- /dev/null
+++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx
@@ -0,0 +1,164 @@
+import React from 'react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import BatchModal, { ProcessStatus } from './index'
+import { useProviderContext } from '@/context/provider-context'
+import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
+import type { IBatchModalProps } from './index'
+import Toast from '@/app/components/base/toast'
+
+jest.mock('@/app/components/base/toast', () => ({
+ __esModule: true,
+ default: {
+ notify: jest.fn(),
+ },
+}))
+
+jest.mock('@/service/annotation', () => ({
+ annotationBatchImport: jest.fn(),
+ checkAnnotationBatchImportProgress: jest.fn(),
+}))
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: jest.fn(),
+}))
+
+jest.mock('./csv-downloader', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+let lastUploadedFile: File | undefined
+
+jest.mock('./csv-uploader', () => ({
+ __esModule: true,
+ default: ({ file, updateFile }: { file?: File; updateFile: (file?: File) => void }) => (
+
+ {
+ lastUploadedFile = new File(['question,answer'], 'batch.csv', { type: 'text/csv' })
+ updateFile(lastUploadedFile)
+ }}
+ >
+ upload
+
+ {file && {file.name} }
+
+ ),
+}))
+
+jest.mock('@/app/components/billing/annotation-full', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+const mockNotify = Toast.notify as jest.Mock
+const useProviderContextMock = useProviderContext as jest.Mock
+const annotationBatchImportMock = annotationBatchImport as jest.Mock
+const checkAnnotationBatchImportProgressMock = checkAnnotationBatchImportProgress as jest.Mock
+
+const renderComponent = (props: Partial = {}) => {
+ const mergedProps: IBatchModalProps = {
+ appId: 'app-id',
+ isShow: true,
+ onCancel: jest.fn(),
+ onAdded: jest.fn(),
+ ...props,
+ }
+ return {
+ ...render( ),
+ props: mergedProps,
+ }
+}
+
+describe('BatchModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ lastUploadedFile = undefined
+ useProviderContextMock.mockReturnValue({
+ plan: {
+ usage: { annotatedResponse: 0 },
+ total: { annotatedResponse: 10 },
+ },
+ enableBilling: false,
+ })
+ })
+
+ it('should disable run action and show billing hint when annotation quota is full', () => {
+ useProviderContextMock.mockReturnValue({
+ plan: {
+ usage: { annotatedResponse: 10 },
+ total: { annotatedResponse: 10 },
+ },
+ enableBilling: true,
+ })
+
+ renderComponent()
+
+ expect(screen.getByTestId('annotation-full')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'appAnnotation.batchModal.run' })).toBeDisabled()
+ })
+
+ it('should reset uploader state when modal closes and allow manual cancellation', () => {
+ const { rerender, props } = renderComponent()
+
+ fireEvent.click(screen.getByTestId('mock-uploader'))
+ expect(screen.getByTestId('selected-file')).toHaveTextContent('batch.csv')
+
+ rerender( )
+ rerender( )
+
+ expect(screen.queryByTestId('selected-file')).toBeNull()
+
+ fireEvent.click(screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' }))
+ expect(props.onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should submit the csv file, poll status, and notify when import completes', async () => {
+ jest.useFakeTimers()
+ const { props } = renderComponent()
+ const fileTrigger = screen.getByTestId('mock-uploader')
+ fireEvent.click(fileTrigger)
+
+ const runButton = screen.getByRole('button', { name: 'appAnnotation.batchModal.run' })
+ expect(runButton).not.toBeDisabled()
+
+ annotationBatchImportMock.mockResolvedValue({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING })
+ checkAnnotationBatchImportProgressMock
+ .mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING })
+ .mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.COMPLETED })
+
+ await act(async () => {
+ fireEvent.click(runButton)
+ })
+
+ await waitFor(() => {
+ expect(annotationBatchImportMock).toHaveBeenCalledTimes(1)
+ })
+
+ const formData = annotationBatchImportMock.mock.calls[0][0].body as FormData
+ expect(formData.get('file')).toBe(lastUploadedFile)
+
+ await waitFor(() => {
+ expect(checkAnnotationBatchImportProgressMock).toHaveBeenCalledTimes(1)
+ })
+
+ await act(async () => {
+ jest.runOnlyPendingTimers()
+ })
+
+ await waitFor(() => {
+ expect(checkAnnotationBatchImportProgressMock).toHaveBeenCalledTimes(2)
+ })
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'appAnnotation.batchModal.completed',
+ })
+ expect(props.onAdded).toHaveBeenCalledTimes(1)
+ expect(props.onCancel).toHaveBeenCalledTimes(1)
+ })
+ jest.useRealTimers()
+ })
+})
diff --git a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx
new file mode 100644
index 0000000000..95a5586292
--- /dev/null
+++ b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx
@@ -0,0 +1,466 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import EditItem, { EditItemType, EditTitle } from './index'
+
+describe('EditTitle', () => {
+ it('should render title content correctly', () => {
+ // Arrange
+ const props = { title: 'Test Title' }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/test title/i)).toBeInTheDocument()
+ // Should contain edit icon (svg element)
+ expect(document.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('should apply custom className when provided', () => {
+ // Arrange
+ const props = {
+ title: 'Test Title',
+ className: 'custom-class',
+ }
+
+ // Act
+ const { container } = render( )
+
+ // Assert
+ expect(screen.getByText(/test title/i)).toBeInTheDocument()
+ expect(container.querySelector('.custom-class')).toBeInTheDocument()
+ })
+})
+
+describe('EditItem', () => {
+ const defaultProps = {
+ type: EditItemType.Query,
+ content: 'Test content',
+ onSave: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // Rendering tests (REQUIRED)
+ describe('Rendering', () => {
+ it('should render content correctly', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/test content/i)).toBeInTheDocument()
+ // Should show item name (query or answer)
+ expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument()
+ })
+
+ it('should render different item types correctly', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ type: EditItemType.Answer,
+ content: 'Answer content',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/answer content/i)).toBeInTheDocument()
+ expect(screen.getByText('appAnnotation.editModal.answerName')).toBeInTheDocument()
+ })
+
+ it('should show edit controls when not readonly', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
+ })
+
+ it('should hide edit controls when readonly', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ readonly: true,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
+ })
+ })
+
+ // Props tests (REQUIRED)
+ describe('Props', () => {
+ it('should respect readonly prop for edit functionality', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ readonly: true,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/test content/i)).toBeInTheDocument()
+ expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
+ })
+
+ it('should display provided content', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ content: 'Custom content for testing',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/custom content for testing/i)).toBeInTheDocument()
+ })
+
+ it('should render appropriate content based on type', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ type: EditItemType.Query,
+ content: 'Question content',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/question content/i)).toBeInTheDocument()
+ expect(screen.getByText('appAnnotation.editModal.queryName')).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should activate edit mode when edit button is clicked', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ await user.click(screen.getByText('common.operation.edit'))
+
+ // Assert
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
+ })
+
+ it('should save new content when save button is clicked', async () => {
+ // Arrange
+ const mockSave = jest.fn().mockResolvedValue(undefined)
+ const props = {
+ ...defaultProps,
+ onSave: mockSave,
+ }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ await user.click(screen.getByText('common.operation.edit'))
+
+ // Type new content
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'Updated content')
+
+ // Save
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ // Assert
+ expect(mockSave).toHaveBeenCalledWith('Updated content')
+ })
+
+ it('should exit edit mode when cancel button is clicked', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ await user.click(screen.getByText('common.operation.edit'))
+ await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+
+ // Assert
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
+ expect(screen.getByText(/test content/i)).toBeInTheDocument()
+ })
+
+ it('should show content preview while typing', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ await user.click(screen.getByText('common.operation.edit'))
+
+ const textarea = screen.getByRole('textbox')
+ await user.type(textarea, 'New content')
+
+ // Assert
+ expect(screen.getByText(/new content/i)).toBeInTheDocument()
+ })
+
+ it('should call onSave with correct content when saving', async () => {
+ // Arrange
+ const mockSave = jest.fn().mockResolvedValue(undefined)
+ const props = {
+ ...defaultProps,
+ onSave: mockSave,
+ }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ await user.click(screen.getByText('common.operation.edit'))
+
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'Test save content')
+
+ // Save
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ // Assert
+ expect(mockSave).toHaveBeenCalledWith('Test save content')
+ })
+
+ it('should show delete option and restore original content when delete is clicked', async () => {
+ // Arrange
+ const mockSave = jest.fn().mockResolvedValue(undefined)
+ const props = {
+ ...defaultProps,
+ onSave: mockSave,
+ }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ // Enter edit mode and change content
+ await user.click(screen.getByText('common.operation.edit'))
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'Modified content')
+
+ // Save to trigger content change
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ // Assert
+ expect(mockSave).toHaveBeenNthCalledWith(1, 'Modified content')
+ expect(await screen.findByText('common.operation.delete')).toBeInTheDocument()
+
+ await user.click(screen.getByText('common.operation.delete'))
+
+ expect(mockSave).toHaveBeenNthCalledWith(2, 'Test content')
+ expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
+ })
+
+ it('should handle keyboard interactions in edit mode', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ await user.click(screen.getByText('common.operation.edit'))
+
+ const textarea = screen.getByRole('textbox')
+
+ // Test typing
+ await user.type(textarea, 'Keyboard test')
+
+ // Assert
+ expect(textarea).toHaveValue('Keyboard test')
+ expect(screen.getByText(/keyboard test/i)).toBeInTheDocument()
+ })
+ })
+
+ // State Management
+ describe('State Management', () => {
+ it('should reset newContent when content prop changes', async () => {
+ // Arrange
+ const { rerender } = render( )
+
+ // Act - Enter edit mode and type something
+ const user = userEvent.setup()
+ await user.click(screen.getByText('common.operation.edit'))
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'New content')
+
+ // Rerender with new content prop
+ rerender( )
+
+ // Assert - Textarea value should be reset due to useEffect
+ expect(textarea).toHaveValue('')
+ })
+
+ it('should preserve edit state across content changes', async () => {
+ // Arrange
+ const { rerender } = render( )
+ const user = userEvent.setup()
+
+ // Act - Enter edit mode
+ await user.click(screen.getByText('common.operation.edit'))
+
+ // Rerender with new content
+ rerender( )
+
+ // Assert - Should still be in edit mode
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+ })
+
+ // Edge Cases (REQUIRED)
+ describe('Edge Cases', () => {
+ it('should handle empty content', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ content: '',
+ }
+
+ // Act
+ const { container } = render( )
+
+ // Assert - Should render without crashing
+ // Check that the component renders properly with empty content
+ expect(container.querySelector('.grow')).toBeInTheDocument()
+ // Should still show edit button
+ expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
+ })
+
+ it('should handle very long content', () => {
+ // Arrange
+ const longContent = 'A'.repeat(1000)
+ const props = {
+ ...defaultProps,
+ content: longContent,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(longContent)).toBeInTheDocument()
+ })
+
+ it('should handle content with special characters', () => {
+ // Arrange
+ const specialContent = 'Content with & < > " \' characters'
+ const props = {
+ ...defaultProps,
+ content: specialContent,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(specialContent)).toBeInTheDocument()
+ })
+
+ it('should handle rapid edit/cancel operations', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ // Rapid edit/cancel operations
+ await user.click(screen.getByText('common.operation.edit'))
+ await user.click(screen.getByText('common.operation.cancel'))
+ await user.click(screen.getByText('common.operation.edit'))
+ await user.click(screen.getByText('common.operation.cancel'))
+
+ // Assert
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
+ expect(screen.getByText('Test content')).toBeInTheDocument()
+ })
+
+ it('should handle save failure gracefully in edit mode', async () => {
+ // Arrange
+ const mockSave = jest.fn().mockRejectedValueOnce(new Error('Save failed'))
+ const props = {
+ ...defaultProps,
+ onSave: mockSave,
+ }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ // Enter edit mode and save (should fail)
+ await user.click(screen.getByText('common.operation.edit'))
+ const textarea = screen.getByRole('textbox')
+ await user.type(textarea, 'New content')
+
+ // Save should fail but not throw
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ // Assert - Should remain in edit mode when save fails
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
+ expect(mockSave).toHaveBeenCalledWith('New content')
+ })
+
+ it('should handle delete action failure gracefully', async () => {
+ // Arrange
+ const mockSave = jest.fn()
+ .mockResolvedValueOnce(undefined) // First save succeeds
+ .mockRejectedValueOnce(new Error('Delete failed')) // Delete fails
+ const props = {
+ ...defaultProps,
+ onSave: mockSave,
+ }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ // Edit content to show delete button
+ await user.click(screen.getByText('common.operation.edit'))
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'Modified content')
+
+ // Save to create new content
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+ await screen.findByText('common.operation.delete')
+
+ // Click delete (should fail but not throw)
+ await user.click(screen.getByText('common.operation.delete'))
+
+ // Assert - Delete action should handle error gracefully
+ expect(mockSave).toHaveBeenCalledTimes(2)
+ expect(mockSave).toHaveBeenNthCalledWith(1, 'Modified content')
+ expect(mockSave).toHaveBeenNthCalledWith(2, 'Test content')
+
+ // When delete fails, the delete button should still be visible (state not changed)
+ expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
+ expect(screen.getByText('Modified content')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx
index e808d0b48a..6ba830967d 100644
--- a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx
+++ b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx
@@ -6,7 +6,7 @@ import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react'
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
import Textarea from '@/app/components/base/textarea'
import Button from '@/app/components/base/button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export enum EditItemType {
Query = 'query',
@@ -52,8 +52,14 @@ const EditItem: FC = ({
}, [content])
const handleSave = async () => {
- await onSave(newContent)
- setIsEdit(false)
+ try {
+ await onSave(newContent)
+ setIsEdit(false)
+ }
+ catch {
+ // Keep edit mode open when save fails
+ // Error notification is handled by the parent component
+ }
}
const handleCancel = () => {
@@ -96,9 +102,16 @@ const EditItem: FC = ({
·
{
- setNewContent(content)
- onSave(content)
+ onClick={async () => {
+ try {
+ await onSave(content)
+ // Only update UI state after successful delete
+ setNewContent(content)
+ }
+ catch {
+ // Delete action failed - error is already handled by parent
+ // UI state remains unchanged, user can retry
+ }
}}
>
diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx
new file mode 100644
index 0000000000..bdc991116c
--- /dev/null
+++ b/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx
@@ -0,0 +1,680 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import Toast, { type IToastProps, type ToastHandle } from '@/app/components/base/toast'
+import EditAnnotationModal from './index'
+
+// Mock only external dependencies
+jest.mock('@/service/annotation', () => ({
+ addAnnotation: jest.fn(),
+ editAnnotation: jest.fn(),
+}))
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ plan: {
+ usage: { annotatedResponse: 5 },
+ total: { annotatedResponse: 10 },
+ },
+ enableBilling: true,
+ }),
+}))
+
+jest.mock('@/hooks/use-timestamp', () => ({
+ __esModule: true,
+ default: () => ({
+ formatTime: () => '2023-12-01 10:30:00',
+ }),
+}))
+
+// Note: i18n is automatically mocked by Jest via __mocks__/react-i18next.ts
+
+jest.mock('@/app/components/billing/annotation-full', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+type ToastNotifyProps = Pick
+type ToastWithNotify = typeof Toast & { notify: (props: ToastNotifyProps) => ToastHandle }
+const toastWithNotify = Toast as unknown as ToastWithNotify
+const toastNotifySpy = jest.spyOn(toastWithNotify, 'notify').mockReturnValue({ clear: jest.fn() })
+
+const { addAnnotation: mockAddAnnotation, editAnnotation: mockEditAnnotation } = jest.requireMock('@/service/annotation') as {
+ addAnnotation: jest.Mock
+ editAnnotation: jest.Mock
+}
+
+describe('EditAnnotationModal', () => {
+ const defaultProps = {
+ isShow: true,
+ onHide: jest.fn(),
+ appId: 'test-app-id',
+ query: 'Test query',
+ answer: 'Test answer',
+ onEdited: jest.fn(),
+ onAdded: jest.fn(),
+ onRemove: jest.fn(),
+ }
+
+ afterAll(() => {
+ toastNotifySpy.mockRestore()
+ })
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockAddAnnotation.mockResolvedValue({
+ id: 'test-id',
+ account: { name: 'Test User' },
+ })
+ mockEditAnnotation.mockResolvedValue({})
+ })
+
+ // Rendering tests (REQUIRED)
+ describe('Rendering', () => {
+ it('should render modal when isShow is true', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert - Check for modal title as it appears in the mock
+ expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
+ })
+
+ it('should not render modal when isShow is false', () => {
+ // Arrange
+ const props = { ...defaultProps, isShow: false }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.queryByText('appAnnotation.editModal.title')).not.toBeInTheDocument()
+ })
+
+ it('should display query and answer sections', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert - Look for query and answer content
+ expect(screen.getByText('Test query')).toBeInTheDocument()
+ expect(screen.getByText('Test answer')).toBeInTheDocument()
+ })
+ })
+
+ // Props tests (REQUIRED)
+ describe('Props', () => {
+ it('should handle different query and answer content', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ query: 'Custom query content',
+ answer: 'Custom answer content',
+ }
+
+ // Act
+ render( )
+
+ // Assert - Check content is displayed
+ expect(screen.getByText('Custom query content')).toBeInTheDocument()
+ expect(screen.getByText('Custom answer content')).toBeInTheDocument()
+ })
+
+ it('should show remove option when annotationId is provided', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ annotationId: 'test-annotation-id',
+ }
+
+ // Act
+ render( )
+
+ // Assert - Remove option should be present (using pattern)
+ expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should enable editing for query and answer sections', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert - Edit links should be visible (using text content)
+ const editLinks = screen.getAllByText(/common\.operation\.edit/i)
+ expect(editLinks).toHaveLength(2)
+ })
+
+ it('should show remove option when annotationId is provided', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ annotationId: 'test-annotation-id',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
+ })
+
+ it('should save content when edited', async () => {
+ // Arrange
+ const mockOnAdded = jest.fn()
+ const props = {
+ ...defaultProps,
+ onAdded: mockOnAdded,
+ }
+ const user = userEvent.setup()
+
+ // Mock API response
+ mockAddAnnotation.mockResolvedValueOnce({
+ id: 'test-annotation-id',
+ account: { name: 'Test User' },
+ })
+
+ // Act
+ render( )
+
+ // Find and click edit link for query
+ const editLinks = screen.getAllByText(/common\.operation\.edit/i)
+ await user.click(editLinks[0])
+
+ // Find textarea and enter new content
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'New query content')
+
+ // Click save button
+ const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
+ await user.click(saveButton)
+
+ // Assert
+ expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', {
+ question: 'New query content',
+ answer: 'Test answer',
+ message_id: undefined,
+ })
+ })
+ })
+
+ // API Calls
+ describe('API Calls', () => {
+ it('should call addAnnotation when saving new annotation', async () => {
+ // Arrange
+ const mockOnAdded = jest.fn()
+ const props = {
+ ...defaultProps,
+ onAdded: mockOnAdded,
+ }
+ const user = userEvent.setup()
+
+ // Mock the API response
+ mockAddAnnotation.mockResolvedValueOnce({
+ id: 'test-annotation-id',
+ account: { name: 'Test User' },
+ })
+
+ // Act
+ render( )
+
+ // Edit query content
+ const editLinks = screen.getAllByText(/common\.operation\.edit/i)
+ await user.click(editLinks[0])
+
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'Updated query')
+
+ const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
+ await user.click(saveButton)
+
+ // Assert
+ expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', {
+ question: 'Updated query',
+ answer: 'Test answer',
+ message_id: undefined,
+ })
+ })
+
+ it('should call editAnnotation when updating existing annotation', async () => {
+ // Arrange
+ const mockOnEdited = jest.fn()
+ const props = {
+ ...defaultProps,
+ annotationId: 'test-annotation-id',
+ messageId: 'test-message-id',
+ onEdited: mockOnEdited,
+ }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ // Edit query content
+ const editLinks = screen.getAllByText(/common\.operation\.edit/i)
+ await user.click(editLinks[0])
+
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'Modified query')
+
+ const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
+ await user.click(saveButton)
+
+ // Assert
+ expect(mockEditAnnotation).toHaveBeenCalledWith(
+ 'test-app-id',
+ 'test-annotation-id',
+ {
+ message_id: 'test-message-id',
+ question: 'Modified query',
+ answer: 'Test answer',
+ },
+ )
+ })
+ })
+
+ // State Management
+ describe('State Management', () => {
+ it('should initialize with closed confirm modal', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert - Confirm dialog should not be visible initially
+ expect(screen.queryByText('appDebug.feature.annotation.removeConfirm')).not.toBeInTheDocument()
+ })
+
+ it('should show confirm modal when remove is clicked', async () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ annotationId: 'test-annotation-id',
+ }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ await user.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
+
+ // Assert - Confirmation dialog should appear
+ expect(screen.getByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
+ })
+
+ it('should call onRemove when removal is confirmed', async () => {
+ // Arrange
+ const mockOnRemove = jest.fn()
+ const props = {
+ ...defaultProps,
+ annotationId: 'test-annotation-id',
+ onRemove: mockOnRemove,
+ }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ // Click remove
+ await user.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
+
+ // Click confirm
+ const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
+ await user.click(confirmButton)
+
+ // Assert
+ expect(mockOnRemove).toHaveBeenCalled()
+ })
+ })
+
+ // Edge Cases (REQUIRED)
+ describe('Edge Cases', () => {
+ it('should handle empty query and answer', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ query: '',
+ answer: '',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
+ })
+
+ it('should handle very long content', () => {
+ // Arrange
+ const longQuery = 'Q'.repeat(1000)
+ const longAnswer = 'A'.repeat(1000)
+ const props = {
+ ...defaultProps,
+ query: longQuery,
+ answer: longAnswer,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(longQuery)).toBeInTheDocument()
+ expect(screen.getByText(longAnswer)).toBeInTheDocument()
+ })
+
+ it('should handle special characters in content', () => {
+ // Arrange
+ const specialQuery = 'Query with & < > " \' characters'
+ const specialAnswer = 'Answer with & < > " \' characters'
+ const props = {
+ ...defaultProps,
+ query: specialQuery,
+ answer: specialAnswer,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(specialQuery)).toBeInTheDocument()
+ expect(screen.getByText(specialAnswer)).toBeInTheDocument()
+ })
+
+ it('should handle onlyEditResponse prop', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ onlyEditResponse: true,
+ }
+
+ // Act
+ render( )
+
+ // Assert - Query should be readonly, answer should be editable
+ const editLinks = screen.queryAllByText(/common\.operation\.edit/i)
+ expect(editLinks).toHaveLength(1) // Only answer should have edit button
+ })
+ })
+
+ // Error Handling (CRITICAL for coverage)
+ describe('Error Handling', () => {
+ it('should show error toast and skip callbacks when addAnnotation fails', async () => {
+ // Arrange
+ const mockOnAdded = jest.fn()
+ const props = {
+ ...defaultProps,
+ onAdded: mockOnAdded,
+ }
+ const user = userEvent.setup()
+
+ // Mock API failure
+ mockAddAnnotation.mockRejectedValueOnce(new Error('API Error'))
+
+ // Act
+ render( )
+
+ // Find and click edit link for query
+ const editLinks = screen.getAllByText(/common\.operation\.edit/i)
+ await user.click(editLinks[0])
+
+ // Find textarea and enter new content
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'New query content')
+
+ // Click save button
+ const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
+ await user.click(saveButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(toastNotifySpy).toHaveBeenCalledWith({
+ message: 'API Error',
+ type: 'error',
+ })
+ })
+ expect(mockOnAdded).not.toHaveBeenCalled()
+
+ // Verify edit mode remains open (textarea should still be visible)
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
+ })
+
+ it('should show fallback error message when addAnnotation error has no message', async () => {
+ // Arrange
+ const mockOnAdded = jest.fn()
+ const props = {
+ ...defaultProps,
+ onAdded: mockOnAdded,
+ }
+ const user = userEvent.setup()
+
+ mockAddAnnotation.mockRejectedValueOnce({})
+
+ // Act
+ render( )
+
+ const editLinks = screen.getAllByText(/common\.operation\.edit/i)
+ await user.click(editLinks[0])
+
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'New query content')
+
+ const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
+ await user.click(saveButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(toastNotifySpy).toHaveBeenCalledWith({
+ message: 'common.api.actionFailed',
+ type: 'error',
+ })
+ })
+ expect(mockOnAdded).not.toHaveBeenCalled()
+
+ // Verify edit mode remains open (textarea should still be visible)
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
+ })
+
+ it('should show error toast and skip callbacks when editAnnotation fails', async () => {
+ // Arrange
+ const mockOnEdited = jest.fn()
+ const props = {
+ ...defaultProps,
+ annotationId: 'test-annotation-id',
+ messageId: 'test-message-id',
+ onEdited: mockOnEdited,
+ }
+ const user = userEvent.setup()
+
+ // Mock API failure
+ mockEditAnnotation.mockRejectedValueOnce(new Error('API Error'))
+
+ // Act
+ render( )
+
+ // Edit query content
+ const editLinks = screen.getAllByText(/common\.operation\.edit/i)
+ await user.click(editLinks[0])
+
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'Modified query')
+
+ const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
+ await user.click(saveButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(toastNotifySpy).toHaveBeenCalledWith({
+ message: 'API Error',
+ type: 'error',
+ })
+ })
+ expect(mockOnEdited).not.toHaveBeenCalled()
+
+ // Verify edit mode remains open (textarea should still be visible)
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
+ })
+
+ it('should show fallback error message when editAnnotation error is not an Error instance', async () => {
+ // Arrange
+ const mockOnEdited = jest.fn()
+ const props = {
+ ...defaultProps,
+ annotationId: 'test-annotation-id',
+ messageId: 'test-message-id',
+ onEdited: mockOnEdited,
+ }
+ const user = userEvent.setup()
+
+ mockEditAnnotation.mockRejectedValueOnce('oops')
+
+ // Act
+ render( )
+
+ const editLinks = screen.getAllByText(/common\.operation\.edit/i)
+ await user.click(editLinks[0])
+
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'Modified query')
+
+ const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
+ await user.click(saveButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(toastNotifySpy).toHaveBeenCalledWith({
+ message: 'common.api.actionFailed',
+ type: 'error',
+ })
+ })
+ expect(mockOnEdited).not.toHaveBeenCalled()
+
+ // Verify edit mode remains open (textarea should still be visible)
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
+ })
+ })
+
+ // Billing & Plan Features
+ describe('Billing & Plan Features', () => {
+ it('should show createdAt time when provided', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ annotationId: 'test-annotation-id',
+ createdAt: 1701381000, // 2023-12-01 10:30:00
+ }
+
+ // Act
+ render( )
+
+ // Assert - Check that the formatted time appears somewhere in the component
+ const container = screen.getByRole('dialog')
+ expect(container).toHaveTextContent('2023-12-01 10:30:00')
+ })
+
+ it('should not show createdAt when not provided', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ annotationId: 'test-annotation-id',
+ // createdAt is undefined
+ }
+
+ // Act
+ render( )
+
+ // Assert - Should not contain any timestamp
+ const container = screen.getByRole('dialog')
+ expect(container).not.toHaveTextContent('2023-12-01 10:30:00')
+ })
+
+ it('should display remove section when annotationId exists', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ annotationId: 'test-annotation-id',
+ }
+
+ // Act
+ render( )
+
+ // Assert - Should have remove functionality
+ expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
+ })
+ })
+
+ // Toast Notifications (Success)
+ describe('Toast Notifications', () => {
+ it('should show success notification when save operation completes', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ const editLinks = screen.getAllByText(/common\.operation\.edit/i)
+ await user.click(editLinks[0])
+
+ const textarea = screen.getByRole('textbox')
+ await user.clear(textarea)
+ await user.type(textarea, 'Updated query')
+
+ const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
+ await user.click(saveButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(toastNotifySpy).toHaveBeenCalledWith({
+ message: 'common.api.actionSuccess',
+ type: 'success',
+ })
+ })
+ })
+ })
+
+ // React.memo Performance Testing
+ describe('React.memo Performance', () => {
+ it('should not re-render when props are the same', () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const { rerender } = render( )
+
+ // Act - Re-render with same props
+ rerender( )
+
+ // Assert - Component should still be visible (no errors thrown)
+ expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
+ })
+
+ it('should re-render when props change', () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const { rerender } = render( )
+
+ // Act - Re-render with different props
+ const newProps = { ...props, query: 'New query content' }
+ rerender( )
+
+ // Assert - Should show new content
+ expect(screen.getByText('New query content')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.tsx
index 2961ce393c..6172a215e4 100644
--- a/web/app/components/app/annotation/edit-annotation-modal/index.tsx
+++ b/web/app/components/app/annotation/edit-annotation-modal/index.tsx
@@ -53,27 +53,39 @@ const EditAnnotationModal: FC = ({
postQuery = editedContent
else
postAnswer = editedContent
- if (!isAdd) {
- await editAnnotation(appId, annotationId, {
- message_id: messageId,
- question: postQuery,
- answer: postAnswer,
- })
- onEdited(postQuery, postAnswer)
- }
- else {
- const res: any = await addAnnotation(appId, {
- question: postQuery,
- answer: postAnswer,
- message_id: messageId,
- })
- onAdded(res.id, res.account?.name, postQuery, postAnswer)
- }
+ try {
+ if (!isAdd) {
+ await editAnnotation(appId, annotationId, {
+ message_id: messageId,
+ question: postQuery,
+ answer: postAnswer,
+ })
+ onEdited(postQuery, postAnswer)
+ }
+ else {
+ const res = await addAnnotation(appId, {
+ question: postQuery,
+ answer: postAnswer,
+ message_id: messageId,
+ })
+ onAdded(res.id, res.account?.name ?? '', postQuery, postAnswer)
+ }
- Toast.notify({
- message: t('common.api.actionSuccess') as string,
- type: 'success',
- })
+ Toast.notify({
+ message: t('common.api.actionSuccess') as string,
+ type: 'success',
+ })
+ }
+ catch (error) {
+ const fallbackMessage = t('common.api.actionFailed') as string
+ const message = error instanceof Error && error.message ? error.message : fallbackMessage
+ Toast.notify({
+ message,
+ type: 'error',
+ })
+ // Re-throw to preserve edit mode behavior for UI components
+ throw error
+ }
}
const [showModal, setShowModal] = useState(false)
diff --git a/web/app/components/app/annotation/empty-element.spec.tsx b/web/app/components/app/annotation/empty-element.spec.tsx
new file mode 100644
index 0000000000..56ebb96121
--- /dev/null
+++ b/web/app/components/app/annotation/empty-element.spec.tsx
@@ -0,0 +1,13 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import EmptyElement from './empty-element'
+
+describe('EmptyElement', () => {
+ it('should render the empty state copy and supporting icon', () => {
+ const { container } = render( )
+
+ expect(screen.getByText('appAnnotation.noData.title')).toBeInTheDocument()
+ expect(screen.getByText('appAnnotation.noData.description')).toBeInTheDocument()
+ expect(container.querySelector('svg')).not.toBeNull()
+ })
+})
diff --git a/web/app/components/app/annotation/filter.spec.tsx b/web/app/components/app/annotation/filter.spec.tsx
new file mode 100644
index 0000000000..6260ff7668
--- /dev/null
+++ b/web/app/components/app/annotation/filter.spec.tsx
@@ -0,0 +1,70 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import Filter, { type QueryParam } from './filter'
+import useSWR from 'swr'
+
+jest.mock('swr', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}))
+
+jest.mock('@/service/log', () => ({
+ fetchAnnotationsCount: jest.fn(),
+}))
+
+const mockUseSWR = useSWR as unknown as jest.Mock
+
+describe('Filter', () => {
+ const appId = 'app-1'
+ const childContent = 'child-content'
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should render nothing until annotation count is fetched', () => {
+ mockUseSWR.mockReturnValue({ data: undefined })
+
+ const { container } = render(
+
+ {childContent}
+ ,
+ )
+
+ expect(container.firstChild).toBeNull()
+ expect(mockUseSWR).toHaveBeenCalledWith(
+ { url: `/apps/${appId}/annotations/count` },
+ expect.any(Function),
+ )
+ })
+
+ it('should propagate keyword changes and clearing behavior', () => {
+ mockUseSWR.mockReturnValue({ data: { total: 20 } })
+ const queryParams: QueryParam = { keyword: 'prefill' }
+ const setQueryParams = jest.fn()
+
+ const { container } = render(
+
+ {childContent}
+ ,
+ )
+
+ const input = screen.getByPlaceholderText('common.operation.search') as HTMLInputElement
+ fireEvent.change(input, { target: { value: 'updated' } })
+ expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' })
+
+ const clearButton = input.parentElement?.querySelector('div.cursor-pointer') as HTMLElement
+ fireEvent.click(clearButton)
+ expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' })
+
+ expect(container).toHaveTextContent(childContent)
+ })
+})
diff --git a/web/app/components/app/annotation/header-opts/index.spec.tsx b/web/app/components/app/annotation/header-opts/index.spec.tsx
new file mode 100644
index 0000000000..3d8a1fd4ef
--- /dev/null
+++ b/web/app/components/app/annotation/header-opts/index.spec.tsx
@@ -0,0 +1,439 @@
+import * as React from 'react'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import type { ComponentProps } from 'react'
+import HeaderOptions from './index'
+import I18NContext from '@/context/i18n'
+import { LanguagesSupported } from '@/i18n-config/language'
+import type { AnnotationItemBasic } from '../type'
+import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
+
+jest.mock('@headlessui/react', () => {
+ type PopoverContextValue = { open: boolean; setOpen: (open: boolean) => void }
+ type MenuContextValue = { open: boolean; setOpen: (open: boolean) => void }
+ const PopoverContext = React.createContext(null)
+ const MenuContext = React.createContext(null)
+
+ const Popover = ({ children }: { children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) }) => {
+ const [open, setOpen] = React.useState(false)
+ const value = React.useMemo(() => ({ open, setOpen }), [open])
+ return (
+
+ {typeof children === 'function' ? children({ open }) : children}
+
+ )
+ }
+
+ const PopoverButton = React.forwardRef(({ onClick, children, ...props }: { onClick?: () => void; children?: React.ReactNode }, ref: React.Ref) => {
+ const context = React.useContext(PopoverContext)
+ const handleClick = () => {
+ context?.setOpen(!context.open)
+ onClick?.()
+ }
+ return (
+
+ {children}
+
+ )
+ })
+
+ const PopoverPanel = React.forwardRef(({ children, ...props }: { children: React.ReactNode | ((props: { close: () => void }) => React.ReactNode) }, ref: React.Ref) => {
+ const context = React.useContext(PopoverContext)
+ if (!context?.open) return null
+ const content = typeof children === 'function' ? children({ close: () => context.setOpen(false) }) : children
+ return (
+
+ {content}
+
+ )
+ })
+
+ const Menu = ({ children }: { children: React.ReactNode }) => {
+ const [open, setOpen] = React.useState(false)
+ const value = React.useMemo(() => ({ open, setOpen }), [open])
+ return (
+
+ {children}
+
+ )
+ }
+
+ const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void; children?: React.ReactNode }) => {
+ const context = React.useContext(MenuContext)
+ const handleClick = () => {
+ context?.setOpen(!context.open)
+ onClick?.()
+ }
+ return (
+
+ {children}
+
+ )
+ }
+
+ const MenuItems = ({ children, ...props }: { children: React.ReactNode }) => {
+ const context = React.useContext(MenuContext)
+ if (!context?.open) return null
+ return (
+
+ {children}
+
+ )
+ }
+
+ return {
+ Dialog: ({ open, children, className }: { open?: boolean; children: React.ReactNode; className?: string }) => {
+ if (open === false) return null
+ return (
+
+ {children}
+
+ )
+ },
+ DialogBackdrop: ({ children, className, onClick }: { children?: React.ReactNode; className?: string; onClick?: () => void }) => (
+
+ {children}
+
+ ),
+ DialogPanel: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+ DialogTitle: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+ Popover,
+ PopoverButton,
+ PopoverPanel,
+ Menu,
+ MenuButton,
+ MenuItems,
+ Transition: ({ show = true, children }: { show?: boolean; children: React.ReactNode }) => (show ? <>{children}> : null),
+ TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ }
+})
+
+let lastCSVDownloaderProps: Record | undefined
+const mockCSVDownloader = jest.fn(({ children, ...props }) => {
+ lastCSVDownloaderProps = props
+ return (
+
+ {children}
+
+ )
+})
+
+jest.mock('react-papaparse', () => ({
+ useCSVDownloader: () => ({
+ CSVDownloader: (props: any) => mockCSVDownloader(props),
+ Type: { Link: 'link' },
+ }),
+}))
+
+jest.mock('@/service/annotation', () => ({
+ fetchExportAnnotationList: jest.fn(),
+ clearAllAnnotations: jest.fn(),
+}))
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ plan: {
+ usage: { annotatedResponse: 0 },
+ total: { annotatedResponse: 10 },
+ },
+ enableBilling: false,
+ }),
+}))
+
+jest.mock('@/app/components/billing/annotation-full', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+type HeaderOptionsProps = ComponentProps
+
+const renderComponent = (
+ props: Partial = {},
+ locale: string = LanguagesSupported[0] as string,
+) => {
+ const defaultProps: HeaderOptionsProps = {
+ appId: 'test-app-id',
+ onAdd: jest.fn(),
+ onAdded: jest.fn(),
+ controlUpdateList: 0,
+ ...props,
+ }
+
+ return render(
+
+
+ ,
+ )
+}
+
+const openOperationsPopover = async (user: ReturnType) => {
+ const trigger = document.querySelector('button.btn.btn-secondary') as HTMLButtonElement
+ expect(trigger).toBeTruthy()
+ await user.click(trigger)
+}
+
+const expandExportMenu = async (user: ReturnType) => {
+ await openOperationsPopover(user)
+ const exportLabel = await screen.findByText('appAnnotation.table.header.bulkExport')
+ const exportButton = exportLabel.closest('button') as HTMLButtonElement
+ expect(exportButton).toBeTruthy()
+ await user.click(exportButton)
+}
+
+const getExportButtons = async () => {
+ const csvLabel = await screen.findByText('CSV')
+ const jsonLabel = await screen.findByText('JSONL')
+ const csvButton = csvLabel.closest('button') as HTMLButtonElement
+ const jsonButton = jsonLabel.closest('button') as HTMLButtonElement
+ expect(csvButton).toBeTruthy()
+ expect(jsonButton).toBeTruthy()
+ return {
+ csvButton,
+ jsonButton,
+ }
+}
+
+const clickOperationAction = async (
+ user: ReturnType,
+ translationKey: string,
+) => {
+ const label = await screen.findByText(translationKey)
+ const button = label.closest('button') as HTMLButtonElement
+ expect(button).toBeTruthy()
+ await user.click(button)
+}
+
+const mockAnnotations: AnnotationItemBasic[] = [
+ {
+ question: 'Question 1',
+ answer: 'Answer 1',
+ },
+]
+
+const mockedFetchAnnotations = jest.mocked(fetchExportAnnotationList)
+const mockedClearAllAnnotations = jest.mocked(clearAllAnnotations)
+
+describe('HeaderOptions', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ jest.useRealTimers()
+ mockCSVDownloader.mockClear()
+ lastCSVDownloaderProps = undefined
+ mockedFetchAnnotations.mockResolvedValue({ data: [] })
+ })
+
+ it('should fetch annotations on mount and render enabled export actions when data exist', async () => {
+ mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
+ const user = userEvent.setup()
+ renderComponent()
+
+ await waitFor(() => {
+ expect(mockedFetchAnnotations).toHaveBeenCalledWith('test-app-id')
+ })
+
+ await expandExportMenu(user)
+
+ const { csvButton, jsonButton } = await getExportButtons()
+
+ expect(csvButton).not.toBeDisabled()
+ expect(jsonButton).not.toBeDisabled()
+
+ await waitFor(() => {
+ expect(lastCSVDownloaderProps).toMatchObject({
+ bom: true,
+ filename: 'annotations-en-US',
+ type: 'link',
+ data: [
+ ['Question', 'Answer'],
+ ['Question 1', 'Answer 1'],
+ ],
+ })
+ })
+ })
+
+ it('should disable export actions when there are no annotations', async () => {
+ const user = userEvent.setup()
+ renderComponent()
+
+ await expandExportMenu(user)
+
+ const { csvButton, jsonButton } = await getExportButtons()
+
+ expect(csvButton).toBeDisabled()
+ expect(jsonButton).toBeDisabled()
+
+ expect(lastCSVDownloaderProps).toMatchObject({
+ data: [['Question', 'Answer']],
+ })
+ })
+
+ it('should open the add annotation modal and forward the onAdd callback', async () => {
+ mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
+ const user = userEvent.setup()
+ const onAdd = jest.fn().mockResolvedValue(undefined)
+ renderComponent({ onAdd })
+
+ await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalled())
+
+ await user.click(
+ screen.getByRole('button', { name: 'appAnnotation.table.header.addAnnotation' }),
+ )
+
+ await screen.findByText('appAnnotation.addModal.title')
+ const questionInput = screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')
+ const answerInput = screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')
+
+ await user.type(questionInput, 'Integration question')
+ await user.type(answerInput, 'Integration answer')
+ await user.click(screen.getByRole('button', { name: 'common.operation.add' }))
+
+ await waitFor(() => {
+ expect(onAdd).toHaveBeenCalledWith({
+ question: 'Integration question',
+ answer: 'Integration answer',
+ })
+ })
+ })
+
+ it('should allow bulk import through the batch modal', async () => {
+ const user = userEvent.setup()
+ const onAdded = jest.fn()
+ renderComponent({ onAdded })
+
+ await openOperationsPopover(user)
+ await clickOperationAction(user, 'appAnnotation.table.header.bulkImport')
+
+ expect(await screen.findByText('appAnnotation.batchModal.title')).toBeInTheDocument()
+ await user.click(
+ screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' }),
+ )
+ expect(onAdded).not.toHaveBeenCalled()
+ })
+
+ it('should trigger JSONL download with locale-specific filename', async () => {
+ mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
+ const user = userEvent.setup()
+ const originalCreateElement = document.createElement.bind(document)
+ const anchor = originalCreateElement('a') as HTMLAnchorElement
+ const clickSpy = jest.spyOn(anchor, 'click').mockImplementation(jest.fn())
+ const createElementSpy = jest
+ .spyOn(document, 'createElement')
+ .mockImplementation((tagName: Parameters[0]) => {
+ if (tagName === 'a')
+ return anchor
+ return originalCreateElement(tagName)
+ })
+ const objectURLSpy = jest
+ .spyOn(URL, 'createObjectURL')
+ .mockReturnValue('blob://mock-url')
+ const revokeSpy = jest.spyOn(URL, 'revokeObjectURL').mockImplementation(jest.fn())
+
+ renderComponent({}, LanguagesSupported[1] as string)
+
+ await expandExportMenu(user)
+
+ await waitFor(() => expect(mockCSVDownloader).toHaveBeenCalled())
+
+ const { jsonButton } = await getExportButtons()
+ await user.click(jsonButton)
+
+ expect(createElementSpy).toHaveBeenCalled()
+ expect(anchor.download).toBe(`annotations-${LanguagesSupported[1]}.jsonl`)
+ expect(clickSpy).toHaveBeenCalled()
+ expect(revokeSpy).toHaveBeenCalledWith('blob://mock-url')
+
+ const blobArg = objectURLSpy.mock.calls[0][0] as Blob
+ await expect(blobArg.text()).resolves.toContain('"Question 1"')
+
+ clickSpy.mockRestore()
+ createElementSpy.mockRestore()
+ objectURLSpy.mockRestore()
+ revokeSpy.mockRestore()
+ })
+
+ it('should clear all annotations when confirmation succeeds', async () => {
+ mockedClearAllAnnotations.mockResolvedValue(undefined)
+ const user = userEvent.setup()
+ const onAdded = jest.fn()
+ renderComponent({ onAdded })
+
+ await openOperationsPopover(user)
+ await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
+
+ await screen.findByText('appAnnotation.table.header.clearAllConfirm')
+ const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
+ await user.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockedClearAllAnnotations).toHaveBeenCalledWith('test-app-id')
+ expect(onAdded).toHaveBeenCalled()
+ })
+ })
+
+ it('should handle clear all failures gracefully', async () => {
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
+ mockedClearAllAnnotations.mockRejectedValue(new Error('network'))
+ const user = userEvent.setup()
+ const onAdded = jest.fn()
+ renderComponent({ onAdded })
+
+ await openOperationsPopover(user)
+ await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
+ await screen.findByText('appAnnotation.table.header.clearAllConfirm')
+ const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
+ await user.click(confirmButton)
+
+ await waitFor(() => {
+ expect(mockedClearAllAnnotations).toHaveBeenCalled()
+ expect(onAdded).not.toHaveBeenCalled()
+ expect(consoleSpy).toHaveBeenCalled()
+ })
+
+ consoleSpy.mockRestore()
+ })
+
+ it('should refetch annotations when controlUpdateList changes', async () => {
+ const view = renderComponent({ controlUpdateList: 0 })
+
+ await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(1))
+
+ view.rerender(
+
+
+ ,
+ )
+
+ await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(2))
+ })
+})
diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx
index 024f75867c..5f8ef658e7 100644
--- a/web/app/components/app/annotation/header-opts/index.tsx
+++ b/web/app/components/app/annotation/header-opts/index.tsx
@@ -17,7 +17,7 @@ import Button from '../../../base/button'
import AddAnnotationModal from '../add-annotation-modal'
import type { AnnotationItemBasic } from '../type'
import BatchAddModal from '../batch-add-annotation-modal'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import CustomPopover from '@/app/components/base/popover'
import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
diff --git a/web/app/components/app/annotation/index.spec.tsx b/web/app/components/app/annotation/index.spec.tsx
new file mode 100644
index 0000000000..4971f5173c
--- /dev/null
+++ b/web/app/components/app/annotation/index.spec.tsx
@@ -0,0 +1,233 @@
+import React from 'react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import Annotation from './index'
+import type { AnnotationItem } from './type'
+import { JobStatus } from './type'
+import { type App, AppModeEnum } from '@/types/app'
+import {
+ addAnnotation,
+ delAnnotation,
+ delAnnotations,
+ fetchAnnotationConfig,
+ fetchAnnotationList,
+ queryAnnotationJobStatus,
+} from '@/service/annotation'
+import { useProviderContext } from '@/context/provider-context'
+import Toast from '@/app/components/base/toast'
+
+jest.mock('@/app/components/base/toast', () => ({
+ __esModule: true,
+ default: { notify: jest.fn() },
+}))
+
+jest.mock('ahooks', () => ({
+ useDebounce: (value: any) => value,
+}))
+
+jest.mock('@/service/annotation', () => ({
+ addAnnotation: jest.fn(),
+ delAnnotation: jest.fn(),
+ delAnnotations: jest.fn(),
+ fetchAnnotationConfig: jest.fn(),
+ editAnnotation: jest.fn(),
+ fetchAnnotationList: jest.fn(),
+ queryAnnotationJobStatus: jest.fn(),
+ updateAnnotationScore: jest.fn(),
+ updateAnnotationStatus: jest.fn(),
+}))
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: jest.fn(),
+}))
+
+jest.mock('./filter', () => ({ children }: { children: React.ReactNode }) => (
+ {children}
+))
+
+jest.mock('./empty-element', () => () =>
)
+
+jest.mock('./header-opts', () => (props: any) => (
+
+ props.onAdd({ question: 'new question', answer: 'new answer' })}>
+ add
+
+
+))
+
+let latestListProps: any
+
+jest.mock('./list', () => (props: any) => {
+ latestListProps = props
+ if (!props.list.length)
+ return
+ return (
+
+ props.onView(props.list[0])}>view
+ props.onRemove(props.list[0].id)}>remove
+ props.onBatchDelete()}>batch-delete
+
+ )
+})
+
+jest.mock('./view-annotation-modal', () => (props: any) => {
+ if (!props.isShow)
+ return null
+ return (
+
+
{props.item.question}
+
remove
+
close
+
+ )
+})
+
+jest.mock('@/app/components/base/pagination', () => () =>
)
+jest.mock('@/app/components/base/loading', () => () =>
)
+jest.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => (props: any) => props.isShow ?
: null)
+jest.mock('@/app/components/billing/annotation-full/modal', () => (props: any) => props.show ?
: null)
+
+const mockNotify = Toast.notify as jest.Mock
+const addAnnotationMock = addAnnotation as jest.Mock
+const delAnnotationMock = delAnnotation as jest.Mock
+const delAnnotationsMock = delAnnotations as jest.Mock
+const fetchAnnotationConfigMock = fetchAnnotationConfig as jest.Mock
+const fetchAnnotationListMock = fetchAnnotationList as jest.Mock
+const queryAnnotationJobStatusMock = queryAnnotationJobStatus as jest.Mock
+const useProviderContextMock = useProviderContext as jest.Mock
+
+const appDetail = {
+ id: 'app-id',
+ mode: AppModeEnum.CHAT,
+} as App
+
+const createAnnotation = (overrides: Partial = {}): AnnotationItem => ({
+ id: overrides.id ?? 'annotation-1',
+ question: overrides.question ?? 'Question 1',
+ answer: overrides.answer ?? 'Answer 1',
+ created_at: overrides.created_at ?? 1700000000,
+ hit_count: overrides.hit_count ?? 0,
+})
+
+const renderComponent = () => render( )
+
+describe('Annotation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ latestListProps = undefined
+ fetchAnnotationConfigMock.mockResolvedValue({
+ id: 'config-id',
+ enabled: false,
+ embedding_model: {
+ embedding_model_name: 'model',
+ embedding_provider_name: 'provider',
+ },
+ score_threshold: 0.5,
+ })
+ fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 })
+ queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed })
+ useProviderContextMock.mockReturnValue({
+ plan: {
+ usage: { annotatedResponse: 0 },
+ total: { annotatedResponse: 10 },
+ },
+ enableBilling: false,
+ })
+ })
+
+ it('should render empty element when no annotations are returned', async () => {
+ renderComponent()
+
+ expect(await screen.findByTestId('empty-element')).toBeInTheDocument()
+ expect(fetchAnnotationListMock).toHaveBeenCalledWith(appDetail.id, expect.objectContaining({
+ page: 1,
+ keyword: '',
+ }))
+ })
+
+ it('should handle annotation creation and refresh list data', async () => {
+ const annotation = createAnnotation()
+ fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
+ addAnnotationMock.mockResolvedValue(undefined)
+
+ renderComponent()
+
+ await screen.findByTestId('list')
+ fireEvent.click(screen.getByTestId('trigger-add'))
+
+ await waitFor(() => {
+ expect(addAnnotationMock).toHaveBeenCalledWith(appDetail.id, { question: 'new question', answer: 'new answer' })
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ message: 'common.api.actionSuccess',
+ type: 'success',
+ }))
+ })
+ expect(fetchAnnotationListMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('should support viewing items and running batch deletion success flow', async () => {
+ const annotation = createAnnotation()
+ fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
+ delAnnotationsMock.mockResolvedValue(undefined)
+ delAnnotationMock.mockResolvedValue(undefined)
+
+ renderComponent()
+ await screen.findByTestId('list')
+
+ await act(async () => {
+ latestListProps.onSelectedIdsChange([annotation.id])
+ })
+ await waitFor(() => {
+ expect(latestListProps.selectedIds).toEqual([annotation.id])
+ })
+
+ await act(async () => {
+ await latestListProps.onBatchDelete()
+ })
+ await waitFor(() => {
+ expect(delAnnotationsMock).toHaveBeenCalledWith(appDetail.id, [annotation.id])
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'success',
+ }))
+ expect(latestListProps.selectedIds).toEqual([])
+ })
+
+ fireEvent.click(screen.getByTestId('list-view'))
+ expect(screen.getByTestId('view-modal')).toBeInTheDocument()
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('view-modal-remove'))
+ })
+ await waitFor(() => {
+ expect(delAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id)
+ })
+ })
+
+ it('should show an error notification when batch deletion fails', async () => {
+ const annotation = createAnnotation()
+ fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
+ const error = new Error('failed')
+ delAnnotationsMock.mockRejectedValue(error)
+
+ renderComponent()
+ await screen.findByTestId('list')
+
+ await act(async () => {
+ latestListProps.onSelectedIdsChange([annotation.id])
+ })
+ await waitFor(() => {
+ expect(latestListProps.selectedIds).toEqual([annotation.id])
+ })
+
+ await act(async () => {
+ await latestListProps.onBatchDelete()
+ })
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: error.message,
+ })
+ expect(latestListProps.selectedIds).toEqual([annotation.id])
+ })
+ })
+})
diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx
index 32d0c799fc..2d639c91e4 100644
--- a/web/app/components/app/annotation/index.tsx
+++ b/web/app/components/app/annotation/index.tsx
@@ -25,7 +25,7 @@ import { sleep } from '@/utils'
import { useProviderContext } from '@/context/provider-context'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import { type App, AppModeEnum } from '@/types/app'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { delAnnotations } from '@/service/annotation'
type Props = {
diff --git a/web/app/components/app/annotation/list.spec.tsx b/web/app/components/app/annotation/list.spec.tsx
new file mode 100644
index 0000000000..9f8d4c8855
--- /dev/null
+++ b/web/app/components/app/annotation/list.spec.tsx
@@ -0,0 +1,116 @@
+import React from 'react'
+import { fireEvent, render, screen, within } from '@testing-library/react'
+import List from './list'
+import type { AnnotationItem } from './type'
+
+const mockFormatTime = jest.fn(() => 'formatted-time')
+
+jest.mock('@/hooks/use-timestamp', () => ({
+ __esModule: true,
+ default: () => ({
+ formatTime: mockFormatTime,
+ }),
+}))
+
+const createAnnotation = (overrides: Partial = {}): AnnotationItem => ({
+ id: overrides.id ?? 'annotation-id',
+ question: overrides.question ?? 'question 1',
+ answer: overrides.answer ?? 'answer 1',
+ created_at: overrides.created_at ?? 1700000000,
+ hit_count: overrides.hit_count ?? 2,
+})
+
+const getCheckboxes = (container: HTMLElement) => container.querySelectorAll('[data-testid^="checkbox"]')
+
+describe('List', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should render annotation rows and call onView when clicking a row', () => {
+ const item = createAnnotation()
+ const onView = jest.fn()
+
+ render(
+
,
+ )
+
+ fireEvent.click(screen.getByText(item.question))
+
+ expect(onView).toHaveBeenCalledWith(item)
+ expect(mockFormatTime).toHaveBeenCalledWith(item.created_at, 'appLog.dateTimeFormat')
+ })
+
+ it('should toggle single and bulk selection states', () => {
+ const list = [createAnnotation({ id: 'a', question: 'A' }), createAnnotation({ id: 'b', question: 'B' })]
+ const onSelectedIdsChange = jest.fn()
+ const { container, rerender } = render(
+
,
+ )
+
+ const checkboxes = getCheckboxes(container)
+ fireEvent.click(checkboxes[1])
+ expect(onSelectedIdsChange).toHaveBeenCalledWith(['a'])
+
+ rerender(
+
,
+ )
+ const updatedCheckboxes = getCheckboxes(container)
+ fireEvent.click(updatedCheckboxes[1])
+ expect(onSelectedIdsChange).toHaveBeenCalledWith([])
+
+ fireEvent.click(updatedCheckboxes[0])
+ expect(onSelectedIdsChange).toHaveBeenCalledWith(['a', 'b'])
+ })
+
+ it('should confirm before removing an annotation and expose batch actions', async () => {
+ const item = createAnnotation({ id: 'to-delete', question: 'Delete me' })
+ const onRemove = jest.fn()
+ render(
+
,
+ )
+
+ const row = screen.getByText(item.question).closest('tr') as HTMLTableRowElement
+ const actionButtons = within(row).getAllByRole('button')
+ fireEvent.click(actionButtons[1])
+
+ expect(await screen.findByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
+ const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
+ fireEvent.click(confirmButton)
+ expect(onRemove).toHaveBeenCalledWith(item.id)
+
+ expect(screen.getByText('appAnnotation.batchAction.selected')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/app/annotation/list.tsx b/web/app/components/app/annotation/list.tsx
index 4135b4362e..62a0c50e60 100644
--- a/web/app/components/app/annotation/list.tsx
+++ b/web/app/components/app/annotation/list.tsx
@@ -7,7 +7,7 @@ import type { AnnotationItem } from './type'
import RemoveAnnotationConfirmModal from './remove-annotation-confirm-modal'
import ActionButton from '@/app/components/base/action-button'
import useTimestamp from '@/hooks/use-timestamp'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Checkbox from '@/app/components/base/checkbox'
import BatchAction from './batch-action'
diff --git a/web/app/components/app/annotation/type.ts b/web/app/components/app/annotation/type.ts
index 5df6f51ace..e2f2264f07 100644
--- a/web/app/components/app/annotation/type.ts
+++ b/web/app/components/app/annotation/type.ts
@@ -12,6 +12,12 @@ export type AnnotationItem = {
hit_count: number
}
+export type AnnotationCreateResponse = AnnotationItem & {
+ account?: {
+ name?: string
+ }
+}
+
export type HitHistoryItem = {
id: string
question: string
diff --git a/web/app/components/app/annotation/view-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/view-annotation-modal/index.spec.tsx
new file mode 100644
index 0000000000..dec0ad0c01
--- /dev/null
+++ b/web/app/components/app/annotation/view-annotation-modal/index.spec.tsx
@@ -0,0 +1,158 @@
+import React from 'react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import ViewAnnotationModal from './index'
+import type { AnnotationItem, HitHistoryItem } from '../type'
+import { fetchHitHistoryList } from '@/service/annotation'
+
+const mockFormatTime = jest.fn(() => 'formatted-time')
+
+jest.mock('@/hooks/use-timestamp', () => ({
+ __esModule: true,
+ default: () => ({
+ formatTime: mockFormatTime,
+ }),
+}))
+
+jest.mock('@/service/annotation', () => ({
+ fetchHitHistoryList: jest.fn(),
+}))
+
+jest.mock('../edit-annotation-modal/edit-item', () => {
+ const EditItemType = {
+ Query: 'query',
+ Answer: 'answer',
+ }
+ return {
+ __esModule: true,
+ default: ({ type, content, onSave }: { type: string; content: string; onSave: (value: string) => void }) => (
+
+
{content}
+
onSave(`${type}-updated`)}>edit-{type}
+
+ ),
+ EditItemType,
+ }
+})
+
+const fetchHitHistoryListMock = fetchHitHistoryList as jest.Mock
+
+const createAnnotationItem = (overrides: Partial = {}): AnnotationItem => ({
+ id: overrides.id ?? 'annotation-id',
+ question: overrides.question ?? 'question',
+ answer: overrides.answer ?? 'answer',
+ created_at: overrides.created_at ?? 1700000000,
+ hit_count: overrides.hit_count ?? 0,
+})
+
+const createHitHistoryItem = (overrides: Partial = {}): HitHistoryItem => ({
+ id: overrides.id ?? 'hit-id',
+ question: overrides.question ?? 'query',
+ match: overrides.match ?? 'match',
+ response: overrides.response ?? 'response',
+ source: overrides.source ?? 'source',
+ score: overrides.score ?? 0.42,
+ created_at: overrides.created_at ?? 1700000000,
+})
+
+const renderComponent = (props?: Partial>) => {
+ const item = createAnnotationItem()
+ const mergedProps: React.ComponentProps = {
+ appId: 'app-id',
+ isShow: true,
+ onHide: jest.fn(),
+ item,
+ onSave: jest.fn().mockResolvedValue(undefined),
+ onRemove: jest.fn().mockResolvedValue(undefined),
+ ...props,
+ }
+ return {
+ ...render( ),
+ props: mergedProps,
+ }
+}
+
+describe('ViewAnnotationModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ fetchHitHistoryListMock.mockResolvedValue({ data: [], total: 0 })
+ })
+
+ it('should render annotation tab and allow saving updated query', async () => {
+ // Arrange
+ const { props } = renderComponent()
+
+ await waitFor(() => {
+ expect(fetchHitHistoryListMock).toHaveBeenCalled()
+ })
+
+ // Act
+ fireEvent.click(screen.getByTestId('edit-query'))
+
+ // Assert
+ await waitFor(() => {
+ expect(props.onSave).toHaveBeenCalledWith('query-updated', props.item.answer)
+ })
+ })
+
+ it('should render annotation tab and allow saving updated answer', async () => {
+ // Arrange
+ const { props } = renderComponent()
+
+ await waitFor(() => {
+ expect(fetchHitHistoryListMock).toHaveBeenCalled()
+ })
+
+ // Act
+ fireEvent.click(screen.getByTestId('edit-answer'))
+
+ // Assert
+ await waitFor(() => {
+ expect(props.onSave).toHaveBeenCalledWith(props.item.question, 'answer-updated')
+ },
+ )
+ })
+
+ it('should switch to hit history tab and show no data message', async () => {
+ // Arrange
+ const { props } = renderComponent()
+
+ await waitFor(() => {
+ expect(fetchHitHistoryListMock).toHaveBeenCalled()
+ })
+
+ // Act
+ fireEvent.click(screen.getByText('appAnnotation.viewModal.hitHistory'))
+
+ // Assert
+ expect(await screen.findByText('appAnnotation.viewModal.noHitHistory')).toBeInTheDocument()
+ expect(mockFormatTime).toHaveBeenCalledWith(props.item.created_at, 'appLog.dateTimeFormat')
+ })
+
+ it('should render hit history entries with pagination badge when data exists', async () => {
+ const hits = [createHitHistoryItem({ question: 'user input' }), createHitHistoryItem({ id: 'hit-2', question: 'second' })]
+ fetchHitHistoryListMock.mockResolvedValue({ data: hits, total: 15 })
+
+ renderComponent()
+
+ fireEvent.click(await screen.findByText('appAnnotation.viewModal.hitHistory'))
+
+ expect(await screen.findByText('user input')).toBeInTheDocument()
+ expect(screen.getByText('15 appAnnotation.viewModal.hits')).toBeInTheDocument()
+ expect(mockFormatTime).toHaveBeenCalledWith(hits[0].created_at, 'appLog.dateTimeFormat')
+ })
+
+ it('should confirm before removing the annotation and hide on success', async () => {
+ const { props } = renderComponent()
+
+ fireEvent.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
+ expect(await screen.findByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
+
+ const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
+ fireEvent.click(confirmButton)
+
+ await waitFor(() => {
+ expect(props.onRemove).toHaveBeenCalledTimes(1)
+ expect(props.onHide).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/web/app/components/app/annotation/view-annotation-modal/index.tsx b/web/app/components/app/annotation/view-annotation-modal/index.tsx
index 8426ab0005..d21b177098 100644
--- a/web/app/components/app/annotation/view-annotation-modal/index.tsx
+++ b/web/app/components/app/annotation/view-annotation-modal/index.tsx
@@ -14,7 +14,7 @@ import TabSlider from '@/app/components/base/tab-slider-plain'
import { fetchHitHistoryList } from '@/service/annotation'
import { APP_PAGE_LIMIT } from '@/config'
import useTimestamp from '@/hooks/use-timestamp'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
appId: string
diff --git a/web/app/components/app/app-access-control/access-control-dialog.tsx b/web/app/components/app/app-access-control/access-control-dialog.tsx
index ee3fa9650b..99cf6d7074 100644
--- a/web/app/components/app/app-access-control/access-control-dialog.tsx
+++ b/web/app/components/app/app-access-control/access-control-dialog.tsx
@@ -2,7 +2,7 @@ import { Fragment, useCallback } from 'react'
import type { ReactNode } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { RiCloseLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type DialogProps = {
className?: string
diff --git a/web/app/components/app/app-access-control/access-control.spec.tsx b/web/app/components/app/app-access-control/access-control.spec.tsx
new file mode 100644
index 0000000000..ea0e17de2e
--- /dev/null
+++ b/web/app/components/app/app-access-control/access-control.spec.tsx
@@ -0,0 +1,389 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import AccessControl from './index'
+import AccessControlDialog from './access-control-dialog'
+import AccessControlItem from './access-control-item'
+import AddMemberOrGroupDialog from './add-member-or-group-pop'
+import SpecificGroupsOrMembers from './specific-groups-or-members'
+import useAccessControlStore from '@/context/access-control-store'
+import { useGlobalPublicStore } from '@/context/global-public-context'
+import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
+import { AccessMode, SubjectType } from '@/models/access-control'
+import Toast from '../../base/toast'
+import { defaultSystemFeatures } from '@/types/feature'
+import type { App } from '@/types/app'
+
+const mockUseAppWhiteListSubjects = jest.fn()
+const mockUseSearchForWhiteListCandidates = jest.fn()
+const mockMutateAsync = jest.fn()
+const mockUseUpdateAccessMode = jest.fn(() => ({
+ isPending: false,
+ mutateAsync: mockMutateAsync,
+}))
+
+jest.mock('@/context/app-context', () => ({
+ useSelector: (selector: (value: { userProfile: { email: string; id?: string; name?: string; avatar?: string; avatar_url?: string; is_password_set?: boolean } }) => T) => selector({
+ userProfile: {
+ id: 'current-user',
+ name: 'Current User',
+ email: 'member@example.com',
+ avatar: '',
+ avatar_url: '',
+ is_password_set: true,
+ },
+ }),
+}))
+
+jest.mock('@/service/common', () => ({
+ fetchCurrentWorkspace: jest.fn(),
+ fetchLangGeniusVersion: jest.fn(),
+ fetchUserProfile: jest.fn(),
+ getSystemFeatures: jest.fn(),
+}))
+
+jest.mock('@/service/access-control', () => ({
+ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
+ useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
+ useUpdateAccessMode: () => mockUseUpdateAccessMode(),
+}))
+
+jest.mock('@headlessui/react', () => {
+ const DialogComponent: any = ({ children, className, ...rest }: any) => (
+ {children}
+ )
+ DialogComponent.Panel = ({ children, className, ...rest }: any) => (
+ {children}
+ )
+ const DialogTitle = ({ children, className, ...rest }: any) => (
+ {children}
+ )
+ const DialogDescription = ({ children, className, ...rest }: any) => (
+ {children}
+ )
+ const TransitionChild = ({ children }: any) => (
+ <>{typeof children === 'function' ? children({}) : children}>
+ )
+ const Transition = ({ show = true, children }: any) => (
+ show ? <>{typeof children === 'function' ? children({}) : children}> : null
+ )
+ Transition.Child = TransitionChild
+ return {
+ Dialog: DialogComponent,
+ Transition,
+ DialogTitle,
+ Description: DialogDescription,
+ }
+})
+
+jest.mock('ahooks', () => {
+ const actual = jest.requireActual('ahooks')
+ return {
+ ...actual,
+ useDebounce: (value: unknown) => value,
+ }
+})
+
+const createGroup = (overrides: Partial = {}): AccessControlGroup => ({
+ id: 'group-1',
+ name: 'Group One',
+ groupSize: 5,
+ ...overrides,
+} as AccessControlGroup)
+
+const createMember = (overrides: Partial = {}): AccessControlAccount => ({
+ id: 'member-1',
+ name: 'Member One',
+ email: 'member@example.com',
+ avatar: '',
+ avatarUrl: '',
+ ...overrides,
+} as AccessControlAccount)
+
+const baseGroup = createGroup()
+const baseMember = createMember()
+const groupSubject: Subject = {
+ subjectId: baseGroup.id,
+ subjectType: SubjectType.GROUP,
+ groupData: baseGroup,
+} as Subject
+const memberSubject: Subject = {
+ subjectId: baseMember.id,
+ subjectType: SubjectType.ACCOUNT,
+ accountData: baseMember,
+} as Subject
+
+const resetAccessControlStore = () => {
+ useAccessControlStore.setState({
+ appId: '',
+ specificGroups: [],
+ specificMembers: [],
+ currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
+ selectedGroupsForBreadcrumb: [],
+ })
+}
+
+const resetGlobalStore = () => {
+ useGlobalPublicStore.setState({
+ systemFeatures: defaultSystemFeatures,
+ isGlobalPending: false,
+ })
+}
+
+beforeAll(() => {
+ class MockIntersectionObserver {
+ observe = jest.fn(() => undefined)
+ disconnect = jest.fn(() => undefined)
+ unobserve = jest.fn(() => undefined)
+ }
+ // @ts-expect-error jsdom does not implement IntersectionObserver
+ globalThis.IntersectionObserver = MockIntersectionObserver
+})
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ resetAccessControlStore()
+ resetGlobalStore()
+ mockMutateAsync.mockResolvedValue(undefined)
+ mockUseUpdateAccessMode.mockReturnValue({
+ isPending: false,
+ mutateAsync: mockMutateAsync,
+ })
+ mockUseAppWhiteListSubjects.mockReturnValue({
+ isPending: false,
+ data: {
+ groups: [baseGroup],
+ members: [baseMember],
+ },
+ })
+ mockUseSearchForWhiteListCandidates.mockReturnValue({
+ isLoading: false,
+ isFetchingNextPage: false,
+ fetchNextPage: jest.fn(),
+ data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }] },
+ })
+})
+
+// AccessControlItem handles selected vs. unselected styling and click state updates
+describe('AccessControlItem', () => {
+ it('should update current menu when selecting a different access type', () => {
+ useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC })
+ render(
+
+ Organization Only
+ ,
+ )
+
+ const option = screen.getByText('Organization Only').parentElement as HTMLElement
+ expect(option).toHaveClass('cursor-pointer')
+
+ fireEvent.click(option)
+
+ expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
+ })
+
+ it('should keep current menu when clicking the selected access type', () => {
+ useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
+ render(
+
+ Organization Only
+ ,
+ )
+
+ const option = screen.getByText('Organization Only').parentElement as HTMLElement
+ fireEvent.click(option)
+
+ expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
+ })
+})
+
+// AccessControlDialog renders a headless UI dialog with a manual close control
+describe('AccessControlDialog', () => {
+ it('should render dialog content when visible', () => {
+ render(
+
+ Dialog Content
+ ,
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByText('Dialog Content')).toBeInTheDocument()
+ })
+
+ it('should trigger onClose when clicking the close control', async () => {
+ const handleClose = jest.fn()
+ const { container } = render(
+
+ Dialog Content
+ ,
+ )
+
+ const closeButton = container.querySelector('.absolute.right-5.top-5') as HTMLElement
+ fireEvent.click(closeButton)
+
+ await waitFor(() => {
+ expect(handleClose).toHaveBeenCalledTimes(1)
+ })
+ })
+})
+
+// SpecificGroupsOrMembers syncs store state with fetched data and supports removals
+describe('SpecificGroupsOrMembers', () => {
+ it('should render collapsed view when not in specific selection mode', () => {
+ useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
+
+ render( )
+
+ expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
+ expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
+ })
+
+ it('should show loading state while pending', async () => {
+ useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
+ mockUseAppWhiteListSubjects.mockReturnValue({
+ isPending: true,
+ data: undefined,
+ })
+
+ const { container } = render( )
+
+ await waitFor(() => {
+ expect(container.querySelector('.spin-animation')).toBeInTheDocument()
+ })
+ })
+
+ it('should render fetched groups and members and support removal', async () => {
+ useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
+
+ render( )
+
+ await waitFor(() => {
+ expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
+ expect(screen.getByText(baseMember.name)).toBeInTheDocument()
+ })
+
+ const groupItem = screen.getByText(baseGroup.name).closest('div')
+ const groupRemove = groupItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
+ fireEvent.click(groupRemove)
+
+ await waitFor(() => {
+ expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
+ })
+
+ const memberItem = screen.getByText(baseMember.name).closest('div')
+ const memberRemove = memberItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
+ fireEvent.click(memberRemove)
+
+ await waitFor(() => {
+ expect(screen.queryByText(baseMember.name)).not.toBeInTheDocument()
+ })
+ })
+})
+
+// AddMemberOrGroupDialog renders search results and updates store selections
+describe('AddMemberOrGroupDialog', () => {
+ it('should open search popover and display candidates', async () => {
+ const user = userEvent.setup()
+
+ render( )
+
+ await user.click(screen.getByText('common.operation.add'))
+
+ expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument()
+ expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
+ expect(screen.getByText(baseMember.name)).toBeInTheDocument()
+ })
+
+ it('should allow selecting members and expanding groups', async () => {
+ const user = userEvent.setup()
+ render( )
+
+ await user.click(screen.getByText('common.operation.add'))
+
+ const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')
+ await user.click(expandButton)
+ expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
+
+ const memberLabel = screen.getByText(baseMember.name)
+ const memberCheckbox = memberLabel.parentElement?.previousElementSibling as HTMLElement
+ fireEvent.click(memberCheckbox)
+
+ expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
+ })
+
+ it('should show empty state when no candidates are returned', async () => {
+ mockUseSearchForWhiteListCandidates.mockReturnValue({
+ isLoading: false,
+ isFetchingNextPage: false,
+ fetchNextPage: jest.fn(),
+ data: { pages: [] },
+ })
+
+ const user = userEvent.setup()
+ render( )
+
+ await user.click(screen.getByText('common.operation.add'))
+
+ expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument()
+ })
+})
+
+// AccessControl integrates dialog, selection items, and confirm flow
+describe('AccessControl', () => {
+ it('should initialize menu from app and call update on confirm', async () => {
+ const onClose = jest.fn()
+ const onConfirm = jest.fn()
+ const toastSpy = jest.spyOn(Toast, 'notify').mockReturnValue({})
+ useAccessControlStore.setState({
+ specificGroups: [baseGroup],
+ specificMembers: [baseMember],
+ })
+ const app = {
+ id: 'app-id-1',
+ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
+ } as App
+
+ render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS)
+ })
+
+ fireEvent.click(screen.getByText('common.operation.confirm'))
+
+ await waitFor(() => {
+ expect(mockMutateAsync).toHaveBeenCalledWith({
+ appId: app.id,
+ accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
+ subjects: [
+ { subjectId: baseGroup.id, subjectType: SubjectType.GROUP },
+ { subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT },
+ ],
+ })
+ expect(toastSpy).toHaveBeenCalled()
+ expect(onConfirm).toHaveBeenCalled()
+ })
+ })
+
+ it('should expose the external members tip when SSO is disabled', () => {
+ const app = {
+ id: 'app-id-2',
+ access_mode: AccessMode.PUBLIC,
+ } as App
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
+ expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx
index e9519aeedf..17263fdd46 100644
--- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx
+++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx
@@ -11,7 +11,7 @@ import Input from '../../base/input'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import Loading from '../../base/loading'
import useAccessControlStore from '../../../../context/access-control-store'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
import { SubjectType } from '@/models/access-control'
@@ -32,7 +32,7 @@ export default function AddMemberOrGroupDialog() {
const anchorRef = useRef(null)
useEffect(() => {
- const hasMore = data?.pages?.[0].hasMore ?? false
+ const hasMore = data?.pages?.[0]?.hasMore ?? false
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {
@@ -106,7 +106,7 @@ function SelectedGroupsBreadCrumb() {
setSelectedGroupsForBreadcrumb([])
}, [setSelectedGroupsForBreadcrumb])
return
-
0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}
+
0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}
{selectedGroupsForBreadcrumb.map((group, index) => {
return
/
@@ -198,7 +198,7 @@ type BaseItemProps = {
children: React.ReactNode
}
function BaseItem({ children, className }: BaseItemProps) {
- return
+ return
{children}
}
diff --git a/web/app/components/app/app-publisher/suggested-action.tsx b/web/app/components/app/app-publisher/suggested-action.tsx
index 2535de6654..154bacc361 100644
--- a/web/app/components/app/app-publisher/suggested-action.tsx
+++ b/web/app/components/app/app-publisher/suggested-action.tsx
@@ -1,6 +1,6 @@
import type { HTMLProps, PropsWithChildren } from 'react'
import { RiArrowRightUpLine } from '@remixicon/react'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type SuggestedActionProps = PropsWithChildren
& {
icon?: React.ReactNode
@@ -19,11 +19,9 @@ const SuggestedAction = ({ icon, link, disabled, children, className, onClick, .
href={disabled ? undefined : link}
target='_blank'
rel='noreferrer'
- className={classNames(
- 'flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors [&:not(:first-child)]:mt-1',
+ className={cn('flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors [&:not(:first-child)]:mt-1',
disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer text-text-secondary hover:bg-state-accent-hover hover:text-text-accent',
- className,
- )}
+ className)}
onClick={handleClick}
{...props}
>
diff --git a/web/app/components/app/configuration/base/feature-panel/index.tsx b/web/app/components/app/configuration/base/feature-panel/index.tsx
index ec5ab96d76..c9ebfefbe5 100644
--- a/web/app/components/app/configuration/base/feature-panel/index.tsx
+++ b/web/app/components/app/configuration/base/feature-panel/index.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC, ReactNode } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type IFeaturePanelProps = {
className?: string
diff --git a/web/app/components/app/configuration/base/operation-btn/index.tsx b/web/app/components/app/configuration/base/operation-btn/index.tsx
index aba35cded2..db19d2976e 100644
--- a/web/app/components/app/configuration/base/operation-btn/index.tsx
+++ b/web/app/components/app/configuration/base/operation-btn/index.tsx
@@ -6,7 +6,7 @@ import {
RiAddLine,
RiEditLine,
} from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { noop } from 'lodash-es'
export type IOperationBtnProps = {
diff --git a/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx b/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx
new file mode 100644
index 0000000000..d625e9fb72
--- /dev/null
+++ b/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import CannotQueryDataset from './cannot-query-dataset'
+
+describe('CannotQueryDataset WarningMask', () => {
+ test('should render dataset warning copy and action button', () => {
+ const onConfirm = jest.fn()
+ render( )
+
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSet')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.unableToQueryDataSetTip')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' })).toBeInTheDocument()
+ })
+
+ test('should invoke onConfirm when OK button clicked', () => {
+ const onConfirm = jest.fn()
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'appDebug.feature.dataSet.queryVariable.ok' }))
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx b/web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx
new file mode 100644
index 0000000000..a968bde272
--- /dev/null
+++ b/web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx
@@ -0,0 +1,39 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import FormattingChanged from './formatting-changed'
+
+describe('FormattingChanged WarningMask', () => {
+ test('should display translation text and both actions', () => {
+ const onConfirm = jest.fn()
+ const onCancel = jest.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('appDebug.formattingChangedTitle')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.formattingChangedText')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /common\.operation\.refresh/ })).toBeInTheDocument()
+ })
+
+ test('should call callbacks when buttons are clicked', () => {
+ const onConfirm = jest.fn()
+ const onCancel = jest.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /common\.operation\.refresh/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx b/web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx
new file mode 100644
index 0000000000..46608374da
--- /dev/null
+++ b/web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx
@@ -0,0 +1,26 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import HasNotSetAPI from './has-not-set-api'
+
+describe('HasNotSetAPI WarningMask', () => {
+ test('should show default title when trial not finished', () => {
+ render( )
+
+ expect(screen.getByText('appDebug.notSetAPIKey.title')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.notSetAPIKey.description')).toBeInTheDocument()
+ })
+
+ test('should show trail finished title when flag is true', () => {
+ render( )
+
+ expect(screen.getByText('appDebug.notSetAPIKey.trailFinished')).toBeInTheDocument()
+ })
+
+ test('should call onSetting when primary button clicked', () => {
+ const onSetting = jest.fn()
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'appDebug.notSetAPIKey.settingBtn' }))
+ expect(onSetting).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/app/configuration/base/warning-mask/index.spec.tsx b/web/app/components/app/configuration/base/warning-mask/index.spec.tsx
new file mode 100644
index 0000000000..6d533a423d
--- /dev/null
+++ b/web/app/components/app/configuration/base/warning-mask/index.spec.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import WarningMask from './index'
+
+describe('WarningMask', () => {
+ // Rendering of title, description, and footer content
+ describe('Rendering', () => {
+ test('should display provided title, description, and footer node', () => {
+ const footer = Retry
+ // Arrange
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('Access Restricted')).toBeInTheDocument()
+ expect(screen.getByText('Only workspace owners may modify this section.')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx
index 5bf2f177ff..6492864ce2 100644
--- a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx
+++ b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx
@@ -14,7 +14,7 @@ import s from './style.module.css'
import MessageTypeSelector from './message-type-selector'
import ConfirmAddVar from './confirm-add-var'
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { PromptRole, PromptVariable } from '@/models/debug'
import {
Copy,
diff --git a/web/app/components/app/configuration/config-prompt/message-type-selector.tsx b/web/app/components/app/configuration/config-prompt/message-type-selector.tsx
index 17b3ecb2f1..71f3e6ee5f 100644
--- a/web/app/components/app/configuration/config-prompt/message-type-selector.tsx
+++ b/web/app/components/app/configuration/config-prompt/message-type-selector.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import { useBoolean, useClickAway } from 'ahooks'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { PromptRole } from '@/models/debug'
import { ChevronSelectorVertical } from '@/app/components/base/icons/src/vender/line/arrows'
type Props = {
diff --git a/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx b/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx
index 9e10db93ae..90a19c883a 100644
--- a/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx
+++ b/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx
@@ -2,7 +2,7 @@
import React, { useCallback, useEffect, useState } from 'react'
import type { FC } from 'react'
import { useDebounceFn } from 'ahooks'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
className?: string
diff --git a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx
index 68bf6dd7c2..e4c21b0cbc 100644
--- a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx
+++ b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx
@@ -7,7 +7,7 @@ import { produce } from 'immer'
import { useContext } from 'use-context-selector'
import ConfirmAddVar from './confirm-add-var'
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { PromptVariable } from '@/models/debug'
import Tooltip from '@/app/components/base/tooltip'
import { AppModeEnum } from '@/types/app'
diff --git a/web/app/components/app/configuration/config-var/config-modal/field.tsx b/web/app/components/app/configuration/config-var/config-modal/field.tsx
index b24e0be6ce..76d228358a 100644
--- a/web/app/components/app/configuration/config-var/config-modal/field.tsx
+++ b/web/app/components/app/configuration/config-var/config-modal/field.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
type Props = {
diff --git a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx
index 2b52991d4a..53d59eb24b 100644
--- a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx
+++ b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx
@@ -2,7 +2,6 @@
import type { FC } from 'react'
import React, { useState } from 'react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
-import classNames from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@@ -10,7 +9,7 @@ import {
} from '@/app/components/base/portal-to-follow-elem'
import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon'
import type { InputVarType } from '@/app/components/workflow/types'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Badge from '@/app/components/base/badge'
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
@@ -47,7 +46,7 @@ const TypeSelector: FC = ({
>
!readonly && setOpen(v => !v)} className='w-full'>
@@ -69,7 +68,7 @@ const TypeSelector: FC
= ({
{items.map((item: Item) => (
) => {
+ const onChange = jest.fn()
+ const defaultProps: IConfigStringProps = {
+ value: 5,
+ maxLength: 10,
+ modelId: 'model-id',
+ onChange,
+ }
+
+ render(
)
+
+ return { onChange }
+}
+
+describe('ConfigString', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render numeric input with bounds', () => {
+ renderConfigString({ value: 3, maxLength: 8 })
+
+ const input = screen.getByRole('spinbutton')
+
+ expect(input).toHaveValue(3)
+ expect(input).toHaveAttribute('min', '1')
+ expect(input).toHaveAttribute('max', '8')
+ })
+
+ it('should render empty input when value is undefined', () => {
+ const { onChange } = renderConfigString({ value: undefined })
+
+ expect(screen.getByRole('spinbutton')).toHaveValue(null)
+ expect(onChange).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Effect behavior', () => {
+ it('should clamp initial value to maxLength when it exceeds limit', async () => {
+ const onChange = jest.fn()
+ render(
+
,
+ )
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledWith(10)
+ })
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+
+ it('should clamp when updated prop value exceeds maxLength', async () => {
+ const onChange = jest.fn()
+ const { rerender } = render(
+
,
+ )
+
+ rerender(
+
,
+ )
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledWith(6)
+ })
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('User interactions', () => {
+ it('should clamp entered value above maxLength', () => {
+ const { onChange } = renderConfigString({ maxLength: 7 })
+
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
+
+ expect(onChange).toHaveBeenCalledWith(7)
+ })
+
+ it('should raise value below minimum to one', () => {
+ const { onChange } = renderConfigString()
+
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '0' } })
+
+ expect(onChange).toHaveBeenCalledWith(1)
+ })
+
+ it('should forward parsed value when within bounds', () => {
+ const { onChange } = renderConfigString({ maxLength: 9 })
+
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } })
+
+ expect(onChange).toHaveBeenCalledWith(7)
+ })
+
+ it('should pass through NaN when input is cleared', () => {
+ const { onChange } = renderConfigString()
+
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '' } })
+
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange.mock.calls[0][0]).toBeNaN()
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx
index 4090b39a3b..c6613bfbe4 100644
--- a/web/app/components/app/configuration/config-var/index.tsx
+++ b/web/app/components/app/configuration/config-var/index.tsx
@@ -23,7 +23,7 @@ import { useModalContext } from '@/context/modal-context'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import type { InputVar } from '@/app/components/workflow/types'
import { InputVarType } from '@/app/components/workflow/types'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export const ADD_EXTERNAL_DATA_TOOL = 'ADD_EXTERNAL_DATA_TOOL'
diff --git a/web/app/components/app/configuration/config-var/select-type-item/index.spec.tsx b/web/app/components/app/configuration/config-var/select-type-item/index.spec.tsx
new file mode 100644
index 0000000000..469164e607
--- /dev/null
+++ b/web/app/components/app/configuration/config-var/select-type-item/index.spec.tsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import SelectTypeItem from './index'
+import { InputVarType } from '@/app/components/workflow/types'
+
+describe('SelectTypeItem', () => {
+ // Rendering pathways based on type and selection state
+ describe('Rendering', () => {
+ test('should render ok', () => {
+ // Arrange
+ const { container } = render(
+
,
+ )
+
+ // Assert
+ expect(screen.getByText('appDebug.variableConfig.text-input')).toBeInTheDocument()
+ expect(container.querySelector('svg')).not.toBeNull()
+ })
+ })
+
+ // User interaction outcomes
+ describe('Interactions', () => {
+ test('should trigger onClick when item is pressed', () => {
+ const handleClick = jest.fn()
+ // Arrange
+ render(
+
,
+ )
+
+ // Act
+ fireEvent.click(screen.getByText('appDebug.variableConfig.paragraph'))
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/config-var/select-type-item/index.tsx b/web/app/components/app/configuration/config-var/select-type-item/index.tsx
index a952bcf02a..58d463ac60 100644
--- a/web/app/components/app/configuration/config-var/select-type-item/index.tsx
+++ b/web/app/components/app/configuration/config-var/select-type-item/index.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { InputVarType } from '@/app/components/workflow/types'
import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon'
export type ISelectTypeItemProps = {
diff --git a/web/app/components/app/configuration/config-var/var-item.tsx b/web/app/components/app/configuration/config-var/var-item.tsx
index 88cd5d7843..5ddb61a0e5 100644
--- a/web/app/components/app/configuration/config-var/var-item.tsx
+++ b/web/app/components/app/configuration/config-var/var-item.tsx
@@ -10,7 +10,7 @@ import type { IInputTypeIconProps } from './input-type-icon'
import IconTypeIcon from './input-type-icon'
import { BracketsX as VarIcon } from '@/app/components/base/icons/src/vender/line/development'
import Badge from '@/app/components/base/badge'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type ItemProps = {
className?: string
diff --git a/web/app/components/app/configuration/config-vision/index.spec.tsx b/web/app/components/app/configuration/config-vision/index.spec.tsx
new file mode 100644
index 0000000000..e22db7b24e
--- /dev/null
+++ b/web/app/components/app/configuration/config-vision/index.spec.tsx
@@ -0,0 +1,227 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ConfigVision from './index'
+import ParamConfig from './param-config'
+import ParamConfigContent from './param-config-content'
+import type { FeatureStoreState } from '@/app/components/base/features/store'
+import type { FileUpload } from '@/app/components/base/features/types'
+import { Resolution, TransferMethod } from '@/types/app'
+import { SupportUploadFileTypes } from '@/app/components/workflow/types'
+
+const mockUseContext = jest.fn()
+jest.mock('use-context-selector', () => {
+ const actual = jest.requireActual('use-context-selector')
+ return {
+ ...actual,
+ useContext: (context: unknown) => mockUseContext(context),
+ }
+})
+
+const mockUseFeatures = jest.fn()
+const mockUseFeaturesStore = jest.fn()
+jest.mock('@/app/components/base/features/hooks', () => ({
+ useFeatures: (selector: (state: FeatureStoreState) => any) => mockUseFeatures(selector),
+ useFeaturesStore: () => mockUseFeaturesStore(),
+}))
+
+const defaultFile: FileUpload = {
+ enabled: false,
+ allowed_file_types: [],
+ allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
+ number_limits: 3,
+ image: {
+ enabled: false,
+ detail: Resolution.low,
+ number_limits: 3,
+ transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
+ },
+}
+
+let featureStoreState: FeatureStoreState
+let setFeaturesMock: jest.Mock
+
+const setupFeatureStore = (fileOverrides: Partial
= {}) => {
+ const mergedFile: FileUpload = {
+ ...defaultFile,
+ ...fileOverrides,
+ image: {
+ ...defaultFile.image,
+ ...fileOverrides.image,
+ },
+ }
+ featureStoreState = {
+ features: {
+ file: mergedFile,
+ },
+ setFeatures: jest.fn(),
+ showFeaturesModal: false,
+ setShowFeaturesModal: jest.fn(),
+ }
+ setFeaturesMock = featureStoreState.setFeatures as jest.Mock
+ mockUseFeaturesStore.mockReturnValue({
+ getState: () => featureStoreState,
+ })
+ mockUseFeatures.mockImplementation(selector => selector(featureStoreState))
+}
+
+const getLatestFileConfig = () => {
+ expect(setFeaturesMock).toHaveBeenCalled()
+ const latestFeatures = setFeaturesMock.mock.calls[setFeaturesMock.mock.calls.length - 1][0] as { file: FileUpload }
+ return latestFeatures.file
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseContext.mockReturnValue({
+ isShowVisionConfig: true,
+ isAllowVideoUpload: false,
+ })
+ setupFeatureStore()
+})
+
+// ConfigVision handles toggling file upload types + visibility rules.
+describe('ConfigVision', () => {
+ it('should not render when vision configuration is hidden', () => {
+ mockUseContext.mockReturnValue({
+ isShowVisionConfig: false,
+ isAllowVideoUpload: false,
+ })
+
+ render( )
+
+ expect(screen.queryByText('appDebug.vision.name')).not.toBeInTheDocument()
+ })
+
+ it('should show the toggle and parameter controls when visible', () => {
+ render( )
+
+ expect(screen.getByText('appDebug.vision.name')).toBeInTheDocument()
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
+ })
+
+ it('should enable both image and video uploads when toggled on with video support', async () => {
+ const user = userEvent.setup()
+ mockUseContext.mockReturnValue({
+ isShowVisionConfig: true,
+ isAllowVideoUpload: true,
+ })
+ setupFeatureStore({
+ allowed_file_types: [],
+ })
+
+ render( )
+ await user.click(screen.getByRole('switch'))
+
+ const updatedFile = getLatestFileConfig()
+ expect(updatedFile.allowed_file_types).toEqual([SupportUploadFileTypes.image, SupportUploadFileTypes.video])
+ expect(updatedFile.image?.enabled).toBe(true)
+ expect(updatedFile.enabled).toBe(true)
+ })
+
+ it('should disable image and video uploads when toggled off and no other types remain', async () => {
+ const user = userEvent.setup()
+ mockUseContext.mockReturnValue({
+ isShowVisionConfig: true,
+ isAllowVideoUpload: true,
+ })
+ setupFeatureStore({
+ allowed_file_types: [SupportUploadFileTypes.image, SupportUploadFileTypes.video],
+ enabled: true,
+ image: {
+ enabled: true,
+ },
+ })
+
+ render( )
+ await user.click(screen.getByRole('switch'))
+
+ const updatedFile = getLatestFileConfig()
+ expect(updatedFile.allowed_file_types).toEqual([])
+ expect(updatedFile.enabled).toBe(false)
+ expect(updatedFile.image?.enabled).toBe(false)
+ })
+
+ it('should keep file uploads enabled when other file types remain after disabling vision', async () => {
+ const user = userEvent.setup()
+ mockUseContext.mockReturnValue({
+ isShowVisionConfig: true,
+ isAllowVideoUpload: false,
+ })
+ setupFeatureStore({
+ allowed_file_types: [SupportUploadFileTypes.image, SupportUploadFileTypes.document],
+ enabled: true,
+ image: { enabled: true },
+ })
+
+ render( )
+ await user.click(screen.getByRole('switch'))
+
+ const updatedFile = getLatestFileConfig()
+ expect(updatedFile.allowed_file_types).toEqual([SupportUploadFileTypes.document])
+ expect(updatedFile.enabled).toBe(true)
+ expect(updatedFile.image?.enabled).toBe(false)
+ })
+})
+
+// ParamConfig exposes ParamConfigContent via an inline trigger.
+describe('ParamConfig', () => {
+ it('should toggle parameter panel when clicking the settings button', async () => {
+ setupFeatureStore()
+ const user = userEvent.setup()
+
+ render( )
+
+ expect(screen.queryByText('appDebug.vision.visionSettings.title')).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'appDebug.voice.settings' }))
+
+ expect(await screen.findByText('appDebug.vision.visionSettings.title')).toBeInTheDocument()
+ })
+})
+
+// ParamConfigContent manages resolution, upload source, and count limits.
+describe('ParamConfigContent', () => {
+ it('should set resolution to high when the corresponding option is selected', async () => {
+ const user = userEvent.setup()
+ setupFeatureStore({
+ image: { detail: Resolution.low },
+ })
+
+ render( )
+
+ await user.click(screen.getByText('appDebug.vision.visionSettings.high'))
+
+ const updatedFile = getLatestFileConfig()
+ expect(updatedFile.image?.detail).toBe(Resolution.high)
+ })
+
+ it('should switch upload method to local only', async () => {
+ const user = userEvent.setup()
+ setupFeatureStore({
+ allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
+ })
+
+ render( )
+
+ await user.click(screen.getByText('appDebug.vision.visionSettings.localUpload'))
+
+ const updatedFile = getLatestFileConfig()
+ expect(updatedFile.allowed_file_upload_methods).toEqual([TransferMethod.local_file])
+ expect(updatedFile.image?.transfer_methods).toEqual([TransferMethod.local_file])
+ })
+
+ it('should update upload limit value when input changes', async () => {
+ setupFeatureStore({
+ number_limits: 2,
+ })
+
+ render( )
+ const input = screen.getByRole('spinbutton') as HTMLInputElement
+ fireEvent.change(input, { target: { value: '4' } })
+
+ const updatedFile = getLatestFileConfig()
+ expect(updatedFile.number_limits).toBe(4)
+ expect(updatedFile.image?.number_limits).toBe(4)
+ })
+})
diff --git a/web/app/components/app/configuration/config-vision/param-config.tsx b/web/app/components/app/configuration/config-vision/param-config.tsx
index 5e4aac6a25..e6af188052 100644
--- a/web/app/components/app/configuration/config-vision/param-config.tsx
+++ b/web/app/components/app/configuration/config-vision/param-config.tsx
@@ -10,7 +10,7 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
const ParamsConfig: FC = () => {
const { t } = useTranslation()
diff --git a/web/app/components/app/configuration/config/agent-setting-button.spec.tsx b/web/app/components/app/configuration/config/agent-setting-button.spec.tsx
new file mode 100644
index 0000000000..db70865e51
--- /dev/null
+++ b/web/app/components/app/configuration/config/agent-setting-button.spec.tsx
@@ -0,0 +1,100 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import AgentSettingButton from './agent-setting-button'
+import type { AgentConfig } from '@/models/debug'
+import { AgentStrategy } from '@/types/app'
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+let latestAgentSettingProps: any
+jest.mock('./agent/agent-setting', () => ({
+ __esModule: true,
+ default: (props: any) => {
+ latestAgentSettingProps = props
+ return (
+
+ props.onSave({ ...props.payload, max_iteration: 9 })}>
+ save-agent
+
+
+ cancel-agent
+
+
+ )
+ },
+}))
+
+const createAgentConfig = (overrides: Partial = {}): AgentConfig => ({
+ enabled: true,
+ strategy: AgentStrategy.react,
+ max_iteration: 3,
+ tools: [],
+ ...overrides,
+})
+
+const setup = (overrides: Partial> = {}) => {
+ const props: React.ComponentProps = {
+ isFunctionCall: false,
+ isChatModel: true,
+ onAgentSettingChange: jest.fn(),
+ agentConfig: createAgentConfig(),
+ ...overrides,
+ }
+
+ const user = userEvent.setup()
+ render( )
+ return { props, user }
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ latestAgentSettingProps = undefined
+})
+
+describe('AgentSettingButton', () => {
+ it('should render button label from translation key', () => {
+ setup()
+
+ expect(screen.getByRole('button', { name: 'appDebug.agent.setting.name' })).toBeInTheDocument()
+ })
+
+ it('should open AgentSetting with the provided configuration when clicked', async () => {
+ const { user, props } = setup({ isFunctionCall: true, isChatModel: false })
+
+ await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
+
+ expect(screen.getByTestId('agent-setting')).toBeInTheDocument()
+ expect(latestAgentSettingProps.isFunctionCall).toBe(true)
+ expect(latestAgentSettingProps.isChatModel).toBe(false)
+ expect(latestAgentSettingProps.payload).toEqual(props.agentConfig)
+ })
+
+ it('should call onAgentSettingChange and close when AgentSetting saves', async () => {
+ const { user, props } = setup()
+
+ await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
+ await user.click(screen.getByText('save-agent'))
+
+ expect(props.onAgentSettingChange).toHaveBeenCalledTimes(1)
+ expect(props.onAgentSettingChange).toHaveBeenCalledWith({
+ ...props.agentConfig,
+ max_iteration: 9,
+ })
+ expect(screen.queryByTestId('agent-setting')).not.toBeInTheDocument()
+ })
+
+ it('should close AgentSetting without saving when cancel is triggered', async () => {
+ const { user, props } = setup()
+
+ await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
+ await user.click(screen.getByText('cancel-agent'))
+
+ expect(props.onAgentSettingChange).not.toHaveBeenCalled()
+ expect(screen.queryByTestId('agent-setting')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx
new file mode 100644
index 0000000000..2ff1034537
--- /dev/null
+++ b/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx
@@ -0,0 +1,106 @@
+import React from 'react'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import AgentSetting from './index'
+import { MAX_ITERATIONS_NUM } from '@/config'
+import type { AgentConfig } from '@/models/debug'
+
+jest.mock('ahooks', () => {
+ const actual = jest.requireActual('ahooks')
+ return {
+ ...actual,
+ useClickAway: jest.fn(),
+ }
+})
+
+jest.mock('react-slider', () => (props: { className?: string; min?: number; max?: number; value: number; onChange: (value: number) => void }) => (
+ props.onChange(Number(e.target.value))}
+ />
+))
+
+const basePayload = {
+ enabled: true,
+ strategy: 'react',
+ max_iteration: 5,
+ tools: [],
+}
+
+const renderModal = (props?: Partial>) => {
+ const onCancel = jest.fn()
+ const onSave = jest.fn()
+ const utils = render(
+ ,
+ )
+ return { ...utils, onCancel, onSave }
+}
+
+describe('AgentSetting', () => {
+ test('should render agent mode description and default prompt section when not function call', () => {
+ renderModal()
+
+ expect(screen.getByText('appDebug.agent.agentMode')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeInTheDocument()
+ expect(screen.getByText('tools.builtInPromptTitle')).toBeInTheDocument()
+ })
+
+ test('should display function call mode when isFunctionCall true', () => {
+ renderModal({ isFunctionCall: true })
+
+ expect(screen.getByText('appDebug.agent.agentModeType.functionCall')).toBeInTheDocument()
+ expect(screen.queryByText('tools.builtInPromptTitle')).not.toBeInTheDocument()
+ })
+
+ test('should update iteration via slider and number input', () => {
+ const { container } = renderModal()
+ const slider = container.querySelector('.slider') as HTMLInputElement
+ const numberInput = screen.getByRole('spinbutton')
+
+ fireEvent.change(slider, { target: { value: '7' } })
+ expect(screen.getAllByDisplayValue('7')).toHaveLength(2)
+
+ fireEvent.change(numberInput, { target: { value: '2' } })
+ expect(screen.getAllByDisplayValue('2')).toHaveLength(2)
+ })
+
+ test('should clamp iteration value within min/max range', () => {
+ renderModal()
+
+ const numberInput = screen.getByRole('spinbutton')
+
+ fireEvent.change(numberInput, { target: { value: '0' } })
+ expect(screen.getAllByDisplayValue('1')).toHaveLength(2)
+
+ fireEvent.change(numberInput, { target: { value: '999' } })
+ expect(screen.getAllByDisplayValue(String(MAX_ITERATIONS_NUM))).toHaveLength(2)
+ })
+
+ test('should call onCancel when cancel button clicked', () => {
+ const { onCancel } = renderModal()
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+ expect(onCancel).toHaveBeenCalled()
+ })
+
+ test('should call onSave with updated payload', async () => {
+ const { onSave } = renderModal()
+ const numberInput = screen.getByRole('spinbutton')
+ fireEvent.change(numberInput, { target: { value: '6' } })
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
+ })
+
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ max_iteration: 6 }))
+ })
+})
diff --git a/web/app/components/app/configuration/config/agent/agent-setting/item-panel.spec.tsx b/web/app/components/app/configuration/config/agent/agent-setting/item-panel.spec.tsx
new file mode 100644
index 0000000000..242f249738
--- /dev/null
+++ b/web/app/components/app/configuration/config/agent/agent-setting/item-panel.spec.tsx
@@ -0,0 +1,21 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import ItemPanel from './item-panel'
+
+describe('AgentSetting/ItemPanel', () => {
+ test('should render icon, name, and children content', () => {
+ render(
+ icon}
+ name="Panel name"
+ description="More info"
+ children={child content
}
+ />,
+ )
+
+ expect(screen.getByText('Panel name')).toBeInTheDocument()
+ expect(screen.getByText('child content')).toBeInTheDocument()
+ expect(screen.getByText('icon')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx b/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx
index 6512e11545..6193392026 100644
--- a/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx
+++ b/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
type Props = {
className?: string
diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.spec.tsx
new file mode 100644
index 0000000000..9899f15375
--- /dev/null
+++ b/web/app/components/app/configuration/config/agent/agent-tools/index.spec.tsx
@@ -0,0 +1,466 @@
+import type {
+ PropsWithChildren,
+} from 'react'
+import React, {
+ useEffect,
+ useMemo,
+ useState,
+} from 'react'
+import { act, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import AgentTools from './index'
+import ConfigContext from '@/context/debug-configuration'
+import type { AgentTool } from '@/types/app'
+import { CollectionType, type Tool, type ToolParameter } from '@/app/components/tools/types'
+import type { ToolWithProvider } from '@/app/components/workflow/types'
+import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
+import type { ModelConfig } from '@/models/debug'
+import { ModelModeType } from '@/types/app'
+import {
+ DEFAULT_AGENT_SETTING,
+ DEFAULT_CHAT_PROMPT_CONFIG,
+ DEFAULT_COMPLETION_PROMPT_CONFIG,
+} from '@/config'
+import copy from 'copy-to-clipboard'
+import type ToolPickerType from '@/app/components/workflow/block-selector/tool-picker'
+import type SettingBuiltInToolType from './setting-built-in-tool'
+
+const formattingDispatcherMock = jest.fn()
+jest.mock('@/app/components/app/configuration/debug/hooks', () => ({
+ useFormattingChangedDispatcher: () => formattingDispatcherMock,
+}))
+
+let pluginInstallHandler: ((names: string[]) => void) | null = null
+const subscribeMock = jest.fn((event: string, handler: any) => {
+ if (event === 'plugin:install:success')
+ pluginInstallHandler = handler
+})
+jest.mock('@/context/mitt-context', () => ({
+ useMittContextSelector: (selector: any) => selector({
+ useSubscribe: subscribeMock,
+ }),
+}))
+
+let builtInTools: ToolWithProvider[] = []
+let customTools: ToolWithProvider[] = []
+let workflowTools: ToolWithProvider[] = []
+let mcpTools: ToolWithProvider[] = []
+jest.mock('@/service/use-tools', () => ({
+ useAllBuiltInTools: () => ({ data: builtInTools }),
+ useAllCustomTools: () => ({ data: customTools }),
+ useAllWorkflowTools: () => ({ data: workflowTools }),
+ useAllMCPTools: () => ({ data: mcpTools }),
+}))
+
+type ToolPickerProps = React.ComponentProps
+let singleToolSelection: ToolDefaultValue | null = null
+let multipleToolSelection: ToolDefaultValue[] = []
+const ToolPickerMock = (props: ToolPickerProps) => (
+
+
{props.trigger}
+
singleToolSelection && props.onSelect(singleToolSelection)}
+ >
+ pick-single
+
+
props.onSelectMultiple(multipleToolSelection)}
+ >
+ pick-multiple
+
+
+)
+jest.mock('@/app/components/workflow/block-selector/tool-picker', () => ({
+ __esModule: true,
+ default: (props: ToolPickerProps) => ,
+}))
+
+type SettingBuiltInToolProps = React.ComponentProps
+let latestSettingPanelProps: SettingBuiltInToolProps | null = null
+let settingPanelSavePayload: Record = {}
+let settingPanelCredentialId = 'credential-from-panel'
+const SettingBuiltInToolMock = (props: SettingBuiltInToolProps) => {
+ latestSettingPanelProps = props
+ return (
+
+ {props.toolName}
+ props.onSave?.(settingPanelSavePayload)}>save-from-panel
+ props.onAuthorizationItemClick?.(settingPanelCredentialId)}>auth-from-panel
+ close-panel
+
+ )
+}
+jest.mock('./setting-built-in-tool', () => ({
+ __esModule: true,
+ default: (props: SettingBuiltInToolProps) => ,
+}))
+
+jest.mock('copy-to-clipboard')
+
+const copyMock = copy as jest.Mock
+
+const createToolParameter = (overrides?: Partial): ToolParameter => ({
+ name: 'api_key',
+ label: {
+ en_US: 'API Key',
+ zh_Hans: 'API Key',
+ },
+ human_description: {
+ en_US: 'desc',
+ zh_Hans: 'desc',
+ },
+ type: 'string',
+ form: 'config',
+ llm_description: '',
+ required: true,
+ multiple: false,
+ default: 'default',
+ ...overrides,
+})
+
+const createToolDefinition = (overrides?: Partial): Tool => ({
+ name: 'search',
+ author: 'tester',
+ label: {
+ en_US: 'Search',
+ zh_Hans: 'Search',
+ },
+ description: {
+ en_US: 'desc',
+ zh_Hans: 'desc',
+ },
+ parameters: [createToolParameter()],
+ labels: [],
+ output_schema: {},
+ ...overrides,
+})
+
+const createCollection = (overrides?: Partial): ToolWithProvider => ({
+ id: overrides?.id || 'provider-1',
+ name: overrides?.name || 'vendor/provider-1',
+ author: 'tester',
+ description: {
+ en_US: 'desc',
+ zh_Hans: 'desc',
+ },
+ icon: 'https://example.com/icon.png',
+ label: {
+ en_US: 'Provider Label',
+ zh_Hans: 'Provider Label',
+ },
+ type: overrides?.type || CollectionType.builtIn,
+ team_credentials: {},
+ is_team_authorization: true,
+ allow_delete: true,
+ labels: [],
+ tools: overrides?.tools || [createToolDefinition()],
+ meta: {
+ version: '1.0.0',
+ },
+ ...overrides,
+})
+
+const createAgentTool = (overrides?: Partial): AgentTool => ({
+ provider_id: overrides?.provider_id || 'provider-1',
+ provider_type: overrides?.provider_type || CollectionType.builtIn,
+ provider_name: overrides?.provider_name || 'vendor/provider-1',
+ tool_name: overrides?.tool_name || 'search',
+ tool_label: overrides?.tool_label || 'Search Tool',
+ tool_parameters: overrides?.tool_parameters || { api_key: 'key' },
+ enabled: overrides?.enabled ?? true,
+ ...overrides,
+})
+
+const createModelConfig = (tools: AgentTool[]): ModelConfig => ({
+ provider: 'OPENAI',
+ model_id: 'gpt-3.5-turbo',
+ mode: ModelModeType.chat,
+ configs: {
+ prompt_template: '',
+ prompt_variables: [],
+ },
+ chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
+ completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
+ opening_statement: '',
+ more_like_this: null,
+ suggested_questions: [],
+ suggested_questions_after_answer: null,
+ speech_to_text: null,
+ text_to_speech: null,
+ file_upload: null,
+ retriever_resource: null,
+ sensitive_word_avoidance: null,
+ annotation_reply: null,
+ external_data_tools: [],
+ system_parameters: {
+ audio_file_size_limit: 0,
+ file_size_limit: 0,
+ image_file_size_limit: 0,
+ video_file_size_limit: 0,
+ workflow_file_upload_limit: 0,
+ },
+ dataSets: [],
+ agentConfig: {
+ ...DEFAULT_AGENT_SETTING,
+ tools,
+ },
+})
+
+const renderAgentTools = (initialTools?: AgentTool[]) => {
+ const tools = initialTools ?? [createAgentTool()]
+ const modelConfigRef = { current: createModelConfig(tools) }
+ const Wrapper = ({ children }: PropsWithChildren) => {
+ const [modelConfig, setModelConfig] = useState(modelConfigRef.current)
+ useEffect(() => {
+ modelConfigRef.current = modelConfig
+ }, [modelConfig])
+ const value = useMemo(() => ({
+ modelConfig,
+ setModelConfig,
+ }), [modelConfig])
+ return (
+
+ {children}
+
+ )
+ }
+ const renderResult = render(
+
+
+ ,
+ )
+ return {
+ ...renderResult,
+ getModelConfig: () => modelConfigRef.current,
+ }
+}
+
+const hoverInfoIcon = async (rowIndex = 0) => {
+ const rows = document.querySelectorAll('.group')
+ const infoTrigger = rows.item(rowIndex)?.querySelector('[data-testid="tool-info-tooltip"]')
+ if (!infoTrigger)
+ throw new Error('Info trigger not found')
+ await userEvent.hover(infoTrigger as HTMLElement)
+}
+
+describe('AgentTools', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ builtInTools = [
+ createCollection(),
+ createCollection({
+ id: 'provider-2',
+ name: 'vendor/provider-2',
+ tools: [createToolDefinition({
+ name: 'translate',
+ label: {
+ en_US: 'Translate',
+ zh_Hans: 'Translate',
+ },
+ })],
+ }),
+ createCollection({
+ id: 'provider-3',
+ name: 'vendor/provider-3',
+ tools: [createToolDefinition({
+ name: 'summarize',
+ label: {
+ en_US: 'Summary',
+ zh_Hans: 'Summary',
+ },
+ })],
+ }),
+ ]
+ customTools = []
+ workflowTools = []
+ mcpTools = []
+ singleToolSelection = {
+ provider_id: 'provider-3',
+ provider_type: CollectionType.builtIn,
+ provider_name: 'vendor/provider-3',
+ tool_name: 'summarize',
+ tool_label: 'Summary Tool',
+ tool_description: 'desc',
+ title: 'Summary Tool',
+ is_team_authorization: true,
+ params: { api_key: 'picker-value' },
+ paramSchemas: [],
+ output_schema: {},
+ }
+ multipleToolSelection = [
+ {
+ provider_id: 'provider-2',
+ provider_type: CollectionType.builtIn,
+ provider_name: 'vendor/provider-2',
+ tool_name: 'translate',
+ tool_label: 'Translate Tool',
+ tool_description: 'desc',
+ title: 'Translate Tool',
+ is_team_authorization: true,
+ params: { api_key: 'multi-a' },
+ paramSchemas: [],
+ output_schema: {},
+ },
+ {
+ provider_id: 'provider-3',
+ provider_type: CollectionType.builtIn,
+ provider_name: 'vendor/provider-3',
+ tool_name: 'summarize',
+ tool_label: 'Summary Tool',
+ tool_description: 'desc',
+ title: 'Summary Tool',
+ is_team_authorization: true,
+ params: { api_key: 'multi-b' },
+ paramSchemas: [],
+ output_schema: {},
+ },
+ ]
+ latestSettingPanelProps = null
+ settingPanelSavePayload = {}
+ settingPanelCredentialId = 'credential-from-panel'
+ pluginInstallHandler = null
+ })
+
+ test('should show enabled count and provider information', () => {
+ renderAgentTools([
+ createAgentTool(),
+ createAgentTool({
+ provider_id: 'provider-2',
+ provider_name: 'vendor/provider-2',
+ tool_name: 'translate',
+ tool_label: 'Translate Tool',
+ enabled: false,
+ }),
+ ])
+
+ const enabledText = screen.getByText(content => content.includes('appDebug.agent.tools.enabled'))
+ expect(enabledText).toHaveTextContent('1/2')
+ expect(screen.getByText('provider-1')).toBeInTheDocument()
+ expect(screen.getByText('Translate Tool')).toBeInTheDocument()
+ })
+
+ test('should copy tool name from tooltip action', async () => {
+ renderAgentTools()
+
+ await hoverInfoIcon()
+ const copyButton = await screen.findByText('tools.copyToolName')
+ await userEvent.click(copyButton)
+ expect(copyMock).toHaveBeenCalledWith('search')
+ })
+
+ test('should toggle tool enabled state via switch', async () => {
+ const { getModelConfig } = renderAgentTools()
+
+ const switchButton = screen.getByRole('switch')
+ await userEvent.click(switchButton)
+
+ await waitFor(() => {
+ const tools = getModelConfig().agentConfig.tools as Array<{ tool_name?: string; enabled?: boolean }>
+ const toggledTool = tools.find(tool => tool.tool_name === 'search')
+ expect(toggledTool?.enabled).toBe(false)
+ })
+ expect(formattingDispatcherMock).toHaveBeenCalled()
+ })
+
+ test('should remove tool when delete action is clicked', async () => {
+ const { getModelConfig } = renderAgentTools()
+ const deleteButton = screen.getByTestId('delete-removed-tool')
+ if (!deleteButton)
+ throw new Error('Delete button not found')
+ await userEvent.click(deleteButton)
+ await waitFor(() => {
+ expect(getModelConfig().agentConfig.tools).toHaveLength(0)
+ })
+ expect(formattingDispatcherMock).toHaveBeenCalled()
+ })
+
+ test('should add a tool when ToolPicker selects one', async () => {
+ const { getModelConfig } = renderAgentTools([])
+ const addSingleButton = screen.getByRole('button', { name: 'pick-single' })
+ await userEvent.click(addSingleButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Summary Tool')).toBeInTheDocument()
+ })
+ expect(getModelConfig().agentConfig.tools).toHaveLength(1)
+ })
+
+ test('should append multiple selected tools at once', async () => {
+ const { getModelConfig } = renderAgentTools([])
+ await userEvent.click(screen.getByRole('button', { name: 'pick-multiple' }))
+
+ await waitFor(() => {
+ expect(screen.getByText('Translate Tool')).toBeInTheDocument()
+ expect(screen.getAllByText('Summary Tool')).toHaveLength(1)
+ })
+ expect(getModelConfig().agentConfig.tools).toHaveLength(2)
+ })
+
+ test('should open settings panel for not authorized tool', async () => {
+ renderAgentTools([
+ createAgentTool({
+ notAuthor: true,
+ }),
+ ])
+
+ const notAuthorizedButton = screen.getByRole('button', { name: /tools.notAuthorized/ })
+ await userEvent.click(notAuthorizedButton)
+ expect(screen.getByTestId('setting-built-in-tool')).toBeInTheDocument()
+ expect(latestSettingPanelProps?.toolName).toBe('search')
+ })
+
+ test('should persist tool parameters when SettingBuiltInTool saves values', async () => {
+ const { getModelConfig } = renderAgentTools([
+ createAgentTool({
+ notAuthor: true,
+ }),
+ ])
+ await userEvent.click(screen.getByRole('button', { name: /tools.notAuthorized/ }))
+ settingPanelSavePayload = { api_key: 'updated' }
+ await userEvent.click(screen.getByRole('button', { name: 'save-from-panel' }))
+
+ await waitFor(() => {
+ expect((getModelConfig().agentConfig.tools[0] as { tool_parameters: Record }).tool_parameters).toEqual({ api_key: 'updated' })
+ })
+ })
+
+ test('should update credential id when authorization selection changes', async () => {
+ const { getModelConfig } = renderAgentTools([
+ createAgentTool({
+ notAuthor: true,
+ }),
+ ])
+ await userEvent.click(screen.getByRole('button', { name: /tools.notAuthorized/ }))
+ settingPanelCredentialId = 'credential-123'
+ await userEvent.click(screen.getByRole('button', { name: 'auth-from-panel' }))
+
+ await waitFor(() => {
+ expect((getModelConfig().agentConfig.tools[0] as { credential_id: string }).credential_id).toBe('credential-123')
+ })
+ expect(formattingDispatcherMock).toHaveBeenCalled()
+ })
+
+ test('should reinstate deleted tools after plugin install success event', async () => {
+ const { getModelConfig } = renderAgentTools([
+ createAgentTool({
+ provider_id: 'provider-1',
+ provider_name: 'vendor/provider-1',
+ tool_name: 'search',
+ tool_label: 'Search Tool',
+ isDeleted: true,
+ }),
+ ])
+ if (!pluginInstallHandler)
+ throw new Error('Plugin handler not registered')
+
+ await act(async () => {
+ pluginInstallHandler?.(['provider-1'])
+ })
+
+ await waitFor(() => {
+ expect((getModelConfig().agentConfig.tools[0] as { isDeleted: boolean }).isDeleted).toBe(false)
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx
index 5716bfd92d..8dfa2f194b 100644
--- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx
+++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx
@@ -25,7 +25,7 @@ import { MAX_TOOLS_NUM } from '@/config'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import { canFindTool } from '@/utils'
@@ -217,7 +217,7 @@ const AgentTools: FC = () => {
}
>
-
@@ -277,6 +277,7 @@ const AgentTools: FC = () => {
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
+ data-testid='delete-removed-tool'
>
diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.spec.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.spec.tsx
new file mode 100644
index 0000000000..8cd95472dc
--- /dev/null
+++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.spec.tsx
@@ -0,0 +1,248 @@
+import React from 'react'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import SettingBuiltInTool from './setting-built-in-tool'
+import I18n from '@/context/i18n'
+import { CollectionType, type Tool, type ToolParameter } from '@/app/components/tools/types'
+
+const fetchModelToolList = jest.fn()
+const fetchBuiltInToolList = jest.fn()
+const fetchCustomToolList = jest.fn()
+const fetchWorkflowToolList = jest.fn()
+jest.mock('@/service/tools', () => ({
+ fetchModelToolList: (collectionName: string) => fetchModelToolList(collectionName),
+ fetchBuiltInToolList: (collectionName: string) => fetchBuiltInToolList(collectionName),
+ fetchCustomToolList: (collectionName: string) => fetchCustomToolList(collectionName),
+ fetchWorkflowToolList: (appId: string) => fetchWorkflowToolList(appId),
+}))
+
+type MockFormProps = {
+ value: Record
+ onChange: (val: Record) => void
+}
+let nextFormValue: Record = {}
+const FormMock = ({ value, onChange }: MockFormProps) => {
+ return (
+
+
{JSON.stringify(value)}
+
onChange({ ...value, ...nextFormValue })}
+ >
+ update-form
+
+
+ )
+}
+jest.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
+ __esModule: true,
+ default: (props: MockFormProps) => ,
+}))
+
+let pluginAuthClickValue = 'credential-from-plugin'
+jest.mock('@/app/components/plugins/plugin-auth', () => ({
+ AuthCategory: { tool: 'tool' },
+ PluginAuthInAgent: (props: { onAuthorizationItemClick?: (id: string) => void }) => (
+
+ props.onAuthorizationItemClick?.(pluginAuthClickValue)}>
+ choose-plugin-credential
+
+
+ ),
+}))
+
+jest.mock('@/app/components/plugins/readme-panel/entrance', () => ({
+ ReadmeEntrance: ({ className }: { className?: string }) => readme
,
+}))
+
+const createParameter = (overrides?: Partial): ToolParameter => ({
+ name: 'settingParam',
+ label: {
+ en_US: 'Setting Param',
+ zh_Hans: 'Setting Param',
+ },
+ human_description: {
+ en_US: 'desc',
+ zh_Hans: 'desc',
+ },
+ type: 'string',
+ form: 'config',
+ llm_description: '',
+ required: true,
+ multiple: false,
+ default: '',
+ ...overrides,
+})
+
+const createTool = (overrides?: Partial): Tool => ({
+ name: 'search',
+ author: 'tester',
+ label: {
+ en_US: 'Search Tool',
+ zh_Hans: 'Search Tool',
+ },
+ description: {
+ en_US: 'tool description',
+ zh_Hans: 'tool description',
+ },
+ parameters: [
+ createParameter({
+ name: 'infoParam',
+ label: {
+ en_US: 'Info Param',
+ zh_Hans: 'Info Param',
+ },
+ form: 'llm',
+ required: false,
+ }),
+ createParameter(),
+ ],
+ labels: [],
+ output_schema: {},
+ ...overrides,
+})
+
+const baseCollection = {
+ id: 'provider-1',
+ name: 'vendor/provider-1',
+ author: 'tester',
+ description: {
+ en_US: 'desc',
+ zh_Hans: 'desc',
+ },
+ icon: 'https://example.com/icon.png',
+ label: {
+ en_US: 'Provider Label',
+ zh_Hans: 'Provider Label',
+ },
+ type: CollectionType.builtIn,
+ team_credentials: {},
+ is_team_authorization: true,
+ allow_delete: true,
+ labels: [],
+ tools: [createTool()],
+}
+
+const renderComponent = (props?: Partial>) => {
+ const onHide = jest.fn()
+ const onSave = jest.fn()
+ const onAuthorizationItemClick = jest.fn()
+ const utils = render(
+
+
+ ,
+ )
+ return {
+ ...utils,
+ onHide,
+ onSave,
+ onAuthorizationItemClick,
+ }
+}
+
+describe('SettingBuiltInTool', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ nextFormValue = {}
+ pluginAuthClickValue = 'credential-from-plugin'
+ })
+
+ test('should fetch tool list when collection has no tools', async () => {
+ fetchModelToolList.mockResolvedValueOnce([createTool()])
+ renderComponent({
+ collection: {
+ ...baseCollection,
+ tools: [],
+ },
+ })
+
+ await waitFor(() => {
+ expect(fetchModelToolList).toHaveBeenCalledTimes(1)
+ expect(fetchModelToolList).toHaveBeenCalledWith('vendor/provider-1')
+ })
+ expect(await screen.findByText('Search Tool')).toBeInTheDocument()
+ })
+
+ test('should switch between info and setting tabs', async () => {
+ renderComponent()
+ await waitFor(() => {
+ expect(screen.getByTestId('mock-form')).toBeInTheDocument()
+ })
+
+ await userEvent.click(screen.getByText('tools.setBuiltInTools.parameters'))
+ expect(screen.getByText('Info Param')).toBeInTheDocument()
+ await userEvent.click(screen.getByText('tools.setBuiltInTools.setting'))
+ expect(screen.getByTestId('mock-form')).toBeInTheDocument()
+ })
+
+ test('should call onSave with updated values when save button clicked', async () => {
+ const { onSave } = renderComponent()
+ await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument())
+ nextFormValue = { settingParam: 'updated' }
+ await userEvent.click(screen.getByRole('button', { name: 'update-form' }))
+ await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ settingParam: 'updated' }))
+ })
+
+ test('should keep save disabled until required field provided', async () => {
+ renderComponent({
+ setting: {},
+ })
+ await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument())
+ const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
+ expect(saveButton).toBeDisabled()
+ nextFormValue = { settingParam: 'filled' }
+ await userEvent.click(screen.getByRole('button', { name: 'update-form' }))
+ expect(saveButton).not.toBeDisabled()
+ })
+
+ test('should call onHide when cancel button is pressed', async () => {
+ const { onHide } = renderComponent()
+ await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument())
+ await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+ expect(onHide).toHaveBeenCalled()
+ })
+
+ test('should trigger authorization callback from plugin auth section', async () => {
+ const { onAuthorizationItemClick } = renderComponent()
+ await userEvent.click(screen.getByRole('button', { name: 'choose-plugin-credential' }))
+ expect(onAuthorizationItemClick).toHaveBeenCalledWith('credential-from-plugin')
+ })
+
+ test('should call onHide when back button is clicked', async () => {
+ const { onHide } = renderComponent({
+ showBackButton: true,
+ })
+ await userEvent.click(screen.getByText('plugin.detailPanel.operation.back'))
+ expect(onHide).toHaveBeenCalled()
+ })
+
+ test('should load workflow tools when workflow collection is provided', async () => {
+ fetchWorkflowToolList.mockResolvedValueOnce([createTool({
+ name: 'workflow-tool',
+ })])
+ renderComponent({
+ collection: {
+ ...baseCollection,
+ type: CollectionType.workflow,
+ tools: [],
+ id: 'workflow-1',
+ } as any,
+ isBuiltIn: false,
+ isModel: false,
+ })
+
+ await waitFor(() => {
+ expect(fetchWorkflowToolList).toHaveBeenCalledWith('workflow-1')
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx
index c5947495db..0627666b4c 100644
--- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx
+++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx
@@ -22,7 +22,7 @@ import { CollectionType } from '@/app/components/tools/types'
import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWorkflowToolList } from '@/service/tools'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import {
AuthCategory,
diff --git a/web/app/components/app/configuration/config/agent/prompt-editor.tsx b/web/app/components/app/configuration/config/agent/prompt-editor.tsx
index 71a9304d0c..78d7eef029 100644
--- a/web/app/components/app/configuration/config/agent/prompt-editor.tsx
+++ b/web/app/components/app/configuration/config/agent/prompt-editor.tsx
@@ -4,7 +4,7 @@ import React from 'react'
import copy from 'copy-to-clipboard'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import {
Copy,
CopyCheck,
diff --git a/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx b/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx
new file mode 100644
index 0000000000..cda24ea045
--- /dev/null
+++ b/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx
@@ -0,0 +1,862 @@
+import React from 'react'
+import { act, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import AssistantTypePicker from './index'
+import type { AgentConfig } from '@/models/debug'
+import { AgentStrategy } from '@/types/app'
+
+// Test utilities
+const defaultAgentConfig: AgentConfig = {
+ enabled: true,
+ max_iteration: 3,
+ strategy: AgentStrategy.functionCall,
+ tools: [],
+}
+
+const defaultProps = {
+ value: 'chat',
+ disabled: false,
+ onChange: jest.fn(),
+ isFunctionCall: true,
+ isChatModel: true,
+ agentConfig: defaultAgentConfig,
+ onAgentSettingChange: jest.fn(),
+}
+
+const renderComponent = (props: Partial> = {}) => {
+ const mergedProps = { ...defaultProps, ...props }
+ return render( )
+}
+
+// Helper to get option element by description (which is unique per option)
+const getOptionByDescription = (descriptionRegex: RegExp) => {
+ const description = screen.getByText(descriptionRegex)
+ return description.parentElement as HTMLElement
+}
+
+describe('AssistantTypePicker', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // Rendering tests (REQUIRED)
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ renderComponent()
+
+ // Assert
+ expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
+ })
+
+ it('should render chat assistant by default when value is "chat"', () => {
+ // Arrange & Act
+ renderComponent({ value: 'chat' })
+
+ // Assert
+ expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
+ })
+
+ it('should render agent assistant when value is "agent"', () => {
+ // Arrange & Act
+ renderComponent({ value: 'agent' })
+
+ // Assert
+ expect(screen.getByText(/agentAssistant.name/i)).toBeInTheDocument()
+ })
+ })
+
+ // Props tests (REQUIRED)
+ describe('Props', () => {
+ it('should use provided value prop', () => {
+ // Arrange & Act
+ renderComponent({ value: 'agent' })
+
+ // Assert
+ expect(screen.getByText(/agentAssistant.name/i)).toBeInTheDocument()
+ })
+
+ it('should handle agentConfig prop', () => {
+ // Arrange
+ const customAgentConfig: AgentConfig = {
+ enabled: true,
+ max_iteration: 10,
+ strategy: AgentStrategy.react,
+ tools: [],
+ }
+
+ // Act
+ expect(() => {
+ renderComponent({ agentConfig: customAgentConfig })
+ }).not.toThrow()
+
+ // Assert
+ expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
+ })
+
+ it('should handle undefined agentConfig prop', () => {
+ // Arrange & Act
+ expect(() => {
+ renderComponent({ agentConfig: undefined })
+ }).not.toThrow()
+
+ // Assert
+ expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should open dropdown when clicking trigger', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent()
+
+ // Act
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Assert - Both options should be visible
+ await waitFor(() => {
+ const chatOptions = screen.getAllByText(/chatAssistant.name/i)
+ const agentOptions = screen.getAllByText(/agentAssistant.name/i)
+ expect(chatOptions.length).toBeGreaterThan(1)
+ expect(agentOptions.length).toBeGreaterThan(0)
+ })
+ })
+
+ it('should call onChange when selecting chat assistant', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+ renderComponent({ value: 'agent', onChange })
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ // Wait for dropdown to open and find chat option
+ await waitFor(() => {
+ expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
+ })
+
+ // Find and click the chat option by its unique description
+ const chatOption = getOptionByDescription(/chatAssistant.description/i)
+ await user.click(chatOption)
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith('chat')
+ })
+
+ it('should call onChange when selecting agent assistant', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+ renderComponent({ value: 'chat', onChange })
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Wait for dropdown to open and click agent option
+ await waitFor(() => {
+ expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
+ })
+
+ const agentOption = getOptionByDescription(/agentAssistant.description/i)
+ await user.click(agentOption)
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith('agent')
+ })
+
+ it('should close dropdown when selecting chat assistant', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'agent' })
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ // Wait for dropdown and select chat
+ await waitFor(() => {
+ expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
+ })
+
+ const chatOption = getOptionByDescription(/chatAssistant.description/i)
+ await user.click(chatOption)
+
+ // Assert - Dropdown should close (descriptions should not be visible)
+ await waitFor(() => {
+ expect(screen.queryByText(/chatAssistant.description/i)).not.toBeInTheDocument()
+ })
+ })
+
+ it('should not close dropdown when selecting agent assistant', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'chat' })
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Wait for dropdown and select agent
+ await waitFor(() => {
+ const agentOptions = screen.getAllByText(/agentAssistant.name/i)
+ expect(agentOptions.length).toBeGreaterThan(0)
+ })
+
+ const agentOptions = screen.getAllByText(/agentAssistant.name/i)
+ await user.click(agentOptions[0])
+
+ // Assert - Dropdown should remain open (agent settings should be visible)
+ await waitFor(() => {
+ expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should not call onChange when clicking same value', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+ renderComponent({ value: 'chat', onChange })
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Wait for dropdown and click same option
+ await waitFor(() => {
+ const chatOptions = screen.getAllByText(/chatAssistant.name/i)
+ expect(chatOptions.length).toBeGreaterThan(1)
+ })
+
+ const chatOptions = screen.getAllByText(/chatAssistant.name/i)
+ await user.click(chatOptions[1])
+
+ // Assert
+ expect(onChange).not.toHaveBeenCalled()
+ })
+ })
+
+ // Disabled state
+ describe('Disabled State', () => {
+ it('should not respond to clicks when disabled', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+ renderComponent({ disabled: true, onChange })
+
+ // Act - Open dropdown (dropdown can still open when disabled)
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Wait for dropdown to open
+ await waitFor(() => {
+ expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
+ })
+
+ // Act - Try to click an option
+ const agentOption = getOptionByDescription(/agentAssistant.description/i)
+ await user.click(agentOption)
+
+ // Assert - onChange should not be called (options are disabled)
+ expect(onChange).not.toHaveBeenCalled()
+ })
+
+ it('should not show agent config UI when disabled', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'agent', disabled: true })
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ // Assert - Agent settings option should not be visible
+ await waitFor(() => {
+ expect(screen.queryByText(/agent.setting.name/i)).not.toBeInTheDocument()
+ })
+ })
+
+ it('should show agent config UI when not disabled', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'agent', disabled: false })
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ // Assert - Agent settings option should be visible
+ await waitFor(() => {
+ expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
+ })
+ })
+ })
+
+ // Agent Settings Modal
+ describe('Agent Settings Modal', () => {
+ it('should open agent settings modal when clicking agent config UI', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'agent', disabled: false })
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ // Click agent settings
+ await waitFor(() => {
+ expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
+ })
+
+ const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
+ await user.click(agentSettingsTrigger)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should not open agent settings when value is not agent', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'chat', disabled: false })
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Wait for dropdown to open
+ await waitFor(() => {
+ expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
+ })
+
+ // Assert - Agent settings modal should not appear (value is 'chat')
+ expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument()
+ })
+
+ it('should call onAgentSettingChange when saving agent settings', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const onAgentSettingChange = jest.fn()
+ renderComponent({ value: 'agent', disabled: false, onAgentSettingChange })
+
+ // Act - Open dropdown and agent settings
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
+ })
+
+ const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
+ await user.click(agentSettingsTrigger)
+
+ // Wait for modal and click save
+ await waitFor(() => {
+ expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
+ })
+
+ const saveButton = screen.getByText(/common.operation.save/i)
+ await user.click(saveButton)
+
+ // Assert
+ expect(onAgentSettingChange).toHaveBeenCalledWith(defaultAgentConfig)
+ })
+
+ it('should close modal when saving agent settings', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'agent', disabled: false })
+
+ // Act - Open dropdown, agent settings, and save
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
+ })
+
+ const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
+ await user.click(agentSettingsTrigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/appDebug.agent.setting.name/i)).toBeInTheDocument()
+ })
+
+ const saveButton = screen.getByText(/common.operation.save/i)
+ await user.click(saveButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument()
+ })
+ })
+
+ it('should close modal when canceling agent settings', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const onAgentSettingChange = jest.fn()
+ renderComponent({ value: 'agent', disabled: false, onAgentSettingChange })
+
+ // Act - Open dropdown, agent settings, and cancel
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
+ })
+
+ const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
+ await user.click(agentSettingsTrigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
+ })
+
+ const cancelButton = screen.getByText(/common.operation.cancel/i)
+ await user.click(cancelButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument()
+ })
+ expect(onAgentSettingChange).not.toHaveBeenCalled()
+ })
+
+ it('should close dropdown when opening agent settings', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'agent', disabled: false })
+
+ // Act - Open dropdown and agent settings
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
+ })
+
+ const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
+ await user.click(agentSettingsTrigger)
+
+ // Assert - Modal should be open and dropdown should close
+ await waitFor(() => {
+ expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
+ })
+
+ // The dropdown should be closed (agent settings description should not be visible)
+ await waitFor(() => {
+ const descriptions = screen.queryAllByText(/agent.setting.description/i)
+ expect(descriptions.length).toBe(0)
+ })
+ })
+ })
+
+ // Edge Cases (REQUIRED)
+ describe('Edge Cases', () => {
+ it('should handle rapid toggle clicks', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent()
+
+ // Act
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+ await user.click(trigger)
+ await user.click(trigger)
+
+ // Assert - Should not crash
+ expect(trigger).toBeInTheDocument()
+ })
+
+ it('should handle multiple rapid selection changes', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+ renderComponent({ value: 'chat', onChange })
+
+ // Act - Open and select agent
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
+ })
+
+ // Click agent option - this stays open because value is 'agent'
+ const agentOption = getOptionByDescription(/agentAssistant.description/i)
+ await user.click(agentOption)
+
+ // Assert - onChange should have been called once to switch to agent
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+ expect(onChange).toHaveBeenCalledWith('agent')
+ })
+
+ it('should handle missing callback functions gracefully', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act & Assert - Should not crash
+ expect(() => {
+ renderComponent({
+ onChange: undefined!,
+ onAgentSettingChange: undefined!,
+ })
+ }).not.toThrow()
+
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+ })
+
+ it('should handle empty agentConfig', async () => {
+ // Arrange & Act
+ expect(() => {
+ renderComponent({ agentConfig: {} as AgentConfig })
+ }).not.toThrow()
+
+ // Assert
+ expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
+ })
+
+ describe('should render with different prop combinations', () => {
+ const combinations = [
+ { value: 'chat' as const, disabled: true, isFunctionCall: true, isChatModel: true },
+ { value: 'agent' as const, disabled: false, isFunctionCall: false, isChatModel: false },
+ { value: 'agent' as const, disabled: true, isFunctionCall: true, isChatModel: false },
+ { value: 'chat' as const, disabled: false, isFunctionCall: false, isChatModel: true },
+ ]
+
+ it.each(combinations)(
+ 'value=$value, disabled=$disabled, isFunctionCall=$isFunctionCall, isChatModel=$isChatModel',
+ (combo) => {
+ // Arrange & Act
+ renderComponent(combo)
+
+ // Assert
+ const expectedText = combo.value === 'agent' ? 'agentAssistant.name' : 'chatAssistant.name'
+ expect(screen.getByText(new RegExp(expectedText, 'i'))).toBeInTheDocument()
+ },
+ )
+ })
+ })
+
+ // Accessibility
+ describe('Accessibility', () => {
+ it('should render interactive dropdown items', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent()
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Assert - Both options should be visible and clickable
+ await waitFor(() => {
+ expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
+ expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
+ })
+
+ // Verify we can interact with option elements using helper function
+ const chatOption = getOptionByDescription(/chatAssistant.description/i)
+ const agentOption = getOptionByDescription(/agentAssistant.description/i)
+ expect(chatOption).toBeInTheDocument()
+ expect(agentOption).toBeInTheDocument()
+ })
+ })
+
+ // SelectItem Component
+ describe('SelectItem Component', () => {
+ it('should show checked state for selected option', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'chat' })
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Assert - Both options should be visible with radio components
+ await waitFor(() => {
+ expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
+ expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
+ })
+
+ // The SelectItem components render with different visual states
+ // based on isChecked prop - we verify both options are rendered
+ const chatOption = getOptionByDescription(/chatAssistant.description/i)
+ const agentOption = getOptionByDescription(/agentAssistant.description/i)
+ expect(chatOption).toBeInTheDocument()
+ expect(agentOption).toBeInTheDocument()
+ })
+
+ it('should render description text', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent()
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Assert - Descriptions should be visible
+ await waitFor(() => {
+ expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
+ expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should show Radio component for each option', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent()
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Assert - Radio components should be present (both options visible)
+ await waitFor(() => {
+ expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
+ expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
+ })
+ })
+ })
+
+ // Agent Setting Integration
+ describe('AgentSetting Integration', () => {
+ it('should show function call mode when isFunctionCall is true', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'agent', isFunctionCall: true, isChatModel: false })
+
+ // Act - Open dropdown and settings modal
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
+ })
+
+ const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
+ await user.click(agentSettingsTrigger)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
+ })
+ expect(screen.getByText(/appDebug.agent.agentModeType.functionCall/i)).toBeInTheDocument()
+ })
+
+ it('should show built-in prompt when isFunctionCall is false', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'agent', isFunctionCall: false, isChatModel: true })
+
+ // Act - Open dropdown and settings modal
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
+ })
+
+ const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
+ await user.click(agentSettingsTrigger)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
+ })
+ expect(screen.getByText(/tools.builtInPromptTitle/i)).toBeInTheDocument()
+ })
+
+ it('should initialize max iteration from agentConfig payload', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const customConfig: AgentConfig = {
+ enabled: true,
+ max_iteration: 10,
+ strategy: AgentStrategy.react,
+ tools: [],
+ }
+
+ renderComponent({ value: 'agent', agentConfig: customConfig })
+
+ // Act - Open dropdown and settings modal
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
+ })
+
+ const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
+ await user.click(agentSettingsTrigger)
+
+ // Assert
+ await screen.findByText(/common.operation.save/i)
+ const maxIterationInput = await screen.findByRole('spinbutton')
+ expect(maxIterationInput).toHaveValue(10)
+ })
+ })
+
+ // Keyboard Navigation
+ describe('Keyboard Navigation', () => {
+ it('should support closing dropdown with Escape key', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent()
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
+ })
+
+ // Press Escape
+ await user.keyboard('{Escape}')
+
+ // Assert - Dropdown should close
+ await waitFor(() => {
+ expect(screen.queryByText(/chatAssistant.description/i)).not.toBeInTheDocument()
+ })
+ })
+
+ it('should allow keyboard focus on trigger element', () => {
+ // Arrange
+ renderComponent()
+
+ // Act - Get trigger and verify it can receive focus
+ const trigger = screen.getByText(/chatAssistant.name/i)
+
+ // Assert - Element should be focusable
+ expect(trigger).toBeInTheDocument()
+ expect(trigger.parentElement).toBeInTheDocument()
+ })
+
+ it('should allow keyboard focus on dropdown options', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent()
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
+ })
+
+ // Get options
+ const chatOption = getOptionByDescription(/chatAssistant.description/i)
+ const agentOption = getOptionByDescription(/agentAssistant.description/i)
+
+ // Assert - Options should be focusable
+ expect(chatOption).toBeInTheDocument()
+ expect(agentOption).toBeInTheDocument()
+
+ // Verify options can receive focus
+ act(() => {
+ chatOption.focus()
+ })
+ expect(document.activeElement).toBe(chatOption)
+ })
+
+ it('should maintain keyboard accessibility for all interactive elements', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent({ value: 'agent' })
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/agentAssistant.name/i)
+ await user.click(trigger)
+
+ // Assert - Agent settings button should be focusable
+ await waitFor(() => {
+ expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
+ })
+
+ const agentSettings = screen.getByText(/agent.setting.name/i)
+ expect(agentSettings).toBeInTheDocument()
+ })
+ })
+
+ // ARIA Attributes
+ describe('ARIA Attributes', () => {
+ it('should have proper ARIA state for dropdown', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const { container } = renderComponent()
+
+ // Act - Check initial state
+ const portalContainer = container.querySelector('[data-state]')
+ expect(portalContainer).toHaveAttribute('data-state', 'closed')
+
+ // Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Assert - State should change to open
+ await waitFor(() => {
+ const openPortal = container.querySelector('[data-state="open"]')
+ expect(openPortal).toBeInTheDocument()
+ })
+ })
+
+ it('should have proper data-state attribute', () => {
+ // Arrange & Act
+ const { container } = renderComponent()
+
+ // Assert - Portal should have data-state for accessibility
+ const portalContainer = container.querySelector('[data-state]')
+ expect(portalContainer).toBeInTheDocument()
+ expect(portalContainer).toHaveAttribute('data-state')
+
+ // Should start in closed state
+ expect(portalContainer).toHaveAttribute('data-state', 'closed')
+ })
+
+ it('should maintain accessible structure for screen readers', () => {
+ // Arrange & Act
+ renderComponent({ value: 'chat' })
+
+ // Assert - Text content should be accessible
+ expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
+
+ // Icons should have proper structure
+ const { container } = renderComponent()
+ const icons = container.querySelectorAll('svg')
+ expect(icons.length).toBeGreaterThan(0)
+ })
+
+ it('should provide context through text labels', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderComponent()
+
+ // Act - Open dropdown
+ const trigger = screen.getByText(/chatAssistant.name/i)
+ await user.click(trigger)
+
+ // Assert - All options should have descriptive text
+ await waitFor(() => {
+ expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
+ expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
+ })
+
+ // Title text should be visible
+ expect(screen.getByText(/assistantType.name/i)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/config/assistant-type-picker/index.tsx b/web/app/components/app/configuration/config/assistant-type-picker/index.tsx
index 3597a6e292..50f16f957a 100644
--- a/web/app/components/app/configuration/config/assistant-type-picker/index.tsx
+++ b/web/app/components/app/configuration/config/assistant-type-picker/index.tsx
@@ -4,7 +4,7 @@ import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import AgentSetting from '../agent/agent-setting'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
diff --git a/web/app/components/app/configuration/config/automatic/idea-output.tsx b/web/app/components/app/configuration/config/automatic/idea-output.tsx
index df4f76c92b..895f74baa3 100644
--- a/web/app/components/app/configuration/config/automatic/idea-output.tsx
+++ b/web/app/components/app/configuration/config/automatic/idea-output.tsx
@@ -3,7 +3,7 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid
import { useBoolean } from 'ahooks'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Textarea from '@/app/components/base/textarea'
import { useTranslation } from 'react-i18next'
diff --git a/web/app/components/app/configuration/config/automatic/instruction-editor.tsx b/web/app/components/app/configuration/config/automatic/instruction-editor.tsx
index b14ee93313..409f335232 100644
--- a/web/app/components/app/configuration/config/automatic/instruction-editor.tsx
+++ b/web/app/components/app/configuration/config/automatic/instruction-editor.tsx
@@ -3,7 +3,7 @@ import type { FC } from 'react'
import React from 'react'
import PromptEditor from '@/app/components/base/prompt-editor'
import type { GeneratorType } from './types'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
diff --git a/web/app/components/app/configuration/config/automatic/prompt-toast.tsx b/web/app/components/app/configuration/config/automatic/prompt-toast.tsx
index 2826cc97c8..c9169f0ad7 100644
--- a/web/app/components/app/configuration/config/automatic/prompt-toast.tsx
+++ b/web/app/components/app/configuration/config/automatic/prompt-toast.tsx
@@ -1,7 +1,7 @@
import { RiArrowDownSLine, RiSparklingFill } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { Markdown } from '@/app/components/base/markdown'
import { useTranslation } from 'react-i18next'
import s from './style.module.css'
diff --git a/web/app/components/app/configuration/config/automatic/version-selector.tsx b/web/app/components/app/configuration/config/automatic/version-selector.tsx
index c3d3e1d91c..715c1f3c80 100644
--- a/web/app/components/app/configuration/config/automatic/version-selector.tsx
+++ b/web/app/components/app/configuration/config/automatic/version-selector.tsx
@@ -1,7 +1,7 @@
import React, { useCallback } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { useBoolean } from 'ahooks'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
diff --git a/web/app/components/app/configuration/config/config-audio.spec.tsx b/web/app/components/app/configuration/config/config-audio.spec.tsx
new file mode 100644
index 0000000000..94eeb87c99
--- /dev/null
+++ b/web/app/components/app/configuration/config/config-audio.spec.tsx
@@ -0,0 +1,123 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ConfigAudio from './config-audio'
+import type { FeatureStoreState } from '@/app/components/base/features/store'
+import { SupportUploadFileTypes } from '@/app/components/workflow/types'
+
+const mockUseContext = jest.fn()
+jest.mock('use-context-selector', () => {
+ const actual = jest.requireActual('use-context-selector')
+ return {
+ ...actual,
+ useContext: (context: unknown) => mockUseContext(context),
+ }
+})
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+const mockUseFeatures = jest.fn()
+const mockUseFeaturesStore = jest.fn()
+jest.mock('@/app/components/base/features/hooks', () => ({
+ useFeatures: (selector: (state: FeatureStoreState) => any) => mockUseFeatures(selector),
+ useFeaturesStore: () => mockUseFeaturesStore(),
+}))
+
+type SetupOptions = {
+ isVisible?: boolean
+ allowedTypes?: SupportUploadFileTypes[]
+}
+
+let mockFeatureStoreState: FeatureStoreState
+let mockSetFeatures: jest.Mock
+const mockStore = {
+ getState: jest.fn(() => mockFeatureStoreState),
+}
+
+const setupFeatureStore = (allowedTypes: SupportUploadFileTypes[] = []) => {
+ mockSetFeatures = jest.fn()
+ mockFeatureStoreState = {
+ features: {
+ file: {
+ allowed_file_types: allowedTypes,
+ enabled: allowedTypes.length > 0,
+ },
+ },
+ setFeatures: mockSetFeatures,
+ showFeaturesModal: false,
+ setShowFeaturesModal: jest.fn(),
+ }
+ mockStore.getState.mockImplementation(() => mockFeatureStoreState)
+ mockUseFeaturesStore.mockReturnValue(mockStore)
+ mockUseFeatures.mockImplementation(selector => selector(mockFeatureStoreState))
+}
+
+const renderConfigAudio = (options: SetupOptions = {}) => {
+ const {
+ isVisible = true,
+ allowedTypes = [],
+ } = options
+ setupFeatureStore(allowedTypes)
+ mockUseContext.mockReturnValue({
+ isShowAudioConfig: isVisible,
+ })
+ const user = userEvent.setup()
+ render( )
+ return {
+ user,
+ setFeatures: mockSetFeatures,
+ }
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+})
+
+describe('ConfigAudio', () => {
+ it('should not render when the audio configuration is hidden', () => {
+ renderConfigAudio({ isVisible: false })
+
+ expect(screen.queryByText('appDebug.feature.audioUpload.title')).not.toBeInTheDocument()
+ })
+
+ it('should display the audio toggle state based on feature store data', () => {
+ renderConfigAudio({ allowedTypes: [SupportUploadFileTypes.audio] })
+
+ expect(screen.getByText('appDebug.feature.audioUpload.title')).toBeInTheDocument()
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
+ })
+
+ it('should enable audio uploads when toggled on', async () => {
+ const { user, setFeatures } = renderConfigAudio()
+ const toggle = screen.getByRole('switch')
+
+ expect(toggle).toHaveAttribute('aria-checked', 'false')
+ await user.click(toggle)
+
+ expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({
+ file: expect.objectContaining({
+ allowed_file_types: [SupportUploadFileTypes.audio],
+ enabled: true,
+ }),
+ }))
+ })
+
+ it('should disable audio uploads and turn off file feature when last type is removed', async () => {
+ const { user, setFeatures } = renderConfigAudio({ allowedTypes: [SupportUploadFileTypes.audio] })
+ const toggle = screen.getByRole('switch')
+
+ expect(toggle).toHaveAttribute('aria-checked', 'true')
+ await user.click(toggle)
+
+ expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({
+ file: expect.objectContaining({
+ allowed_file_types: [],
+ enabled: false,
+ }),
+ }))
+ })
+})
diff --git a/web/app/components/app/configuration/config/config-document.spec.tsx b/web/app/components/app/configuration/config/config-document.spec.tsx
new file mode 100644
index 0000000000..aeb504fdbd
--- /dev/null
+++ b/web/app/components/app/configuration/config/config-document.spec.tsx
@@ -0,0 +1,119 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ConfigDocument from './config-document'
+import type { FeatureStoreState } from '@/app/components/base/features/store'
+import { SupportUploadFileTypes } from '@/app/components/workflow/types'
+
+const mockUseContext = jest.fn()
+jest.mock('use-context-selector', () => {
+ const actual = jest.requireActual('use-context-selector')
+ return {
+ ...actual,
+ useContext: (context: unknown) => mockUseContext(context),
+ }
+})
+
+const mockUseFeatures = jest.fn()
+const mockUseFeaturesStore = jest.fn()
+jest.mock('@/app/components/base/features/hooks', () => ({
+ useFeatures: (selector: (state: FeatureStoreState) => any) => mockUseFeatures(selector),
+ useFeaturesStore: () => mockUseFeaturesStore(),
+}))
+
+type SetupOptions = {
+ isVisible?: boolean
+ allowedTypes?: SupportUploadFileTypes[]
+}
+
+let mockFeatureStoreState: FeatureStoreState
+let mockSetFeatures: jest.Mock
+const mockStore = {
+ getState: jest.fn(() => mockFeatureStoreState),
+}
+
+const setupFeatureStore = (allowedTypes: SupportUploadFileTypes[] = []) => {
+ mockSetFeatures = jest.fn()
+ mockFeatureStoreState = {
+ features: {
+ file: {
+ allowed_file_types: allowedTypes,
+ enabled: allowedTypes.length > 0,
+ },
+ },
+ setFeatures: mockSetFeatures,
+ showFeaturesModal: false,
+ setShowFeaturesModal: jest.fn(),
+ }
+ mockStore.getState.mockImplementation(() => mockFeatureStoreState)
+ mockUseFeaturesStore.mockReturnValue(mockStore)
+ mockUseFeatures.mockImplementation(selector => selector(mockFeatureStoreState))
+}
+
+const renderConfigDocument = (options: SetupOptions = {}) => {
+ const {
+ isVisible = true,
+ allowedTypes = [],
+ } = options
+ setupFeatureStore(allowedTypes)
+ mockUseContext.mockReturnValue({
+ isShowDocumentConfig: isVisible,
+ })
+ const user = userEvent.setup()
+ render( )
+ return {
+ user,
+ setFeatures: mockSetFeatures,
+ }
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+})
+
+describe('ConfigDocument', () => {
+ it('should not render when the document configuration is hidden', () => {
+ renderConfigDocument({ isVisible: false })
+
+ expect(screen.queryByText('appDebug.feature.documentUpload.title')).not.toBeInTheDocument()
+ })
+
+ it('should show document toggle badge when configuration is visible', () => {
+ renderConfigDocument({ allowedTypes: [SupportUploadFileTypes.document] })
+
+ expect(screen.getByText('appDebug.feature.documentUpload.title')).toBeInTheDocument()
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
+ })
+
+ it('should add document type to allowed list when toggled on', async () => {
+ const { user, setFeatures } = renderConfigDocument({ allowedTypes: [SupportUploadFileTypes.audio] })
+ const toggle = screen.getByRole('switch')
+
+ expect(toggle).toHaveAttribute('aria-checked', 'false')
+ await user.click(toggle)
+
+ expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({
+ file: expect.objectContaining({
+ allowed_file_types: [SupportUploadFileTypes.audio, SupportUploadFileTypes.document],
+ enabled: true,
+ }),
+ }))
+ })
+
+ it('should remove document type but keep file feature enabled when other types remain', async () => {
+ const { user, setFeatures } = renderConfigDocument({
+ allowedTypes: [SupportUploadFileTypes.document, SupportUploadFileTypes.audio],
+ })
+ const toggle = screen.getByRole('switch')
+
+ expect(toggle).toHaveAttribute('aria-checked', 'true')
+ await user.click(toggle)
+
+ expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({
+ file: expect.objectContaining({
+ allowed_file_types: [SupportUploadFileTypes.audio],
+ enabled: true,
+ }),
+ }))
+ })
+})
diff --git a/web/app/components/app/configuration/config/index.spec.tsx b/web/app/components/app/configuration/config/index.spec.tsx
new file mode 100644
index 0000000000..814c52c3d7
--- /dev/null
+++ b/web/app/components/app/configuration/config/index.spec.tsx
@@ -0,0 +1,254 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import Config from './index'
+import type { ModelConfig, PromptVariable } from '@/models/debug'
+import * as useContextSelector from 'use-context-selector'
+import type { ToolItem } from '@/types/app'
+import { AgentStrategy, AppModeEnum, ModelModeType } from '@/types/app'
+
+jest.mock('use-context-selector', () => {
+ const actual = jest.requireActual('use-context-selector')
+ return {
+ ...actual,
+ useContext: jest.fn(),
+ }
+})
+
+const mockFormattingDispatcher = jest.fn()
+jest.mock('../debug/hooks', () => ({
+ __esModule: true,
+ useFormattingChangedDispatcher: () => mockFormattingDispatcher,
+}))
+
+let latestConfigPromptProps: any
+jest.mock('@/app/components/app/configuration/config-prompt', () => ({
+ __esModule: true,
+ default: (props: any) => {
+ latestConfigPromptProps = props
+ return
+ },
+}))
+
+let latestConfigVarProps: any
+jest.mock('@/app/components/app/configuration/config-var', () => ({
+ __esModule: true,
+ default: (props: any) => {
+ latestConfigVarProps = props
+ return
+ },
+}))
+
+jest.mock('../dataset-config', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+jest.mock('./agent/agent-tools', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+jest.mock('../config-vision', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+jest.mock('./config-document', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+jest.mock('./config-audio', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+let latestHistoryPanelProps: any
+jest.mock('../config-prompt/conversation-history/history-panel', () => ({
+ __esModule: true,
+ default: (props: any) => {
+ latestHistoryPanelProps = props
+ return
+ },
+}))
+
+type MockContext = {
+ mode: AppModeEnum
+ isAdvancedMode: boolean
+ modelModeType: ModelModeType
+ isAgent: boolean
+ hasSetBlockStatus: {
+ context: boolean
+ history: boolean
+ query: boolean
+ }
+ showHistoryModal: jest.Mock
+ modelConfig: ModelConfig
+ setModelConfig: jest.Mock
+ setPrevPromptConfig: jest.Mock
+}
+
+const createPromptVariable = (overrides: Partial = {}): PromptVariable => ({
+ key: 'variable',
+ name: 'Variable',
+ type: 'string',
+ ...overrides,
+})
+
+const createModelConfig = (overrides: Partial = {}): ModelConfig => ({
+ provider: 'openai',
+ model_id: 'gpt-4',
+ mode: ModelModeType.chat,
+ configs: {
+ prompt_template: 'Hello {{variable}}',
+ prompt_variables: [createPromptVariable({ key: 'existing' })],
+ },
+ chat_prompt_config: null,
+ completion_prompt_config: null,
+ opening_statement: null,
+ more_like_this: null,
+ suggested_questions: null,
+ suggested_questions_after_answer: null,
+ speech_to_text: null,
+ text_to_speech: null,
+ file_upload: null,
+ retriever_resource: null,
+ sensitive_word_avoidance: null,
+ annotation_reply: null,
+ external_data_tools: null,
+ system_parameters: {
+ audio_file_size_limit: 1,
+ file_size_limit: 1,
+ image_file_size_limit: 1,
+ video_file_size_limit: 1,
+ workflow_file_upload_limit: 1,
+ },
+ dataSets: [],
+ agentConfig: {
+ enabled: false,
+ strategy: AgentStrategy.react,
+ max_iteration: 1,
+ tools: [] as ToolItem[],
+ },
+ ...overrides,
+})
+
+const createContextValue = (overrides: Partial = {}): MockContext => ({
+ mode: AppModeEnum.CHAT,
+ isAdvancedMode: false,
+ modelModeType: ModelModeType.chat,
+ isAgent: false,
+ hasSetBlockStatus: {
+ context: false,
+ history: true,
+ query: false,
+ },
+ showHistoryModal: jest.fn(),
+ modelConfig: createModelConfig(),
+ setModelConfig: jest.fn(),
+ setPrevPromptConfig: jest.fn(),
+ ...overrides,
+})
+
+const mockUseContext = useContextSelector.useContext as jest.Mock
+
+const renderConfig = (contextOverrides: Partial = {}) => {
+ const contextValue = createContextValue(contextOverrides)
+ mockUseContext.mockReturnValue(contextValue)
+ return {
+ contextValue,
+ ...render( ),
+ }
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ latestConfigPromptProps = undefined
+ latestConfigVarProps = undefined
+ latestHistoryPanelProps = undefined
+})
+
+// Rendering scenarios ensure the layout toggles agent/history specific sections correctly.
+describe('Config - Rendering', () => {
+ it('should render baseline sections without agent specific panels', () => {
+ renderConfig()
+
+ expect(screen.getByTestId('config-prompt')).toBeInTheDocument()
+ expect(screen.getByTestId('config-var')).toBeInTheDocument()
+ expect(screen.getByTestId('dataset-config')).toBeInTheDocument()
+ expect(screen.getByTestId('config-vision')).toBeInTheDocument()
+ expect(screen.getByTestId('config-document')).toBeInTheDocument()
+ expect(screen.getByTestId('config-audio')).toBeInTheDocument()
+ expect(screen.queryByTestId('agent-tools')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('history-panel')).not.toBeInTheDocument()
+ })
+
+ it('should show AgentTools when app runs in agent mode', () => {
+ renderConfig({ isAgent: true })
+
+ expect(screen.getByTestId('agent-tools')).toBeInTheDocument()
+ })
+
+ it('should display HistoryPanel only when advanced chat completion values apply', () => {
+ const showHistoryModal = jest.fn()
+ renderConfig({
+ isAdvancedMode: true,
+ mode: AppModeEnum.ADVANCED_CHAT,
+ modelModeType: ModelModeType.completion,
+ hasSetBlockStatus: {
+ context: false,
+ history: false,
+ query: false,
+ },
+ showHistoryModal,
+ })
+
+ expect(screen.getByTestId('history-panel')).toBeInTheDocument()
+ expect(latestHistoryPanelProps.showWarning).toBe(true)
+ expect(latestHistoryPanelProps.onShowEditModal).toBe(showHistoryModal)
+ })
+})
+
+// Prompt handling scenarios validate integration between Config and prompt children.
+describe('Config - Prompt Handling', () => {
+ it('should update prompt template and dispatch formatting event when text changes', () => {
+ const { contextValue } = renderConfig()
+ const previousVariables = contextValue.modelConfig.configs.prompt_variables
+ const additions = [createPromptVariable({ key: 'new', name: 'New' })]
+
+ latestConfigPromptProps.onChange('Updated template', additions)
+
+ expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs)
+ expect(contextValue.setModelConfig).toHaveBeenCalledWith(expect.objectContaining({
+ configs: expect.objectContaining({
+ prompt_template: 'Updated template',
+ prompt_variables: [...previousVariables, ...additions],
+ }),
+ }))
+ expect(mockFormattingDispatcher).toHaveBeenCalledTimes(1)
+ })
+
+ it('should skip formatting dispatcher when template remains identical', () => {
+ const { contextValue } = renderConfig()
+ const unchangedTemplate = contextValue.modelConfig.configs.prompt_template
+
+ latestConfigPromptProps.onChange(unchangedTemplate, [createPromptVariable({ key: 'added' })])
+
+ expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs)
+ expect(mockFormattingDispatcher).not.toHaveBeenCalled()
+ })
+
+ it('should replace prompt variables when ConfigVar reports updates', () => {
+ const { contextValue } = renderConfig()
+ const replacementVariables = [createPromptVariable({ key: 'replacement' })]
+
+ latestConfigVarProps.onPromptVariablesChange(replacementVariables)
+
+ expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs)
+ expect(contextValue.setModelConfig).toHaveBeenCalledWith(expect.objectContaining({
+ configs: expect.objectContaining({
+ prompt_variables: replacementVariables,
+ }),
+ }))
+ })
+})
diff --git a/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx b/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx
new file mode 100644
index 0000000000..4d92ae4080
--- /dev/null
+++ b/web/app/components/app/configuration/dataset-config/card-item/index.spec.tsx
@@ -0,0 +1,242 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import Item from './index'
+import type React from 'react'
+import type { DataSet } from '@/models/datasets'
+import { ChunkingMode, DataSourceType, DatasetPermission } from '@/models/datasets'
+import type { IndexingType } from '@/app/components/datasets/create/step-two'
+import type { RetrievalConfig } from '@/types/app'
+import { RETRIEVE_METHOD } from '@/types/app'
+import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
+
+jest.mock('../settings-modal', () => ({
+ __esModule: true,
+ default: ({ onSave, onCancel, currentDataset }: any) => (
+
+
Mock settings modal
+
onSave({ ...currentDataset, name: 'Updated dataset' })}>Save changes
+
Close
+
+ ),
+}))
+
+jest.mock('@/hooks/use-breakpoints', () => {
+ const actual = jest.requireActual('@/hooks/use-breakpoints')
+ return {
+ __esModule: true,
+ ...actual,
+ default: jest.fn(() => actual.MediaType.pc),
+ }
+})
+
+const mockedUseBreakpoints = useBreakpoints as jest.MockedFunction
+
+const baseRetrievalConfig: RetrievalConfig = {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: 'provider',
+ reranking_model_name: 'rerank-model',
+ },
+ top_k: 4,
+ score_threshold_enabled: false,
+ score_threshold: 0,
+}
+
+const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType
+
+const createDataset = (overrides: Partial = {}): DataSet => {
+ const {
+ retrieval_model,
+ retrieval_model_dict,
+ icon_info,
+ ...restOverrides
+ } = overrides
+
+ const resolvedRetrievalModelDict = {
+ ...baseRetrievalConfig,
+ ...retrieval_model_dict,
+ }
+ const resolvedRetrievalModel = {
+ ...baseRetrievalConfig,
+ ...(retrieval_model ?? retrieval_model_dict),
+ }
+
+ const defaultIconInfo = {
+ icon: '📘',
+ icon_type: 'emoji',
+ icon_background: '#FFEAD5',
+ icon_url: '',
+ }
+
+ const resolvedIconInfo = ('icon_info' in overrides)
+ ? icon_info
+ : defaultIconInfo
+
+ return {
+ id: 'dataset-id',
+ name: 'Dataset Name',
+ indexing_status: 'completed',
+ icon_info: resolvedIconInfo as DataSet['icon_info'],
+ description: 'A test dataset',
+ permission: DatasetPermission.onlyMe,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: defaultIndexingTechnique,
+ author_name: 'author',
+ created_by: 'creator',
+ updated_by: 'updater',
+ updated_at: 0,
+ app_count: 0,
+ doc_form: ChunkingMode.text,
+ document_count: 0,
+ total_document_count: 0,
+ total_available_documents: 0,
+ word_count: 0,
+ provider: 'dify',
+ embedding_model: 'text-embedding',
+ embedding_model_provider: 'openai',
+ embedding_available: true,
+ retrieval_model_dict: resolvedRetrievalModelDict,
+ retrieval_model: resolvedRetrievalModel,
+ tags: [],
+ external_knowledge_info: {
+ external_knowledge_id: 'external-id',
+ external_knowledge_api_id: 'api-id',
+ external_knowledge_api_name: 'api-name',
+ external_knowledge_api_endpoint: 'https://endpoint',
+ },
+ external_retrieval_model: {
+ top_k: 2,
+ score_threshold: 0.5,
+ score_threshold_enabled: true,
+ },
+ built_in_field_enabled: true,
+ doc_metadata: [],
+ keyword_number: 3,
+ pipeline_id: 'pipeline-id',
+ is_published: true,
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+ ...restOverrides,
+ }
+}
+
+const renderItem = (config: DataSet, props?: Partial>) => {
+ const onSave = jest.fn()
+ const onRemove = jest.fn()
+
+ render(
+ ,
+ )
+
+ return { onSave, onRemove }
+}
+
+describe('dataset-config/card-item', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockedUseBreakpoints.mockReturnValue(MediaType.pc)
+ })
+
+ it('should render dataset details with indexing and external badges', () => {
+ const dataset = createDataset({
+ provider: 'external',
+ retrieval_model_dict: {
+ ...baseRetrievalConfig,
+ search_method: RETRIEVE_METHOD.semantic,
+ },
+ })
+
+ renderItem(dataset)
+
+ const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
+ const actionButtons = within(card).getAllByRole('button', { hidden: true })
+
+ expect(screen.getByText(dataset.name)).toBeInTheDocument()
+ expect(screen.getByText('dataset.indexingTechnique.high_quality · dataset.indexingMethod.semantic_search')).toBeInTheDocument()
+ expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
+ expect(actionButtons).toHaveLength(2)
+ })
+
+ it('should open settings drawer from edit action and close after saving', async () => {
+ const user = userEvent.setup()
+ const dataset = createDataset()
+ const { onSave } = renderItem(dataset)
+
+ const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
+ const [editButton] = within(card).getAllByRole('button', { hidden: true })
+ await user.click(editButton)
+
+ expect(screen.getByText('Mock settings modal')).toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByRole('dialog')).toBeVisible()
+ })
+
+ await user.click(screen.getByText('Save changes'))
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' }))
+ })
+ await waitFor(() => {
+ expect(screen.getByText('Mock settings modal')).not.toBeVisible()
+ })
+ })
+
+ it('should call onRemove and toggle destructive state on hover', async () => {
+ const user = userEvent.setup()
+ const dataset = createDataset()
+ const { onRemove } = renderItem(dataset)
+
+ const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
+ const buttons = within(card).getAllByRole('button', { hidden: true })
+ const deleteButton = buttons[buttons.length - 1]
+
+ expect(deleteButton.className).not.toContain('action-btn-destructive')
+
+ fireEvent.mouseEnter(deleteButton)
+ expect(deleteButton.className).toContain('action-btn-destructive')
+ expect(card.className).toContain('border-state-destructive-border')
+
+ fireEvent.mouseLeave(deleteButton)
+ expect(deleteButton.className).not.toContain('action-btn-destructive')
+
+ await user.click(deleteButton)
+ expect(onRemove).toHaveBeenCalledWith(dataset.id)
+ })
+
+ it('should use default icon information when icon details are missing', () => {
+ const dataset = createDataset({ icon_info: undefined })
+
+ renderItem(dataset)
+
+ const nameElement = screen.getByText(dataset.name)
+ const iconElement = nameElement.parentElement?.firstElementChild as HTMLElement
+
+ expect(iconElement).toHaveStyle({ background: '#FFF4ED' })
+ expect(iconElement.querySelector('em-emoji')).toHaveAttribute('id', '📙')
+ })
+
+ it('should apply mask overlay on mobile when drawer is open', async () => {
+ mockedUseBreakpoints.mockReturnValue(MediaType.mobile)
+ const user = userEvent.setup()
+ const dataset = createDataset()
+
+ renderItem(dataset)
+
+ const card = screen.getByText(dataset.name).closest('.group') as HTMLElement
+ const [editButton] = within(card).getAllByRole('button', { hidden: true })
+ await user.click(editButton)
+ expect(screen.getByText('Mock settings modal')).toBeInTheDocument()
+
+ const overlay = Array.from(document.querySelectorAll('[class]'))
+ .find(element => element.className.toString().includes('bg-black/30'))
+
+ expect(overlay).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/app/configuration/dataset-config/card-item/index.tsx b/web/app/components/app/configuration/dataset-config/card-item/index.tsx
index 85d46122a3..7fd7011a56 100644
--- a/web/app/components/app/configuration/dataset-config/card-item/index.tsx
+++ b/web/app/components/app/configuration/dataset-config/card-item/index.tsx
@@ -13,7 +13,7 @@ import Drawer from '@/app/components/base/drawer'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Badge from '@/app/components/base/badge'
import { useKnowledge } from '@/hooks/use-knowledge'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import AppIcon from '@/app/components/base/app-icon'
type ItemProps = {
diff --git a/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx
new file mode 100644
index 0000000000..69378fbb32
--- /dev/null
+++ b/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx
@@ -0,0 +1,299 @@
+import * as React from 'react'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ContextVar from './index'
+import type { Props } from './var-picker'
+
+// Mock external dependencies only
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({ push: jest.fn() }),
+ usePathname: () => '/test',
+}))
+
+type PortalToFollowElemProps = {
+ children: React.ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}
+type PortalToFollowElemTriggerProps = React.HTMLAttributes & { children?: React.ReactNode; asChild?: boolean }
+type PortalToFollowElemContentProps = React.HTMLAttributes & { children?: React.ReactNode }
+
+jest.mock('@/app/components/base/portal-to-follow-elem', () => {
+ const PortalContext = React.createContext({ open: false })
+
+ const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
+ return (
+
+ {children}
+
+ )
+ }
+
+ const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
+ const { open } = React.useContext(PortalContext)
+ if (!open) return null
+ return (
+
+ {children}
+
+ )
+ }
+
+ const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
+ if (asChild && React.isValidElement(children)) {
+ return React.cloneElement(children, {
+ ...props,
+ 'data-testid': 'portal-trigger',
+ } as React.HTMLAttributes)
+ }
+ return (
+
+ {children}
+
+ )
+ }
+
+ return {
+ PortalToFollowElem,
+ PortalToFollowElemContent,
+ PortalToFollowElemTrigger,
+ }
+})
+
+describe('ContextVar', () => {
+ const mockOptions: Props['options'] = [
+ { name: 'Variable 1', value: 'var1', type: 'string' },
+ { name: 'Variable 2', value: 'var2', type: 'number' },
+ ]
+
+ const defaultProps: Props = {
+ value: 'var1',
+ options: mockOptions,
+ onChange: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // Rendering tests (REQUIRED)
+ describe('Rendering', () => {
+ it('should display query variable selector when options are provided', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
+ })
+
+ it('should show selected variable with proper formatting when value is provided', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('var1')).toBeInTheDocument()
+ expect(screen.getByText('{{')).toBeInTheDocument()
+ expect(screen.getByText('}}')).toBeInTheDocument()
+ })
+ })
+
+ // Props tests (REQUIRED)
+ describe('Props', () => {
+ it('should display selected variable when value prop is provided', () => {
+ // Arrange
+ const props = { ...defaultProps, value: 'var2' }
+
+ // Act
+ render( )
+
+ // Assert - Should display the selected value
+ expect(screen.getByText('var2')).toBeInTheDocument()
+ })
+
+ it('should show placeholder text when no value is selected', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ value: undefined,
+ }
+
+ // Act
+ render( )
+
+ // Assert - Should show placeholder instead of variable
+ expect(screen.queryByText('var1')).not.toBeInTheDocument()
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
+ })
+
+ it('should display custom tip message when notSelectedVarTip is provided', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ value: undefined,
+ notSelectedVarTip: 'Select a variable',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Select a variable')).toBeInTheDocument()
+ })
+
+ it('should apply custom className to VarPicker when provided', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ className: 'custom-class',
+ }
+
+ // Act
+ const { container } = render( )
+
+ // Assert
+ expect(container.querySelector('.custom-class')).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call onChange when user selects a different variable', async () => {
+ // Arrange
+ const onChange = jest.fn()
+ const props = { ...defaultProps, onChange }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ const triggers = screen.getAllByTestId('portal-trigger')
+ const varPickerTrigger = triggers[triggers.length - 1]
+
+ await user.click(varPickerTrigger)
+ expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+
+ // Select a different option
+ const options = screen.getAllByText('var2')
+ expect(options.length).toBeGreaterThan(0)
+ await user.click(options[0])
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith('var2')
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ })
+
+ it('should toggle dropdown when clicking the trigger button', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ const triggers = screen.getAllByTestId('portal-trigger')
+ const varPickerTrigger = triggers[triggers.length - 1]
+
+ // Open dropdown
+ await user.click(varPickerTrigger)
+ expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+
+ // Close dropdown
+ await user.click(varPickerTrigger)
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ })
+ })
+
+ // Edge Cases (REQUIRED)
+ describe('Edge Cases', () => {
+ it('should handle undefined value gracefully', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ value: undefined,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
+ expect(screen.queryByText('var1')).not.toBeInTheDocument()
+ })
+
+ it('should handle empty options array', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ options: [],
+ value: undefined,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
+ })
+
+ it('should handle null value without crashing', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ value: undefined,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.title')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
+ })
+
+ it('should handle options with different data types', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ options: [
+ { name: 'String Var', value: 'strVar', type: 'string' },
+ { name: 'Number Var', value: '42', type: 'number' },
+ { name: 'Boolean Var', value: 'true', type: 'boolean' },
+ ],
+ value: 'strVar',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('strVar')).toBeInTheDocument()
+ expect(screen.getByText('{{')).toBeInTheDocument()
+ expect(screen.getByText('}}')).toBeInTheDocument()
+ })
+
+ it('should render variable names with special characters safely', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ options: [
+ { name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' },
+ ],
+ value: 'specialVar',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('specialVar')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/dataset-config/context-var/index.tsx b/web/app/components/app/configuration/dataset-config/context-var/index.tsx
index ebba9c51cb..80cc50acdf 100644
--- a/web/app/components/app/configuration/dataset-config/context-var/index.tsx
+++ b/web/app/components/app/configuration/dataset-config/context-var/index.tsx
@@ -4,7 +4,7 @@ import React from 'react'
import { useTranslation } from 'react-i18next'
import type { Props } from './var-picker'
import VarPicker from './var-picker'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { BracketsX } from '@/app/components/base/icons/src/vender/line/development'
import Tooltip from '@/app/components/base/tooltip'
diff --git a/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx
new file mode 100644
index 0000000000..cb46ce9788
--- /dev/null
+++ b/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx
@@ -0,0 +1,392 @@
+import * as React from 'react'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import VarPicker, { type Props } from './var-picker'
+
+// Mock external dependencies only
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({ push: jest.fn() }),
+ usePathname: () => '/test',
+}))
+
+type PortalToFollowElemProps = {
+ children: React.ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}
+type PortalToFollowElemTriggerProps = React.HTMLAttributes & { children?: React.ReactNode; asChild?: boolean }
+type PortalToFollowElemContentProps = React.HTMLAttributes & { children?: React.ReactNode }
+
+jest.mock('@/app/components/base/portal-to-follow-elem', () => {
+ const PortalContext = React.createContext({ open: false })
+
+ const PortalToFollowElem = ({ children, open }: PortalToFollowElemProps) => {
+ return (
+
+ {children}
+
+ )
+ }
+
+ const PortalToFollowElemContent = ({ children, ...props }: PortalToFollowElemContentProps) => {
+ const { open } = React.useContext(PortalContext)
+ if (!open) return null
+ return (
+
+ {children}
+
+ )
+ }
+
+ const PortalToFollowElemTrigger = ({ children, asChild, ...props }: PortalToFollowElemTriggerProps) => {
+ if (asChild && React.isValidElement(children)) {
+ return React.cloneElement(children, {
+ ...props,
+ 'data-testid': 'portal-trigger',
+ } as React.HTMLAttributes)
+ }
+ return (
+
+ {children}
+
+ )
+ }
+
+ return {
+ PortalToFollowElem,
+ PortalToFollowElemContent,
+ PortalToFollowElemTrigger,
+ }
+})
+
+describe('VarPicker', () => {
+ const mockOptions: Props['options'] = [
+ { name: 'Variable 1', value: 'var1', type: 'string' },
+ { name: 'Variable 2', value: 'var2', type: 'number' },
+ { name: 'Variable 3', value: 'var3', type: 'boolean' },
+ ]
+
+ const defaultProps: Props = {
+ value: 'var1',
+ options: mockOptions,
+ onChange: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // Rendering tests (REQUIRED)
+ describe('Rendering', () => {
+ it('should render variable picker with dropdown trigger', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
+ expect(screen.getByText('var1')).toBeInTheDocument()
+ })
+
+ it('should display selected variable with type icon when value is provided', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('var1')).toBeInTheDocument()
+ expect(screen.getByText('{{')).toBeInTheDocument()
+ expect(screen.getByText('}}')).toBeInTheDocument()
+ // IconTypeIcon should be rendered (check for svg icon)
+ expect(document.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('should show placeholder text when no value is selected', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ value: undefined,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.queryByText('var1')).not.toBeInTheDocument()
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
+ })
+
+ it('should display custom tip message when notSelectedVarTip is provided', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ value: undefined,
+ notSelectedVarTip: 'Select a variable',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Select a variable')).toBeInTheDocument()
+ })
+
+ it('should render dropdown indicator icon', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert - Trigger should be present
+ expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
+ })
+ })
+
+ // Props tests (REQUIRED)
+ describe('Props', () => {
+ it('should apply custom className to wrapper', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ className: 'custom-class',
+ }
+
+ // Act
+ const { container } = render( )
+
+ // Assert
+ expect(container.querySelector('.custom-class')).toBeInTheDocument()
+ })
+
+ it('should apply custom triggerClassName to trigger button', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ triggerClassName: 'custom-trigger-class',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('portal-trigger')).toHaveClass('custom-trigger-class')
+ })
+
+ it('should display selected value with proper formatting', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ value: 'customVar',
+ options: [
+ { name: 'Custom Variable', value: 'customVar', type: 'string' },
+ ],
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('customVar')).toBeInTheDocument()
+ expect(screen.getByText('{{')).toBeInTheDocument()
+ expect(screen.getByText('}}')).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should open dropdown when clicking the trigger button', async () => {
+ // Arrange
+ const onChange = jest.fn()
+ const props = { ...defaultProps, onChange }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ await user.click(screen.getByTestId('portal-trigger'))
+
+ // Assert
+ expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+ })
+
+ it('should call onChange and close dropdown when selecting an option', async () => {
+ // Arrange
+ const onChange = jest.fn()
+ const props = { ...defaultProps, onChange }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ // Open dropdown
+ await user.click(screen.getByTestId('portal-trigger'))
+ expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+
+ // Select a different option
+ const options = screen.getAllByText('var2')
+ expect(options.length).toBeGreaterThan(0)
+ await user.click(options[0])
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith('var2')
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ })
+
+ it('should toggle dropdown when clicking trigger button multiple times', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ const trigger = screen.getByTestId('portal-trigger')
+
+ // Open dropdown
+ await user.click(trigger)
+ expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+
+ // Close dropdown
+ await user.click(trigger)
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ })
+ })
+
+ // State Management
+ describe('State Management', () => {
+ it('should initialize with closed dropdown', () => {
+ // Arrange
+ const props = { ...defaultProps }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ })
+
+ it('should toggle dropdown state on trigger click', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ const trigger = screen.getByTestId('portal-trigger')
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+
+ // Open dropdown
+ await user.click(trigger)
+ expect(screen.getByTestId('portal-content')).toBeInTheDocument()
+
+ // Close dropdown
+ await user.click(trigger)
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ })
+
+ it('should preserve selected value when dropdown is closed without selection', async () => {
+ // Arrange
+ const props = { ...defaultProps }
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+
+ // Open and close dropdown without selecting anything
+ const trigger = screen.getByTestId('portal-trigger')
+ await user.click(trigger)
+ await user.click(trigger)
+
+ // Assert
+ expect(screen.getByText('var1')).toBeInTheDocument() // Original value still displayed
+ })
+ })
+
+ // Edge Cases (REQUIRED)
+ describe('Edge Cases', () => {
+ it('should handle undefined value gracefully', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ value: undefined,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
+ expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
+ })
+
+ it('should handle empty options array', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ options: [],
+ value: undefined,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
+ })
+
+ it('should handle null value without crashing', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ value: undefined,
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('appDebug.feature.dataSet.queryVariable.choosePlaceholder')).toBeInTheDocument()
+ })
+
+ it('should handle variable names with special characters safely', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ options: [
+ { name: 'Variable with & < > " \' characters', value: 'specialVar', type: 'string' },
+ ],
+ value: 'specialVar',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('specialVar')).toBeInTheDocument()
+ })
+
+ it('should handle long variable names', () => {
+ // Arrange
+ const props = {
+ ...defaultProps,
+ options: [
+ { name: 'A very long variable name that should be truncated', value: 'longVar', type: 'string' },
+ ],
+ value: 'longVar',
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('longVar')).toBeInTheDocument()
+ expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx b/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx
index c443ea0b1f..f5ea2eaa27 100644
--- a/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx
+++ b/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx
@@ -3,7 +3,7 @@ import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
diff --git a/web/app/components/app/configuration/dataset-config/index.spec.tsx b/web/app/components/app/configuration/dataset-config/index.spec.tsx
new file mode 100644
index 0000000000..3c48eca206
--- /dev/null
+++ b/web/app/components/app/configuration/dataset-config/index.spec.tsx
@@ -0,0 +1,1048 @@
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import DatasetConfig from './index'
+import type { DataSet } from '@/models/datasets'
+import { DataSourceType, DatasetPermission } from '@/models/datasets'
+import { AppModeEnum } from '@/types/app'
+import { ModelModeType } from '@/types/app'
+import { RETRIEVE_TYPE } from '@/types/app'
+import { ComparisonOperator, LogicalOperator } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
+import type { DatasetConfigs } from '@/models/debug'
+
+// Mock external dependencies
+jest.mock('@/app/components/workflow/nodes/knowledge-retrieval/utils', () => ({
+ getMultipleRetrievalConfig: jest.fn(() => ({
+ top_k: 4,
+ score_threshold: 0.7,
+ reranking_enable: false,
+ reranking_model: undefined,
+ reranking_mode: 'reranking_model',
+ weights: { weight1: 1.0 },
+ })),
+ getSelectedDatasetsMode: jest.fn(() => ({
+ allInternal: true,
+ allExternal: false,
+ mixtureInternalAndExternal: false,
+ mixtureHighQualityAndEconomic: false,
+ inconsistentEmbeddingModel: false,
+ })),
+}))
+
+jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(() => ({
+ currentModel: { model: 'rerank-model' },
+ currentProvider: { provider: 'openai' },
+ })),
+}))
+
+jest.mock('@/context/app-context', () => ({
+ useSelector: jest.fn((fn: any) => fn({
+ userProfile: {
+ id: 'user-123',
+ },
+ })),
+}))
+
+jest.mock('@/utils/permission', () => ({
+ hasEditPermissionForDataset: jest.fn(() => true),
+}))
+
+jest.mock('../debug/hooks', () => ({
+ useFormattingChangedDispatcher: jest.fn(() => jest.fn()),
+}))
+
+jest.mock('lodash-es', () => ({
+ intersectionBy: jest.fn((...arrays) => {
+ // Mock realistic intersection behavior based on metadata name
+ const validArrays = arrays.filter(Array.isArray)
+ if (validArrays.length === 0) return []
+
+ // Start with first array and filter down
+ return validArrays[0].filter((item: any) => {
+ if (!item || !item.name) return false
+
+ // Only return items that exist in all arrays
+ return validArrays.every(array =>
+ array.some((otherItem: any) =>
+ otherItem && otherItem.name === item.name,
+ ),
+ )
+ })
+ }),
+}))
+
+jest.mock('uuid', () => ({
+ v4: jest.fn(() => 'mock-uuid'),
+}))
+
+// Mock child components
+jest.mock('./card-item', () => ({
+ __esModule: true,
+ default: ({ config, onRemove, onSave, editable }: any) => (
+
+ {config.name}
+ {editable && onSave(config)}>Edit }
+ onRemove(config.id)}>Remove
+
+ ),
+}))
+
+jest.mock('./params-config', () => ({
+ __esModule: true,
+ default: ({ disabled, selectedDatasets }: any) => (
+
+ Params ({selectedDatasets.length})
+
+ ),
+}))
+
+jest.mock('./context-var', () => ({
+ __esModule: true,
+ default: ({ value, options, onChange }: any) => (
+ onChange(e.target.value)}>
+ Select context variable
+ {options.map((opt: any) => (
+ {opt.name}
+ ))}
+
+ ),
+}))
+
+jest.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter', () => ({
+ __esModule: true,
+ default: ({
+ metadataList,
+ metadataFilterMode,
+ handleMetadataFilterModeChange,
+ handleAddCondition,
+ handleRemoveCondition,
+ handleUpdateCondition,
+ handleToggleConditionLogicalOperator,
+ }: any) => (
+
+ {metadataList.length}
+ handleMetadataFilterModeChange(e.target.value)}>
+ Disabled
+ Automatic
+ Manual
+
+ handleAddCondition({ name: 'test', type: 'string' })}>
+ Add Condition
+
+ handleRemoveCondition('condition-id')}>
+ Remove Condition
+
+ handleUpdateCondition('condition-id', { name: 'updated' })}>
+ Update Condition
+
+
+ Toggle Operator
+
+
+ ),
+}))
+
+// Mock context
+const mockConfigContext: any = {
+ mode: AppModeEnum.CHAT,
+ modelModeType: ModelModeType.chat,
+ isAgent: false,
+ dataSets: [],
+ setDataSets: jest.fn(),
+ modelConfig: {
+ configs: {
+ prompt_variables: [],
+ },
+ },
+ setModelConfig: jest.fn(),
+ showSelectDataSet: jest.fn(),
+ datasetConfigs: {
+ retrieval_model: RETRIEVE_TYPE.multiWay,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 4,
+ score_threshold_enabled: false,
+ score_threshold: 0.7,
+ metadata_filtering_mode: 'disabled' as any,
+ metadata_filtering_conditions: undefined,
+ datasets: {
+ datasets: [],
+ },
+ } as DatasetConfigs,
+ datasetConfigsRef: {
+ current: {
+ retrieval_model: RETRIEVE_TYPE.multiWay,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 4,
+ score_threshold_enabled: false,
+ score_threshold: 0.7,
+ metadata_filtering_mode: 'disabled' as any,
+ metadata_filtering_conditions: undefined,
+ datasets: {
+ datasets: [],
+ },
+ } as DatasetConfigs,
+ },
+ setDatasetConfigs: jest.fn(),
+ setRerankSettingModalOpen: jest.fn(),
+}
+
+jest.mock('@/context/debug-configuration', () => ({
+ __esModule: true,
+ default: ({ children }: any) => (
+
+ {children}
+
+ ),
+}))
+
+jest.mock('use-context-selector', () => ({
+ useContext: jest.fn(() => mockConfigContext),
+}))
+
+const createMockDataset = (overrides: Partial = {}): DataSet => {
+ const defaultDataset: DataSet = {
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ indexing_status: 'completed' as any,
+ icon_info: {
+ icon: '📘',
+ icon_type: 'emoji',
+ icon_background: '#FFEAD5',
+ icon_url: '',
+ },
+ description: 'Test dataset description',
+ permission: DatasetPermission.onlyMe,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: 'high_quality' as any,
+ author_name: 'Test Author',
+ created_by: 'user-123',
+ updated_by: 'user-123',
+ updated_at: Date.now(),
+ app_count: 0,
+ doc_form: 'text' as any,
+ document_count: 10,
+ total_document_count: 10,
+ total_available_documents: 10,
+ word_count: 1000,
+ provider: 'dify',
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ embedding_available: true,
+ retrieval_model_dict: {
+ search_method: 'semantic_search' as any,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 4,
+ score_threshold_enabled: false,
+ score_threshold: 0.7,
+ },
+ retrieval_model: {
+ search_method: 'semantic_search' as any,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 4,
+ score_threshold_enabled: false,
+ score_threshold: 0.7,
+ },
+ tags: [],
+ external_knowledge_info: {
+ external_knowledge_id: '',
+ external_knowledge_api_id: '',
+ external_knowledge_api_name: '',
+ external_knowledge_api_endpoint: '',
+ },
+ external_retrieval_model: {
+ top_k: 2,
+ score_threshold: 0.5,
+ score_threshold_enabled: true,
+ },
+ built_in_field_enabled: true,
+ doc_metadata: [
+ { name: 'category', type: 'string' } as any,
+ { name: 'priority', type: 'number' } as any,
+ ],
+ keyword_number: 3,
+ pipeline_id: 'pipeline-123',
+ is_published: true,
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+ ...overrides,
+ }
+ return defaultDataset
+}
+
+const renderDatasetConfig = (contextOverrides: Partial = {}) => {
+ const useContextSelector = require('use-context-selector').useContext
+ const mergedContext = { ...mockConfigContext, ...contextOverrides }
+ useContextSelector.mockReturnValue(mergedContext)
+
+ return render( )
+}
+
+describe('DatasetConfig', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockConfigContext.dataSets = []
+ mockConfigContext.setDataSets = jest.fn()
+ mockConfigContext.setModelConfig = jest.fn()
+ mockConfigContext.setDatasetConfigs = jest.fn()
+ mockConfigContext.setRerankSettingModalOpen = jest.fn()
+ })
+
+ describe('Rendering', () => {
+ it('should render dataset configuration panel when component mounts', () => {
+ renderDatasetConfig()
+
+ expect(screen.getByText('appDebug.feature.dataSet.title')).toBeInTheDocument()
+ })
+
+ it('should display empty state message when no datasets are configured', () => {
+ renderDatasetConfig()
+
+ expect(screen.getByText(/no.*data/i)).toBeInTheDocument()
+ expect(screen.getByTestId('params-config')).toBeDisabled()
+ })
+
+ it('should render dataset cards and enable parameters when datasets exist', () => {
+ const dataset = createMockDataset()
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ expect(screen.getByTestId(`card-item-${dataset.id}`)).toBeInTheDocument()
+ expect(screen.getByText(dataset.name)).toBeInTheDocument()
+ expect(screen.getByTestId('params-config')).not.toBeDisabled()
+ })
+
+ it('should show configuration title and add dataset button in header', () => {
+ renderDatasetConfig()
+
+ expect(screen.getByText('appDebug.feature.dataSet.title')).toBeInTheDocument()
+ expect(screen.getByText('common.operation.add')).toBeInTheDocument()
+ })
+
+ it('should hide parameters configuration when in agent mode', () => {
+ renderDatasetConfig({
+ isAgent: true,
+ })
+
+ expect(screen.queryByTestId('params-config')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Dataset Management', () => {
+ it('should open dataset selection modal when add button is clicked', async () => {
+ const user = userEvent.setup()
+ renderDatasetConfig()
+
+ const addButton = screen.getByText('common.operation.add')
+ await user.click(addButton)
+
+ expect(mockConfigContext.showSelectDataSet).toHaveBeenCalledTimes(1)
+ })
+
+ it('should remove dataset and update configuration when remove button is clicked', async () => {
+ const user = userEvent.setup()
+ const dataset = createMockDataset()
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ const removeButton = screen.getByText('Remove')
+ await user.click(removeButton)
+
+ expect(mockConfigContext.setDataSets).toHaveBeenCalledWith([])
+ // Note: setDatasetConfigs is also called but its exact parameters depend on
+ // the retrieval config calculation which involves complex mocked utilities
+ })
+
+ it('should trigger rerank setting modal when removing dataset requires rerank configuration', async () => {
+ const user = userEvent.setup()
+ const { getSelectedDatasetsMode } = require('@/app/components/workflow/nodes/knowledge-retrieval/utils')
+
+ // Mock scenario that triggers rerank modal
+ getSelectedDatasetsMode.mockReturnValue({
+ allInternal: false,
+ allExternal: true,
+ mixtureInternalAndExternal: false,
+ mixtureHighQualityAndEconomic: false,
+ inconsistentEmbeddingModel: false,
+ })
+
+ const dataset = createMockDataset()
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ const removeButton = screen.getByText('Remove')
+ await user.click(removeButton)
+
+ expect(mockConfigContext.setRerankSettingModalOpen).toHaveBeenCalledWith(true)
+ })
+
+ it('should handle dataset save', async () => {
+ const user = userEvent.setup()
+ const dataset = createMockDataset()
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ // Mock the onSave in card-item component - it will pass the original dataset
+ const editButton = screen.getByText('Edit')
+ await user.click(editButton)
+
+ expect(mockConfigContext.setDataSets).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: dataset.id,
+ name: dataset.name,
+ editable: true,
+ }),
+ ]),
+ )
+ })
+
+ it('should format datasets with edit permission', () => {
+ const dataset = createMockDataset({
+ created_by: 'user-123',
+ })
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ expect(screen.getByTestId(`card-item-${dataset.id}`)).toBeInTheDocument()
+ })
+ })
+
+ describe('Context Variables', () => {
+ it('should show context variable selector in completion mode with datasets', () => {
+ const dataset = createMockDataset()
+ renderDatasetConfig({
+ mode: AppModeEnum.COMPLETION,
+ dataSets: [dataset],
+ modelConfig: {
+ configs: {
+ prompt_variables: [
+ { key: 'query', name: 'Query', type: 'string', is_context_var: false },
+ { key: 'context', name: 'Context', type: 'string', is_context_var: true },
+ ],
+ },
+ },
+ })
+
+ expect(screen.getByTestId('context-var')).toBeInTheDocument()
+ // Should find the selected context variable in the options
+ expect(screen.getByText('Select context variable')).toBeInTheDocument()
+ })
+
+ it('should not show context variable selector in chat mode', () => {
+ const dataset = createMockDataset()
+ renderDatasetConfig({
+ mode: AppModeEnum.CHAT,
+ dataSets: [dataset],
+ modelConfig: {
+ configs: {
+ prompt_variables: [
+ { key: 'query', name: 'Query', type: 'string', is_context_var: false },
+ ],
+ },
+ },
+ })
+
+ expect(screen.queryByTestId('context-var')).not.toBeInTheDocument()
+ })
+
+ it('should handle context variable selection', async () => {
+ const user = userEvent.setup()
+ const dataset = createMockDataset()
+ renderDatasetConfig({
+ mode: AppModeEnum.COMPLETION,
+ dataSets: [dataset],
+ modelConfig: {
+ configs: {
+ prompt_variables: [
+ { key: 'query', name: 'Query', type: 'string', is_context_var: false },
+ { key: 'context', name: 'Context', type: 'string', is_context_var: true },
+ ],
+ },
+ },
+ })
+
+ const select = screen.getByTestId('context-var')
+ await user.selectOptions(select, 'query')
+
+ expect(mockConfigContext.setModelConfig).toHaveBeenCalled()
+ })
+ })
+
+ describe('Metadata Filtering', () => {
+ it('should render metadata filter component', () => {
+ const dataset = createMockDataset({
+ doc_metadata: [
+ { name: 'category', type: 'string' } as any,
+ { name: 'priority', type: 'number' } as any,
+ ],
+ })
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ expect(screen.getByTestId('metadata-filter')).toBeInTheDocument()
+ expect(screen.getByTestId('metadata-list-count')).toHaveTextContent('2') // both 'category' and 'priority'
+ })
+
+ it('should handle metadata filter mode change', async () => {
+ const user = userEvent.setup()
+ const dataset = createMockDataset()
+ const updatedDatasetConfigs = {
+ ...mockConfigContext.datasetConfigs,
+ metadata_filtering_mode: 'disabled' as any,
+ }
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ datasetConfigs: updatedDatasetConfigs,
+ })
+
+ // Update the ref to match
+ mockConfigContext.datasetConfigsRef.current = updatedDatasetConfigs
+
+ const select = within(screen.getByTestId('metadata-filter')).getByDisplayValue('Disabled')
+ await user.selectOptions(select, 'automatic')
+
+ expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata_filtering_mode: 'automatic',
+ }),
+ )
+ })
+
+ it('should handle adding metadata conditions', async () => {
+ const user = userEvent.setup()
+ const dataset = createMockDataset()
+ const baseDatasetConfigs = {
+ ...mockConfigContext.datasetConfigs,
+ }
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ datasetConfigs: baseDatasetConfigs,
+ })
+
+ // Update the ref to match
+ mockConfigContext.datasetConfigsRef.current = baseDatasetConfigs
+
+ const addButton = within(screen.getByTestId('metadata-filter')).getByText('Add Condition')
+ await user.click(addButton)
+
+ expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata_filtering_conditions: expect.objectContaining({
+ logical_operator: LogicalOperator.and,
+ conditions: expect.arrayContaining([
+ expect.objectContaining({
+ id: 'mock-uuid',
+ name: 'test',
+ comparison_operator: ComparisonOperator.is,
+ }),
+ ]),
+ }),
+ }),
+ )
+ })
+
+ it('should handle removing metadata conditions', async () => {
+ const user = userEvent.setup()
+ const dataset = createMockDataset()
+
+ const datasetConfigsWithConditions = {
+ ...mockConfigContext.datasetConfigs,
+ metadata_filtering_conditions: {
+ logical_operator: LogicalOperator.and,
+ conditions: [
+ { id: 'condition-id', name: 'test', comparison_operator: ComparisonOperator.is },
+ ],
+ },
+ }
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ datasetConfigs: datasetConfigsWithConditions,
+ })
+
+ // Update ref to match datasetConfigs
+ mockConfigContext.datasetConfigsRef.current = datasetConfigsWithConditions
+
+ const removeButton = within(screen.getByTestId('metadata-filter')).getByText('Remove Condition')
+ await user.click(removeButton)
+
+ expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata_filtering_conditions: expect.objectContaining({
+ conditions: [],
+ }),
+ }),
+ )
+ })
+
+ it('should handle updating metadata conditions', async () => {
+ const user = userEvent.setup()
+ const dataset = createMockDataset()
+
+ const datasetConfigsWithConditions = {
+ ...mockConfigContext.datasetConfigs,
+ metadata_filtering_conditions: {
+ logical_operator: LogicalOperator.and,
+ conditions: [
+ { id: 'condition-id', name: 'test', comparison_operator: ComparisonOperator.is },
+ ],
+ },
+ }
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ datasetConfigs: datasetConfigsWithConditions,
+ })
+
+ mockConfigContext.datasetConfigsRef.current = datasetConfigsWithConditions
+
+ const updateButton = within(screen.getByTestId('metadata-filter')).getByText('Update Condition')
+ await user.click(updateButton)
+
+ expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata_filtering_conditions: expect.objectContaining({
+ conditions: expect.arrayContaining([
+ expect.objectContaining({
+ name: 'updated',
+ }),
+ ]),
+ }),
+ }),
+ )
+ })
+
+ it('should handle toggling logical operator', async () => {
+ const user = userEvent.setup()
+ const dataset = createMockDataset()
+
+ const datasetConfigsWithConditions = {
+ ...mockConfigContext.datasetConfigs,
+ metadata_filtering_conditions: {
+ logical_operator: LogicalOperator.and,
+ conditions: [
+ { id: 'condition-id', name: 'test', comparison_operator: ComparisonOperator.is },
+ ],
+ },
+ }
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ datasetConfigs: datasetConfigsWithConditions,
+ })
+
+ mockConfigContext.datasetConfigsRef.current = datasetConfigsWithConditions
+
+ const toggleButton = within(screen.getByTestId('metadata-filter')).getByText('Toggle Operator')
+ await user.click(toggleButton)
+
+ expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata_filtering_conditions: expect.objectContaining({
+ logical_operator: LogicalOperator.or,
+ }),
+ }),
+ )
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle null doc_metadata gracefully', () => {
+ const dataset = createMockDataset({
+ doc_metadata: undefined,
+ })
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ expect(screen.getByTestId('metadata-filter')).toBeInTheDocument()
+ expect(screen.getByTestId('metadata-list-count')).toHaveTextContent('0')
+ })
+
+ it('should handle empty doc_metadata array', () => {
+ const dataset = createMockDataset({
+ doc_metadata: [],
+ })
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ expect(screen.getByTestId('metadata-filter')).toBeInTheDocument()
+ expect(screen.getByTestId('metadata-list-count')).toHaveTextContent('0')
+ })
+
+ it('should handle missing userProfile', () => {
+ const useSelector = require('@/context/app-context').useSelector
+ useSelector.mockImplementation((fn: any) => fn({ userProfile: null }))
+
+ const dataset = createMockDataset()
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ expect(screen.getByTestId(`card-item-${dataset.id}`)).toBeInTheDocument()
+ })
+
+ it('should handle missing datasetConfigsRef gracefully', () => {
+ const dataset = createMockDataset()
+
+ // Test with undefined datasetConfigsRef - component renders without immediate error
+ // The component will fail on interaction due to non-null assertions in handlers
+ expect(() => {
+ renderDatasetConfig({
+ dataSets: [dataset],
+ datasetConfigsRef: undefined as any,
+ })
+ }).not.toThrow()
+
+ // The component currently expects datasetConfigsRef to exist for interactions
+ // This test documents the current behavior and requirements
+ })
+
+ it('should handle missing prompt_variables', () => {
+ // Context var is only shown when datasets exist AND there are prompt_variables
+ // Test with no datasets to ensure context var is not shown
+ renderDatasetConfig({
+ mode: AppModeEnum.COMPLETION,
+ dataSets: [],
+ modelConfig: {
+ configs: {
+ prompt_variables: [],
+ },
+ },
+ })
+
+ expect(screen.queryByTestId('context-var')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Component Integration', () => {
+ it('should integrate with card item component', () => {
+ const datasets = [
+ createMockDataset({ id: 'ds1', name: 'Dataset 1' }),
+ createMockDataset({ id: 'ds2', name: 'Dataset 2' }),
+ ]
+
+ renderDatasetConfig({
+ dataSets: datasets,
+ })
+
+ expect(screen.getByTestId('card-item-ds1')).toBeInTheDocument()
+ expect(screen.getByTestId('card-item-ds2')).toBeInTheDocument()
+ expect(screen.getByText('Dataset 1')).toBeInTheDocument()
+ expect(screen.getByText('Dataset 2')).toBeInTheDocument()
+ })
+
+ it('should integrate with params config component', () => {
+ const datasets = [
+ createMockDataset(),
+ createMockDataset({ id: 'ds2' }),
+ ]
+
+ renderDatasetConfig({
+ dataSets: datasets,
+ })
+
+ const paramsConfig = screen.getByTestId('params-config')
+ expect(paramsConfig).toBeInTheDocument()
+ expect(paramsConfig).toHaveTextContent('Params (2)')
+ expect(paramsConfig).not.toBeDisabled()
+ })
+
+ it('should integrate with metadata filter component', () => {
+ const datasets = [
+ createMockDataset({
+ doc_metadata: [
+ { name: 'category', type: 'string' } as any,
+ { name: 'tags', type: 'string' } as any,
+ ],
+ }),
+ createMockDataset({
+ id: 'ds2',
+ doc_metadata: [
+ { name: 'category', type: 'string' } as any,
+ { name: 'priority', type: 'number' } as any,
+ ],
+ }),
+ ]
+
+ renderDatasetConfig({
+ dataSets: datasets,
+ })
+
+ const metadataFilter = screen.getByTestId('metadata-filter')
+ expect(metadataFilter).toBeInTheDocument()
+ // Should show intersection (only 'category')
+ expect(screen.getByTestId('metadata-list-count')).toHaveTextContent('1')
+ })
+ })
+
+ describe('Model Configuration', () => {
+ it('should handle metadata model change', () => {
+ const dataset = createMockDataset()
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ datasetConfigs: {
+ ...mockConfigContext.datasetConfigs,
+ metadata_model_config: {
+ provider: 'openai',
+ name: 'gpt-3.5-turbo',
+ mode: AppModeEnum.CHAT,
+ completion_params: { temperature: 0.7 },
+ },
+ },
+ })
+
+ // The component would need to expose this functionality through the metadata filter
+ expect(screen.getByTestId('metadata-filter')).toBeInTheDocument()
+ })
+
+ it('should handle metadata completion params change', () => {
+ const dataset = createMockDataset()
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ datasetConfigs: {
+ ...mockConfigContext.datasetConfigs,
+ metadata_model_config: {
+ provider: 'openai',
+ name: 'gpt-3.5-turbo',
+ mode: AppModeEnum.CHAT,
+ completion_params: { temperature: 0.5, max_tokens: 100 },
+ },
+ },
+ })
+
+ expect(screen.getByTestId('metadata-filter')).toBeInTheDocument()
+ })
+ })
+
+ describe('Permission Handling', () => {
+ it('should hide edit options when user lacks permission', () => {
+ const { hasEditPermissionForDataset } = require('@/utils/permission')
+ hasEditPermissionForDataset.mockReturnValue(false)
+
+ const dataset = createMockDataset({
+ created_by: 'other-user',
+ permission: DatasetPermission.onlyMe,
+ })
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ // The editable property should be false when no permission
+ expect(screen.getByTestId(`card-item-${dataset.id}`)).toBeInTheDocument()
+ })
+
+ it('should show readonly state for non-editable datasets', () => {
+ const { hasEditPermissionForDataset } = require('@/utils/permission')
+ hasEditPermissionForDataset.mockReturnValue(false)
+
+ const dataset = createMockDataset({
+ created_by: 'admin',
+ permission: DatasetPermission.allTeamMembers,
+ })
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ expect(screen.getByTestId(`card-item-${dataset.id}`)).toBeInTheDocument()
+ })
+
+ it('should allow editing when user has partial member permission', () => {
+ const { hasEditPermissionForDataset } = require('@/utils/permission')
+ hasEditPermissionForDataset.mockReturnValue(true)
+
+ const dataset = createMockDataset({
+ created_by: 'admin',
+ permission: DatasetPermission.partialMembers,
+ partial_member_list: ['user-123'],
+ })
+
+ renderDatasetConfig({
+ dataSets: [dataset],
+ })
+
+ expect(screen.getByTestId(`card-item-${dataset.id}`)).toBeInTheDocument()
+ })
+ })
+
+ describe('Dataset Reordering and Management', () => {
+ it('should maintain dataset order after updates', () => {
+ const datasets = [
+ createMockDataset({ id: 'ds1', name: 'Dataset 1' }),
+ createMockDataset({ id: 'ds2', name: 'Dataset 2' }),
+ createMockDataset({ id: 'ds3', name: 'Dataset 3' }),
+ ]
+
+ renderDatasetConfig({
+ dataSets: datasets,
+ })
+
+ // Verify order is maintained
+ expect(screen.getByText('Dataset 1')).toBeInTheDocument()
+ expect(screen.getByText('Dataset 2')).toBeInTheDocument()
+ expect(screen.getByText('Dataset 3')).toBeInTheDocument()
+ })
+
+ it('should handle multiple dataset operations correctly', async () => {
+ const user = userEvent.setup()
+ const datasets = [
+ createMockDataset({ id: 'ds1', name: 'Dataset 1' }),
+ createMockDataset({ id: 'ds2', name: 'Dataset 2' }),
+ ]
+
+ renderDatasetConfig({
+ dataSets: datasets,
+ })
+
+ // Remove first dataset
+ const removeButton1 = screen.getAllByText('Remove')[0]
+ await user.click(removeButton1)
+
+ expect(mockConfigContext.setDataSets).toHaveBeenCalledWith([datasets[1]])
+ })
+ })
+
+ describe('Complex Configuration Scenarios', () => {
+ it('should handle multiple retrieval methods in configuration', () => {
+ const datasets = [
+ createMockDataset({
+ id: 'ds1',
+ retrieval_model: {
+ search_method: 'semantic_search' as any,
+ reranking_enable: true,
+ reranking_model: {
+ reranking_provider_name: 'cohere',
+ reranking_model_name: 'rerank-v3.5',
+ },
+ top_k: 5,
+ score_threshold_enabled: true,
+ score_threshold: 0.8,
+ },
+ }),
+ createMockDataset({
+ id: 'ds2',
+ retrieval_model: {
+ search_method: 'full_text_search' as any,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 3,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ },
+ }),
+ ]
+
+ renderDatasetConfig({
+ dataSets: datasets,
+ })
+
+ expect(screen.getByTestId('params-config')).toHaveTextContent('Params (2)')
+ })
+
+ it('should handle external knowledge base integration', () => {
+ const externalDataset = createMockDataset({
+ provider: 'notion',
+ external_knowledge_info: {
+ external_knowledge_id: 'notion-123',
+ external_knowledge_api_id: 'api-456',
+ external_knowledge_api_name: 'Notion Integration',
+ external_knowledge_api_endpoint: 'https://api.notion.com',
+ },
+ })
+
+ renderDatasetConfig({
+ dataSets: [externalDataset],
+ })
+
+ expect(screen.getByTestId(`card-item-${externalDataset.id}`)).toBeInTheDocument()
+ expect(screen.getByText(externalDataset.name)).toBeInTheDocument()
+ })
+ })
+
+ describe('Performance and Error Handling', () => {
+ it('should handle large dataset lists efficiently', () => {
+ // Create many datasets to test performance
+ const manyDatasets = Array.from({ length: 50 }, (_, i) =>
+ createMockDataset({
+ id: `ds-${i}`,
+ name: `Dataset ${i}`,
+ doc_metadata: [
+ { name: 'category', type: 'string' } as any,
+ { name: 'priority', type: 'number' } as any,
+ ],
+ }),
+ )
+
+ renderDatasetConfig({
+ dataSets: manyDatasets,
+ })
+
+ expect(screen.getByTestId('params-config')).toHaveTextContent('Params (50)')
+ })
+
+ it('should handle metadata intersection calculation efficiently', () => {
+ const datasets = [
+ createMockDataset({
+ id: 'ds1',
+ doc_metadata: [
+ { name: 'category', type: 'string' } as any,
+ { name: 'tags', type: 'string' } as any,
+ { name: 'priority', type: 'number' } as any,
+ ],
+ }),
+ createMockDataset({
+ id: 'ds2',
+ doc_metadata: [
+ { name: 'category', type: 'string' } as any,
+ { name: 'status', type: 'string' } as any,
+ { name: 'priority', type: 'number' } as any,
+ ],
+ }),
+ ]
+
+ renderDatasetConfig({
+ dataSets: datasets,
+ })
+
+ // Should calculate intersection correctly
+ expect(screen.getByTestId('metadata-filter')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx
new file mode 100644
index 0000000000..e44eba6c03
--- /dev/null
+++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx
@@ -0,0 +1,390 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ConfigContent from './config-content'
+import type { DataSet } from '@/models/datasets'
+import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
+import type { DatasetConfigs } from '@/models/debug'
+import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app'
+import type { RetrievalConfig } from '@/types/app'
+import Toast from '@/app/components/base/toast'
+import type { IndexingType } from '@/app/components/datasets/create/step-two'
+import {
+ useCurrentProviderAndModel,
+ useModelListAndDefaultModelAndCurrentProviderAndModel,
+} from '@/app/components/header/account-setting/model-provider-page/hooks'
+
+jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => {
+ type Props = {
+ defaultModel?: { provider: string; model: string }
+ onSelect?: (model: { provider: string; model: string }) => void
+ }
+
+ const MockModelSelector = ({ defaultModel, onSelect }: Props) => (
+ onSelect?.(defaultModel ?? { provider: 'mock-provider', model: 'mock-model' })}
+ >
+ Mock ModelSelector
+
+ )
+
+ return {
+ __esModule: true,
+ default: MockModelSelector,
+ }
+})
+
+jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(),
+ useCurrentProviderAndModel: jest.fn(),
+}))
+
+const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction
+const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction
+
+let toastNotifySpy: jest.SpyInstance
+
+const baseRetrievalConfig: RetrievalConfig = {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: 'provider',
+ reranking_model_name: 'rerank-model',
+ },
+ top_k: 4,
+ score_threshold_enabled: false,
+ score_threshold: 0,
+}
+
+const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType
+
+const createDataset = (overrides: Partial = {}): DataSet => {
+ const {
+ retrieval_model,
+ retrieval_model_dict,
+ icon_info,
+ ...restOverrides
+ } = overrides
+
+ const resolvedRetrievalModelDict = {
+ ...baseRetrievalConfig,
+ ...retrieval_model_dict,
+ }
+ const resolvedRetrievalModel = {
+ ...baseRetrievalConfig,
+ ...(retrieval_model ?? retrieval_model_dict),
+ }
+
+ const defaultIconInfo = {
+ icon: '📘',
+ icon_type: 'emoji',
+ icon_background: '#FFEAD5',
+ icon_url: '',
+ }
+
+ const resolvedIconInfo = ('icon_info' in overrides)
+ ? icon_info
+ : defaultIconInfo
+
+ return {
+ id: 'dataset-id',
+ name: 'Dataset Name',
+ indexing_status: 'completed',
+ icon_info: resolvedIconInfo as DataSet['icon_info'],
+ description: 'A test dataset',
+ permission: DatasetPermission.onlyMe,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: defaultIndexingTechnique,
+ author_name: 'author',
+ created_by: 'creator',
+ updated_by: 'updater',
+ updated_at: 0,
+ app_count: 0,
+ doc_form: ChunkingMode.text,
+ document_count: 0,
+ total_document_count: 0,
+ total_available_documents: 0,
+ word_count: 0,
+ provider: 'dify',
+ embedding_model: 'text-embedding',
+ embedding_model_provider: 'openai',
+ embedding_available: true,
+ retrieval_model_dict: resolvedRetrievalModelDict,
+ retrieval_model: resolvedRetrievalModel,
+ tags: [],
+ external_knowledge_info: {
+ external_knowledge_id: 'external-id',
+ external_knowledge_api_id: 'api-id',
+ external_knowledge_api_name: 'api-name',
+ external_knowledge_api_endpoint: 'https://endpoint',
+ },
+ external_retrieval_model: {
+ top_k: 2,
+ score_threshold: 0.5,
+ score_threshold_enabled: true,
+ },
+ built_in_field_enabled: true,
+ doc_metadata: [],
+ keyword_number: 3,
+ pipeline_id: 'pipeline-id',
+ is_published: true,
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+ ...restOverrides,
+ }
+}
+
+const createDatasetConfigs = (overrides: Partial = {}): DatasetConfigs => {
+ return {
+ retrieval_model: RETRIEVE_TYPE.multiWay,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 4,
+ score_threshold_enabled: false,
+ score_threshold: 0,
+ datasets: {
+ datasets: [],
+ },
+ reranking_mode: RerankingModeEnum.WeightedScore,
+ weights: {
+ weight_type: WeightedScoreEnum.Customized,
+ vector_setting: {
+ vector_weight: 0.5,
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding',
+ },
+ keyword_setting: {
+ keyword_weight: 0.5,
+ },
+ },
+ reranking_enable: false,
+ ...overrides,
+ }
+}
+
+describe('ConfigContent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ toastNotifySpy = jest.spyOn(Toast, 'notify').mockImplementation(() => ({}))
+ mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
+ modelList: [],
+ defaultModel: undefined,
+ currentProvider: undefined,
+ currentModel: undefined,
+ })
+ mockedUseCurrentProviderAndModel.mockReturnValue({
+ currentProvider: undefined,
+ currentModel: undefined,
+ })
+ })
+
+ afterEach(() => {
+ toastNotifySpy.mockRestore()
+ })
+
+ // State management
+ describe('Effects', () => {
+ it('should normalize oneWay retrieval mode to multiWay', async () => {
+ // Arrange
+ const onChange = jest.fn()
+ const datasetConfigs = createDatasetConfigs({ retrieval_model: RETRIEVE_TYPE.oneWay })
+
+ // Act
+ render( )
+
+ // Assert
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalled()
+ })
+ const [nextConfigs] = onChange.mock.calls[0]
+ expect(nextConfigs.retrieval_model).toBe(RETRIEVE_TYPE.multiWay)
+ })
+ })
+
+ // Rendering tests (REQUIRED)
+ describe('Rendering', () => {
+ it('should render weighted score panel when datasets are high-quality and consistent', () => {
+ // Arrange
+ const onChange = jest.fn()
+ const datasetConfigs = createDatasetConfigs({
+ reranking_mode: RerankingModeEnum.WeightedScore,
+ })
+ const selectedDatasets: DataSet[] = [
+ createDataset({
+ indexing_technique: 'high_quality' as IndexingType,
+ provider: 'dify',
+ embedding_model: 'text-embedding',
+ embedding_model_provider: 'openai',
+ retrieval_model_dict: {
+ ...baseRetrievalConfig,
+ search_method: RETRIEVE_METHOD.semantic,
+ },
+ }),
+ ]
+
+ // Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('dataset.weightedScore.title')).toBeInTheDocument()
+ expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
+ expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument()
+ expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument()
+ })
+ })
+
+ // User interactions
+ describe('User Interactions', () => {
+ it('should update weights when user changes weighted score slider', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+ const datasetConfigs = createDatasetConfigs({
+ reranking_mode: RerankingModeEnum.WeightedScore,
+ weights: {
+ weight_type: WeightedScoreEnum.Customized,
+ vector_setting: {
+ vector_weight: 0.5,
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding',
+ },
+ keyword_setting: {
+ keyword_weight: 0.5,
+ },
+ },
+ })
+ const selectedDatasets: DataSet[] = [
+ createDataset({
+ indexing_technique: 'high_quality' as IndexingType,
+ provider: 'dify',
+ embedding_model: 'text-embedding',
+ embedding_model_provider: 'openai',
+ retrieval_model_dict: {
+ ...baseRetrievalConfig,
+ search_method: RETRIEVE_METHOD.semantic,
+ },
+ }),
+ ]
+
+ // Act
+ render(
+ ,
+ )
+
+ const weightedScoreSlider = screen.getAllByRole('slider')
+ .find(slider => slider.getAttribute('aria-valuemax') === '1')
+ expect(weightedScoreSlider).toBeDefined()
+ await user.click(weightedScoreSlider!)
+ const callsBefore = onChange.mock.calls.length
+ await user.keyboard('{ArrowRight}')
+
+ // Assert
+ expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore)
+ const [nextConfigs] = onChange.mock.calls.at(-1) ?? []
+ expect(nextConfigs?.weights?.vector_setting.vector_weight).toBeCloseTo(0.6, 5)
+ expect(nextConfigs?.weights?.keyword_setting.keyword_weight).toBeCloseTo(0.4, 5)
+ })
+
+ it('should warn when switching to rerank model mode without a valid model', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+ const datasetConfigs = createDatasetConfigs({
+ reranking_mode: RerankingModeEnum.WeightedScore,
+ })
+ const selectedDatasets: DataSet[] = [
+ createDataset({
+ indexing_technique: 'high_quality' as IndexingType,
+ provider: 'dify',
+ embedding_model: 'text-embedding',
+ embedding_model_provider: 'openai',
+ retrieval_model_dict: {
+ ...baseRetrievalConfig,
+ search_method: RETRIEVE_METHOD.semantic,
+ },
+ }),
+ ]
+
+ // Act
+ render(
+ ,
+ )
+ await user.click(screen.getByText('common.modelProvider.rerankModel.key'))
+
+ // Assert
+ expect(toastNotifySpy).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'workflow.errorMsg.rerankModelRequired',
+ })
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ reranking_mode: RerankingModeEnum.RerankingModel,
+ }),
+ )
+ })
+
+ it('should warn when enabling rerank without a valid model in manual toggle mode', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const onChange = jest.fn()
+ const datasetConfigs = createDatasetConfigs({
+ reranking_enable: false,
+ })
+ const selectedDatasets: DataSet[] = [
+ createDataset({
+ indexing_technique: 'economy' as IndexingType,
+ provider: 'dify',
+ embedding_model: 'text-embedding',
+ embedding_model_provider: 'openai',
+ retrieval_model_dict: {
+ ...baseRetrievalConfig,
+ search_method: RETRIEVE_METHOD.semantic,
+ },
+ }),
+ ]
+
+ // Act
+ render(
+ ,
+ )
+ await user.click(screen.getByRole('switch'))
+
+ // Assert
+ expect(toastNotifySpy).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'workflow.errorMsg.rerankModelRequired',
+ })
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ reranking_enable: true,
+ }),
+ )
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx
index 8e06d6c901..c7a43fbfbd 100644
--- a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx
+++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx
@@ -20,7 +20,7 @@ import type {
DataSet,
} from '@/models/datasets'
import { RerankingModeEnum } from '@/models/datasets'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/hooks'
import Switch from '@/app/components/base/switch'
import Toast from '@/app/components/base/toast'
diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx
new file mode 100644
index 0000000000..c432ca68e2
--- /dev/null
+++ b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx
@@ -0,0 +1,265 @@
+import * as React from 'react'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ParamsConfig from './index'
+import ConfigContext from '@/context/debug-configuration'
+import type { DatasetConfigs } from '@/models/debug'
+import { RerankingModeEnum } from '@/models/datasets'
+import { RETRIEVE_TYPE } from '@/types/app'
+import Toast from '@/app/components/base/toast'
+import {
+ useCurrentProviderAndModel,
+ useModelListAndDefaultModelAndCurrentProviderAndModel,
+} from '@/app/components/header/account-setting/model-provider-page/hooks'
+
+jest.mock('@headlessui/react', () => ({
+ Dialog: ({ children, className }: { children: React.ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+ DialogPanel: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+ DialogTitle: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+ Transition: ({ show, children }: { show: boolean; children: React.ReactNode }) => (show ? <>{children}> : null),
+ TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ Switch: ({ checked, onChange, children, ...props }: { checked: boolean; onChange?: (value: boolean) => void; children?: React.ReactNode }) => (
+ onChange?.(!checked)}
+ {...props}
+ >
+ {children}
+
+ ),
+}))
+
+jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(),
+ useCurrentProviderAndModel: jest.fn(),
+}))
+
+jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => {
+ type Props = {
+ defaultModel?: { provider: string; model: string }
+ onSelect?: (model: { provider: string; model: string }) => void
+ }
+
+ const MockModelSelector = ({ defaultModel, onSelect }: Props) => (
+ onSelect?.(defaultModel ?? { provider: 'mock-provider', model: 'mock-model' })}
+ >
+ Mock ModelSelector
+
+ )
+
+ return {
+ __esModule: true,
+ default: MockModelSelector,
+ }
+})
+
+jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
+ __esModule: true,
+ default: () =>
,
+}))
+
+const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction
+const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction
+let toastNotifySpy: jest.SpyInstance
+
+const createDatasetConfigs = (overrides: Partial = {}): DatasetConfigs => {
+ return {
+ retrieval_model: RETRIEVE_TYPE.multiWay,
+ reranking_model: {
+ reranking_provider_name: 'provider',
+ reranking_model_name: 'rerank-model',
+ },
+ top_k: 4,
+ score_threshold_enabled: false,
+ score_threshold: 0,
+ datasets: {
+ datasets: [],
+ },
+ reranking_enable: false,
+ reranking_mode: RerankingModeEnum.RerankingModel,
+ ...overrides,
+ }
+}
+
+const renderParamsConfig = ({
+ datasetConfigs = createDatasetConfigs(),
+ initialModalOpen = false,
+ disabled,
+}: {
+ datasetConfigs?: DatasetConfigs
+ initialModalOpen?: boolean
+ disabled?: boolean
+} = {}) => {
+ const Wrapper = ({ children }: { children: React.ReactNode }) => {
+ const [datasetConfigsState, setDatasetConfigsState] = React.useState(datasetConfigs)
+ const [modalOpen, setModalOpen] = React.useState(initialModalOpen)
+
+ const contextValue = {
+ datasetConfigs: datasetConfigsState,
+ setDatasetConfigs: (next: DatasetConfigs) => {
+ setDatasetConfigsState(next)
+ },
+ rerankSettingModalOpen: modalOpen,
+ setRerankSettingModalOpen: (open: boolean) => {
+ setModalOpen(open)
+ },
+ } as unknown as React.ComponentProps['value']
+
+ return (
+
+ {children}
+
+ )
+ }
+
+ return render(
+ ,
+ { wrapper: Wrapper },
+ )
+}
+
+describe('dataset-config/params-config', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ jest.useRealTimers()
+ toastNotifySpy = jest.spyOn(Toast, 'notify').mockImplementation(() => ({}))
+ mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
+ modelList: [],
+ defaultModel: undefined,
+ currentProvider: undefined,
+ currentModel: undefined,
+ })
+ mockedUseCurrentProviderAndModel.mockReturnValue({
+ currentProvider: undefined,
+ currentModel: undefined,
+ })
+ })
+
+ afterEach(() => {
+ toastNotifySpy.mockRestore()
+ })
+
+ // Rendering tests (REQUIRED)
+ describe('Rendering', () => {
+ it('should disable settings trigger when disabled is true', () => {
+ // Arrange
+ renderParamsConfig({ disabled: true })
+
+ // Assert
+ expect(screen.getByRole('button', { name: 'dataset.retrievalSettings' })).toBeDisabled()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should open modal and persist changes when save is clicked', async () => {
+ // Arrange
+ renderParamsConfig()
+ const user = userEvent.setup()
+
+ // Act
+ await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
+ const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
+ const dialogScope = within(dialog)
+
+ const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
+ await user.click(incrementButtons[0])
+
+ await waitFor(() => {
+ const [topKInput] = dialogScope.getAllByRole('spinbutton')
+ expect(topKInput).toHaveValue(5)
+ })
+
+ await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
+ const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
+ const reopenedScope = within(reopenedDialog)
+ const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
+
+ // Assert
+ expect(reopenedTopKInput).toHaveValue(5)
+ })
+
+ it('should discard changes when cancel is clicked', async () => {
+ // Arrange
+ renderParamsConfig()
+ const user = userEvent.setup()
+
+ // Act
+ await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
+ const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
+ const dialogScope = within(dialog)
+
+ const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
+ await user.click(incrementButtons[0])
+
+ await waitFor(() => {
+ const [topKInput] = dialogScope.getAllByRole('spinbutton')
+ expect(topKInput).toHaveValue(5)
+ })
+
+ const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' })
+ await user.click(cancelButton)
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ // Re-open and verify the original value remains.
+ await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
+ const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
+ const reopenedScope = within(reopenedDialog)
+ const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
+
+ // Assert
+ expect(reopenedTopKInput).toHaveValue(4)
+ })
+
+ it('should prevent saving when rerank model is required but invalid', async () => {
+ // Arrange
+ renderParamsConfig({
+ datasetConfigs: createDatasetConfigs({
+ reranking_enable: true,
+ reranking_mode: RerankingModeEnum.RerankingModel,
+ }),
+ initialModalOpen: true,
+ })
+ const user = userEvent.setup()
+
+ // Act
+ const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
+ const dialogScope = within(dialog)
+ await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
+
+ // Assert
+ expect(toastNotifySpy).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'appDebug.datasetConfig.rerankModelRequired',
+ })
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.tsx
index df2b4293c4..24da958217 100644
--- a/web/app/components/app/configuration/dataset-config/params-config/index.tsx
+++ b/web/app/components/app/configuration/dataset-config/params-config/index.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiEqualizer2Line } from '@remixicon/react'
import ConfigContent from './config-content'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import ConfigContext from '@/context/debug-configuration'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
diff --git a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx
new file mode 100644
index 0000000000..e7b1eb8421
--- /dev/null
+++ b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx
@@ -0,0 +1,81 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import WeightedScore from './weighted-score'
+
+describe('WeightedScore', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // Rendering tests (REQUIRED)
+ describe('Rendering', () => {
+ it('should render semantic and keyword weights', () => {
+ // Arrange
+ const onChange = jest.fn()
+ const value = { value: [0.3, 0.7] }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument()
+ expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument()
+ expect(screen.getByText('0.3')).toBeInTheDocument()
+ expect(screen.getByText('0.7')).toBeInTheDocument()
+ })
+
+ it('should format a weight of 1 as 1.0', () => {
+ // Arrange
+ const onChange = jest.fn()
+ const value = { value: [1, 0] }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('1.0')).toBeInTheDocument()
+ expect(screen.getByText('0')).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should emit complementary weights when the slider value changes', async () => {
+ // Arrange
+ const onChange = jest.fn()
+ const value = { value: [0.5, 0.5] }
+ const user = userEvent.setup()
+ render( )
+
+ // Act
+ await user.tab()
+ const slider = screen.getByRole('slider')
+ expect(slider).toHaveFocus()
+ const callsBefore = onChange.mock.calls.length
+ await user.keyboard('{ArrowRight}')
+
+ // Assert
+ expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore)
+ const lastCall = onChange.mock.calls.at(-1)?.[0]
+ expect(lastCall?.value[0]).toBeCloseTo(0.6, 5)
+ expect(lastCall?.value[1]).toBeCloseTo(0.4, 5)
+ })
+
+ it('should not call onChange when readonly is true', async () => {
+ // Arrange
+ const onChange = jest.fn()
+ const value = { value: [0.5, 0.5] }
+ const user = userEvent.setup()
+ render( )
+
+ // Act
+ await user.tab()
+ const slider = screen.getByRole('slider')
+ expect(slider).toHaveFocus()
+ await user.keyboard('{ArrowRight}')
+
+ // Assert
+ expect(onChange).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx
index ebfa3b1e12..459623104d 100644
--- a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx
+++ b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx
@@ -2,7 +2,7 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import './weighted-score.css'
import Slider from '@/app/components/base/slider'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { noop } from 'lodash-es'
const formatNumber = (value: number) => {
diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx
index 6857c38e1e..f02fdcb5d7 100644
--- a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx
+++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx
@@ -10,7 +10,7 @@ import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import Badge from '@/app/components/base/badge'
import { useKnowledge } from '@/hooks/use-knowledge'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import AppIcon from '@/app/components/base/app-icon'
import { useInfiniteDatasets } from '@/service/knowledge/use-dataset'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx
new file mode 100644
index 0000000000..08db7186ec
--- /dev/null
+++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx
@@ -0,0 +1,473 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import SettingsModal from './index'
+import { ToastContext } from '@/app/components/base/toast'
+import type { DataSet } from '@/models/datasets'
+import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum } from '@/models/datasets'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { updateDatasetSetting } from '@/service/datasets'
+import { fetchMembers } from '@/service/common'
+import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app'
+
+const mockNotify = jest.fn()
+const mockOnCancel = jest.fn()
+const mockOnSave = jest.fn()
+const mockSetShowAccountSettingModal = jest.fn()
+let mockIsWorkspaceDatasetOperator = false
+
+const mockUseModelList = jest.fn()
+const mockUseModelListAndDefaultModel = jest.fn()
+const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = jest.fn()
+const mockUseCurrentProviderAndModel = jest.fn()
+const mockCheckShowMultiModalTip = jest.fn()
+
+jest.mock('ky', () => {
+ const ky = () => ky
+ ky.extend = () => ky
+ ky.create = () => ky
+ return { __esModule: true, default: ky }
+})
+
+jest.mock('@/app/components/datasets/create/step-two', () => ({
+ __esModule: true,
+ IndexingType: {
+ QUALIFIED: 'high_quality',
+ ECONOMICAL: 'economy',
+ },
+}))
+
+jest.mock('@/service/datasets', () => ({
+ updateDatasetSetting: jest.fn(),
+}))
+
+jest.mock('@/service/common', () => ({
+ fetchMembers: jest.fn(),
+}))
+
+jest.mock('@/context/app-context', () => ({
+ useAppContext: () => ({ isCurrentWorkspaceDatasetOperator: mockIsWorkspaceDatasetOperator }),
+ useSelector: (selector: (value: { userProfile: { id: string; name: string; email: string; avatar_url: string } }) => T) => selector({
+ userProfile: {
+ id: 'user-1',
+ name: 'User One',
+ email: 'user@example.com',
+ avatar_url: 'avatar.png',
+ },
+ }),
+}))
+
+jest.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowAccountSettingModal: mockSetShowAccountSettingModal,
+ }),
+}))
+
+jest.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs${path}`,
+}))
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ modelProviders: [],
+ textGenerationModelList: [],
+ supportRetrievalMethods: [
+ RETRIEVE_METHOD.semantic,
+ RETRIEVE_METHOD.fullText,
+ RETRIEVE_METHOD.hybrid,
+ RETRIEVE_METHOD.keywordSearch,
+ ],
+ }),
+}))
+
+jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ __esModule: true,
+ useModelList: (...args: unknown[]) => mockUseModelList(...args),
+ useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args),
+ useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
+ mockUseModelListAndDefaultModelAndCurrentProviderAndModel(...args),
+ useCurrentProviderAndModel: (...args: unknown[]) => mockUseCurrentProviderAndModel(...args),
+}))
+
+jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
+ __esModule: true,
+ default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => (
+
+ {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
+
+ ),
+}))
+
+jest.mock('@/app/components/datasets/settings/utils', () => ({
+ checkShowMultiModalTip: (...args: unknown[]) => mockCheckShowMultiModalTip(...args),
+}))
+
+const mockUpdateDatasetSetting = updateDatasetSetting as jest.MockedFunction
+const mockFetchMembers = fetchMembers as jest.MockedFunction
+
+const createRetrievalConfig = (overrides: Partial = {}): RetrievalConfig => ({
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 2,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ reranking_mode: RerankingModeEnum.RerankingModel,
+ ...overrides,
+})
+
+const createDataset = (overrides: Partial = {}, retrievalOverrides: Partial = {}): DataSet => {
+ const retrievalConfig = createRetrievalConfig(retrievalOverrides)
+ return {
+ id: 'dataset-id',
+ name: 'Test Dataset',
+ indexing_status: 'completed',
+ icon_info: {
+ icon: 'icon',
+ icon_type: 'emoji',
+ },
+ description: 'Description',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ author_name: 'Author',
+ created_by: 'creator',
+ updated_by: 'updater',
+ updated_at: 1700000000,
+ app_count: 0,
+ doc_form: ChunkingMode.text,
+ document_count: 0,
+ total_document_count: 0,
+ total_available_documents: 0,
+ word_count: 0,
+ provider: 'internal',
+ embedding_model: 'embed-model',
+ embedding_model_provider: 'embed-provider',
+ embedding_available: true,
+ tags: [],
+ partial_member_list: [],
+ external_knowledge_info: {
+ external_knowledge_id: 'ext-id',
+ external_knowledge_api_id: 'ext-api-id',
+ external_knowledge_api_name: 'External API',
+ external_knowledge_api_endpoint: 'https://api.example.com',
+ },
+ external_retrieval_model: {
+ top_k: 2,
+ score_threshold: 0.5,
+ score_threshold_enabled: false,
+ },
+ built_in_field_enabled: false,
+ doc_metadata: [],
+ keyword_number: 10,
+ pipeline_id: 'pipeline-id',
+ is_published: false,
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+ ...overrides,
+ retrieval_model_dict: {
+ ...retrievalConfig,
+ ...overrides.retrieval_model_dict,
+ },
+ retrieval_model: {
+ ...retrievalConfig,
+ ...overrides.retrieval_model,
+ },
+ }
+}
+
+const renderWithProviders = (dataset: DataSet) => {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('SettingsModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockIsWorkspaceDatasetOperator = false
+ mockUseModelList.mockImplementation((type: ModelTypeEnum) => {
+ if (type === ModelTypeEnum.rerank) {
+ return {
+ data: [
+ {
+ provider: 'rerank-provider',
+ models: [{ model: 'rerank-model' }],
+ },
+ ],
+ }
+ }
+ return { data: [{ provider: 'embed-provider', models: [{ model: 'embed-model' }] }] }
+ })
+ mockUseModelListAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null })
+ mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null })
+ mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null })
+ mockCheckShowMultiModalTip.mockReturnValue(false)
+ mockFetchMembers.mockResolvedValue({
+ accounts: [
+ {
+ id: 'user-1',
+ name: 'User One',
+ email: 'user@example.com',
+ avatar: 'avatar.png',
+ avatar_url: 'avatar.png',
+ status: 'active',
+ role: 'owner',
+ },
+ {
+ id: 'member-2',
+ name: 'Member Two',
+ email: 'member@example.com',
+ avatar: 'avatar.png',
+ avatar_url: 'avatar.png',
+ status: 'active',
+ role: 'editor',
+ },
+ ],
+ })
+ mockUpdateDatasetSetting.mockResolvedValue(createDataset())
+ })
+
+ it('renders dataset details', async () => {
+ renderWithProviders(createDataset())
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+
+ expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue('Test Dataset')
+ expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue('Description')
+ })
+
+ it('calls onCancel when cancel is clicked', async () => {
+ renderWithProviders(createDataset())
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+
+ await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+
+ expect(mockOnCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('shows external knowledge info for external datasets', async () => {
+ const dataset = createDataset({
+ provider: 'external',
+ external_knowledge_info: {
+ external_knowledge_id: 'ext-id-123',
+ external_knowledge_api_id: 'ext-api-id-123',
+ external_knowledge_api_name: 'External Knowledge API',
+ external_knowledge_api_endpoint: 'https://api.external.com',
+ },
+ })
+
+ renderWithProviders(dataset)
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+
+ expect(screen.getByText('External Knowledge API')).toBeInTheDocument()
+ expect(screen.getByText('https://api.external.com')).toBeInTheDocument()
+ expect(screen.getByText('ext-id-123')).toBeInTheDocument()
+ })
+
+ it('updates name when user types', async () => {
+ renderWithProviders(createDataset())
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+
+ const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
+ await userEvent.clear(nameInput)
+ await userEvent.type(nameInput, 'New Dataset Name')
+
+ expect(nameInput).toHaveValue('New Dataset Name')
+ })
+
+ it('updates description when user types', async () => {
+ renderWithProviders(createDataset())
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+
+ const descriptionInput = screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')
+ await userEvent.clear(descriptionInput)
+ await userEvent.type(descriptionInput, 'New description')
+
+ expect(descriptionInput).toHaveValue('New description')
+ })
+
+ it('shows and dismisses retrieval change tip when index method changes', async () => {
+ const dataset = createDataset({ indexing_technique: IndexingType.ECONOMICAL })
+
+ renderWithProviders(dataset)
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+
+ await userEvent.click(screen.getByText('datasetCreation.stepTwo.qualified'))
+
+ expect(await screen.findByText('appDebug.datasetConfig.retrieveChangeTip')).toBeInTheDocument()
+
+ await userEvent.click(screen.getByLabelText('close-retrieval-change-tip'))
+
+ await waitFor(() => {
+ expect(screen.queryByText('appDebug.datasetConfig.retrieveChangeTip')).not.toBeInTheDocument()
+ })
+ })
+
+ it('requires dataset name before saving', async () => {
+ renderWithProviders(createDataset())
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+
+ const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
+ await userEvent.clear(nameInput)
+ await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'datasetSettings.form.nameError',
+ }))
+ expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
+ })
+
+ it('requires rerank model when reranking is enabled', async () => {
+ mockUseModelList.mockReturnValue({ data: [] })
+ const dataset = createDataset({}, createRetrievalConfig({
+ reranking_enable: true,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ }))
+
+ renderWithProviders(dataset)
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+ await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'appDebug.datasetConfig.rerankModelRequired',
+ }))
+ expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
+ })
+
+ it('saves internal dataset changes', async () => {
+ const rerankRetrieval = createRetrievalConfig({
+ reranking_enable: true,
+ reranking_model: {
+ reranking_provider_name: 'rerank-provider',
+ reranking_model_name: 'rerank-model',
+ },
+ })
+ const dataset = createDataset({
+ retrieval_model: rerankRetrieval,
+ retrieval_model_dict: rerankRetrieval,
+ })
+
+ renderWithProviders(dataset)
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+
+ const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
+ await userEvent.clear(nameInput)
+ await userEvent.type(nameInput, 'Updated Internal Dataset')
+ await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled())
+
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({
+ body: expect.objectContaining({
+ name: 'Updated Internal Dataset',
+ permission: DatasetPermission.allTeamMembers,
+ }),
+ }))
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'success',
+ message: 'common.actionMsg.modifiedSuccessfully',
+ }))
+ expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
+ name: 'Updated Internal Dataset',
+ retrieval_model_dict: expect.objectContaining({
+ reranking_enable: true,
+ }),
+ }))
+ })
+
+ it('saves external dataset with partial members and updated retrieval params', async () => {
+ const dataset = createDataset({
+ provider: 'external',
+ permission: DatasetPermission.partialMembers,
+ partial_member_list: ['member-2'],
+ external_retrieval_model: {
+ top_k: 5,
+ score_threshold: 0.3,
+ score_threshold_enabled: true,
+ },
+ }, {
+ score_threshold_enabled: true,
+ score_threshold: 0.8,
+ })
+
+ renderWithProviders(dataset)
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+
+ await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled())
+
+ expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({
+ body: expect.objectContaining({
+ permission: DatasetPermission.partialMembers,
+ external_retrieval_model: expect.objectContaining({
+ top_k: 5,
+ }),
+ partial_member_list: [
+ {
+ user_id: 'member-2',
+ role: 'editor',
+ },
+ ],
+ }),
+ }))
+ expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
+ retrieval_model_dict: expect.objectContaining({
+ score_threshold_enabled: true,
+ score_threshold: 0.8,
+ }),
+ }))
+ })
+
+ it('disables save button while saving', async () => {
+ mockUpdateDatasetSetting.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
+
+ renderWithProviders(createDataset())
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+
+ const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
+ await userEvent.click(saveButton)
+
+ expect(saveButton).toBeDisabled()
+ })
+
+ it('shows error toast when save fails', async () => {
+ mockUpdateDatasetSetting.mockRejectedValue(new Error('API Error'))
+
+ renderWithProviders(createDataset())
+
+ await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
+
+ await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx
index cd6e39011e..8c3e753b22 100644
--- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx
+++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx
@@ -4,10 +4,8 @@ import { useMount } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { isEqual } from 'lodash-es'
import { RiCloseLine } from '@remixicon/react'
-import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import IndexMethod from '@/app/components/datasets/settings/index-method'
-import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
@@ -18,11 +16,7 @@ import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import type { RetrievalConfig } from '@/types/app'
-import RetrievalSettings from '@/app/components/datasets/external-knowledge-base/create/RetrievalSettings'
-import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
-import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
-import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import PermissionSelector from '@/app/components/datasets/settings/permission-selector'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
@@ -32,6 +26,7 @@ import type { Member } from '@/models/common'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { useDocLink } from '@/context/i18n'
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
+import { RetrievalChangeTip, RetrievalSection } from './retrieval-section'
type SettingsModalProps = {
currentDataset: DataSet
@@ -298,92 +293,37 @@ const SettingsModal: FC = ({
)}
{/* Retrieval Method Config */}
- {currentDataset?.provider === 'external'
- ? <>
-
-
-
-
{t('datasetSettings.form.retrievalSetting.title')}
-
-
-
-
-
-
-
{t('datasetSettings.form.externalKnowledgeAPI')}
-
-
-
-
-
- {currentDataset?.external_knowledge_info.external_knowledge_api_name}
-
-
·
-
{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}
-
-
-
-
-
-
{t('datasetSettings.form.externalKnowledgeID')}
-
-
-
-
{currentDataset?.external_knowledge_info.external_knowledge_id}
-
-
-
-
- >
- :
-
-
-
{t('datasetSettings.form.retrievalSetting.title')}
-
-
-
-
- {indexMethod === IndexingType.QUALIFIED
- ? (
-
- )
- : (
-
- )}
-
-
}
+ {isExternal ? (
+
+ ) : (
+
+ )}
- {isRetrievalChanged && !isHideChangedTip && (
-
-
-
-
{t('appDebug.datasetConfig.retrieveChangeTip')}
-
-
{
- setIsHideChangedTip(true)
- e.stopPropagation()
- e.nativeEvent.stopImmediatePropagation()
- }}>
-
-
-
- )}
+
setIsHideChangedTip(true)}
+ />
{
+ const ky = () => ky
+ ky.extend = () => ky
+ ky.create = () => ky
+ return { __esModule: true, default: ky }
+})
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ modelProviders: [],
+ textGenerationModelList: [],
+ supportRetrievalMethods: [
+ RETRIEVE_METHOD.semantic,
+ RETRIEVE_METHOD.fullText,
+ RETRIEVE_METHOD.hybrid,
+ RETRIEVE_METHOD.keywordSearch,
+ ],
+ }),
+}))
+
+jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ __esModule: true,
+ useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
+ mockUseModelListAndDefaultModelAndCurrentProviderAndModel(...args),
+ useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args),
+ useModelList: (...args: unknown[]) => mockUseModelList(...args),
+ useCurrentProviderAndModel: (...args: unknown[]) => mockUseCurrentProviderAndModel(...args),
+}))
+
+jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
+ __esModule: true,
+ default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => (
+
+ {defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
+
+ ),
+}))
+
+jest.mock('@/app/components/datasets/create/step-two', () => ({
+ __esModule: true,
+ IndexingType: {
+ QUALIFIED: 'high_quality',
+ ECONOMICAL: 'economy',
+ },
+}))
+
+const createRetrievalConfig = (overrides: Partial
= {}): RetrievalConfig => ({
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 2,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ reranking_mode: RerankingModeEnum.RerankingModel,
+ ...overrides,
+})
+
+const createDataset = (overrides: Partial = {}, retrievalOverrides: Partial = {}): DataSet => {
+ const retrievalConfig = createRetrievalConfig(retrievalOverrides)
+ return {
+ id: 'dataset-id',
+ name: 'Test Dataset',
+ indexing_status: 'completed',
+ icon_info: {
+ icon: 'icon',
+ icon_type: 'emoji',
+ },
+ description: 'Description',
+ permission: DatasetPermission.allTeamMembers,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingType.QUALIFIED,
+ author_name: 'Author',
+ created_by: 'creator',
+ updated_by: 'updater',
+ updated_at: 1700000000,
+ app_count: 0,
+ doc_form: ChunkingMode.text,
+ document_count: 0,
+ total_document_count: 0,
+ total_available_documents: 0,
+ word_count: 0,
+ provider: 'internal',
+ embedding_model: 'embed-model',
+ embedding_model_provider: 'embed-provider',
+ embedding_available: true,
+ tags: [],
+ partial_member_list: [],
+ external_knowledge_info: {
+ external_knowledge_id: 'ext-id',
+ external_knowledge_api_id: 'ext-api-id',
+ external_knowledge_api_name: 'External API',
+ external_knowledge_api_endpoint: 'https://api.example.com',
+ },
+ external_retrieval_model: {
+ top_k: 2,
+ score_threshold: 0.5,
+ score_threshold_enabled: false,
+ },
+ built_in_field_enabled: false,
+ doc_metadata: [],
+ keyword_number: 10,
+ pipeline_id: 'pipeline-id',
+ is_published: false,
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+ ...overrides,
+ retrieval_model_dict: {
+ ...retrievalConfig,
+ ...overrides.retrieval_model_dict,
+ },
+ retrieval_model: {
+ ...retrievalConfig,
+ ...overrides.retrieval_model,
+ },
+ }
+}
+
+describe('RetrievalChangeTip', () => {
+ const defaultProps = {
+ visible: true,
+ message: 'Test message',
+ onDismiss: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('renders and supports dismiss', async () => {
+ // Arrange
+ const onDismiss = jest.fn()
+ render( )
+
+ // Act
+ await userEvent.click(screen.getByRole('button', { name: 'close-retrieval-change-tip' }))
+
+ // Assert
+ expect(screen.getByText('Test message')).toBeInTheDocument()
+ expect(onDismiss).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not render when hidden', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.queryByText('Test message')).not.toBeInTheDocument()
+ })
+})
+
+describe('RetrievalSection', () => {
+ const t = (key: string) => key
+ const rowClass = 'row'
+ const labelClass = 'label'
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseModelList.mockImplementation((type: ModelTypeEnum) => {
+ if (type === ModelTypeEnum.rerank)
+ return { data: [{ provider: 'rerank-provider', models: [{ model: 'rerank-model' }] }] }
+ return { data: [] }
+ })
+ mockUseModelListAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null })
+ mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null })
+ mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null })
+ })
+
+ it('renders external retrieval details and propagates changes', async () => {
+ // Arrange
+ const dataset = createDataset({
+ provider: 'external',
+ external_knowledge_info: {
+ external_knowledge_id: 'ext-id-999',
+ external_knowledge_api_id: 'ext-api-id-999',
+ external_knowledge_api_name: 'External API',
+ external_knowledge_api_endpoint: 'https://api.external.com',
+ },
+ })
+ const handleExternalChange = jest.fn()
+
+ // Act
+ render(
+ ,
+ )
+ const [topKIncrement] = screen.getAllByLabelText('increment')
+ await userEvent.click(topKIncrement)
+
+ // Assert
+ expect(screen.getByText('External API')).toBeInTheDocument()
+ expect(screen.getByText('https://api.external.com')).toBeInTheDocument()
+ expect(screen.getByText('ext-id-999')).toBeInTheDocument()
+ expect(handleExternalChange).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 }))
+ })
+
+ it('renders internal retrieval config with doc link', () => {
+ // Arrange
+ const docLink = jest.fn((path: string) => `https://docs.example${path}`)
+ const retrievalConfig = createRetrievalConfig()
+
+ // Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
+ const learnMoreLink = screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' })
+ expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')
+ expect(docLink).toHaveBeenCalledWith('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')
+ })
+
+ it('propagates retrieval config changes for economical indexing', async () => {
+ // Arrange
+ const handleRetrievalChange = jest.fn()
+
+ // Act
+ render(
+ path}
+ />,
+ )
+ const [topKIncrement] = screen.getAllByLabelText('increment')
+ await userEvent.click(topKIncrement)
+
+ // Assert
+ expect(screen.getByText('dataset.retrieval.keyword_search.title')).toBeInTheDocument()
+ expect(handleRetrievalChange).toHaveBeenCalledWith(expect.objectContaining({
+ top_k: 3,
+ }))
+ })
+})
diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx
new file mode 100644
index 0000000000..99d042f681
--- /dev/null
+++ b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx
@@ -0,0 +1,218 @@
+import { RiCloseLine } from '@remixicon/react'
+import type { FC } from 'react'
+import { cn } from '@/utils/classnames'
+import Divider from '@/app/components/base/divider'
+import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
+import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
+import RetrievalSettings from '@/app/components/datasets/external-knowledge-base/create/RetrievalSettings'
+import type { DataSet } from '@/models/datasets'
+import { IndexingType } from '@/app/components/datasets/create/step-two'
+import type { RetrievalConfig } from '@/types/app'
+import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
+import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
+
+type CommonSectionProps = {
+ rowClass: string
+ labelClass: string
+ t: (key: string, options?: any) => string
+}
+
+type ExternalRetrievalSectionProps = CommonSectionProps & {
+ topK: number
+ scoreThreshold: number
+ scoreThresholdEnabled: boolean
+ onExternalSettingChange: (data: { top_k?: number; score_threshold?: number; score_threshold_enabled?: boolean }) => void
+ currentDataset: DataSet
+}
+
+const ExternalRetrievalSection: FC = ({
+ rowClass,
+ labelClass,
+ t,
+ topK,
+ scoreThreshold,
+ scoreThresholdEnabled,
+ onExternalSettingChange,
+ currentDataset,
+}) => (
+ <>
+
+
+
+
{t('datasetSettings.form.retrievalSetting.title')}
+
+
+
+
+
+
+
{t('datasetSettings.form.externalKnowledgeAPI')}
+
+
+
+
+
+ {currentDataset?.external_knowledge_info.external_knowledge_api_name}
+
+
·
+
{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}
+
+
+
+
+
+
{t('datasetSettings.form.externalKnowledgeID')}
+
+
+
+
{currentDataset?.external_knowledge_info.external_knowledge_id}
+
+
+
+
+ >
+)
+
+type InternalRetrievalSectionProps = CommonSectionProps & {
+ indexMethod: IndexingType
+ retrievalConfig: RetrievalConfig
+ showMultiModalTip: boolean
+ onRetrievalConfigChange: (value: RetrievalConfig) => void
+ docLink: (path: string) => string
+}
+
+const InternalRetrievalSection: FC = ({
+ rowClass,
+ labelClass,
+ t,
+ indexMethod,
+ retrievalConfig,
+ showMultiModalTip,
+ onRetrievalConfigChange,
+ docLink,
+}) => (
+
+
+
+
{t('datasetSettings.form.retrievalSetting.title')}
+
+
+
+
+ {indexMethod === IndexingType.QUALIFIED
+ ? (
+
+ )
+ : (
+
+ )}
+
+
+)
+
+type RetrievalSectionProps
+ = | (ExternalRetrievalSectionProps & { isExternal: true })
+ | (InternalRetrievalSectionProps & { isExternal: false })
+
+export const RetrievalSection: FC = (props) => {
+ if (props.isExternal) {
+ const {
+ rowClass,
+ labelClass,
+ t,
+ topK,
+ scoreThreshold,
+ scoreThresholdEnabled,
+ onExternalSettingChange,
+ currentDataset,
+ } = props
+
+ return (
+
+ )
+ }
+
+ const {
+ rowClass,
+ labelClass,
+ t,
+ indexMethod,
+ retrievalConfig,
+ showMultiModalTip,
+ onRetrievalConfigChange,
+ docLink,
+ } = props
+
+ return (
+
+ )
+}
+
+type RetrievalChangeTipProps = {
+ visible: boolean
+ message: string
+ onDismiss: () => void
+}
+
+export const RetrievalChangeTip: FC = ({
+ visible,
+ message,
+ onDismiss,
+}) => {
+ if (!visible)
+ return null
+
+ return (
+
+
+
{
+ onDismiss()
+ event.stopPropagation()
+ }}
+ aria-label='close-retrieval-change-tip'
+ >
+
+
+
+ )
+}
diff --git a/web/app/components/app/configuration/debug/chat-user-input.tsx b/web/app/components/app/configuration/debug/chat-user-input.tsx
index 16666d514e..c25bed548c 100644
--- a/web/app/components/app/configuration/debug/chat-user-input.tsx
+++ b/web/app/components/app/configuration/debug/chat-user-input.tsx
@@ -7,7 +7,7 @@ import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
import type { Inputs } from '@/models/debug'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
type Props = {
diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx
new file mode 100644
index 0000000000..676456c3ea
--- /dev/null
+++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx
@@ -0,0 +1,1011 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { type ReactNode, type RefObject, createRef } from 'react'
+import DebugWithSingleModel from './index'
+import type { DebugWithSingleModelRefType } from './index'
+import type { ChatItem } from '@/app/components/base/chat/types'
+import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import type { ProviderContextState } from '@/context/provider-context'
+import type { DatasetConfigs, ModelConfig } from '@/models/debug'
+import { PromptMode } from '@/models/debug'
+import { type Collection, CollectionType } from '@/app/components/tools/types'
+import type { FileEntity } from '@/app/components/base/file-uploader/types'
+import { AgentStrategy, AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app'
+
+// ============================================================================
+// Test Data Factories (Following testing.md guidelines)
+// ============================================================================
+
+/**
+ * Factory function for creating mock ModelConfig with type safety
+ */
+function createMockModelConfig(overrides: Partial = {}): ModelConfig {
+ return {
+ provider: 'openai',
+ model_id: 'gpt-3.5-turbo',
+ mode: ModelModeType.chat,
+ configs: {
+ prompt_template: 'Test template',
+ prompt_variables: [
+ { key: 'var1', name: 'Variable 1', type: 'text', required: false },
+ ],
+ },
+ chat_prompt_config: {
+ prompt: [],
+ },
+ completion_prompt_config: {
+ prompt: { text: '' },
+ conversation_histories_role: {
+ user_prefix: 'user',
+ assistant_prefix: 'assistant',
+ },
+ },
+ more_like_this: null,
+ opening_statement: '',
+ suggested_questions: [],
+ sensitive_word_avoidance: null,
+ speech_to_text: null,
+ text_to_speech: null,
+ file_upload: null,
+ suggested_questions_after_answer: null,
+ retriever_resource: null,
+ annotation_reply: null,
+ external_data_tools: [],
+ system_parameters: {
+ audio_file_size_limit: 0,
+ file_size_limit: 0,
+ image_file_size_limit: 0,
+ video_file_size_limit: 0,
+ workflow_file_upload_limit: 0,
+ },
+ dataSets: [],
+ agentConfig: {
+ enabled: false,
+ max_iteration: 5,
+ tools: [],
+ strategy: AgentStrategy.react,
+ },
+ ...overrides,
+ }
+}
+
+/**
+ * Factory function for creating mock Collection list
+ */
+function createMockCollections(collections: Partial[] = []): Collection[] {
+ return collections.map((collection, index) => ({
+ id: `collection-${index}`,
+ name: `Collection ${index}`,
+ icon: 'icon-url',
+ type: 'tool',
+ ...collection,
+ } as Collection))
+}
+
+/**
+ * Factory function for creating mock Provider Context
+ */
+function createMockProviderContext(overrides: Partial = {}): ProviderContextState {
+ return {
+ textGenerationModelList: [
+ {
+ provider: 'openai',
+ label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
+ icon_small: { en_US: 'icon', zh_Hans: 'icon' },
+ icon_large: { en_US: 'icon', zh_Hans: 'icon' },
+ status: ModelStatusEnum.active,
+ models: [
+ {
+ model: 'gpt-3.5-turbo',
+ label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' },
+ model_type: ModelTypeEnum.textGeneration,
+ features: [ModelFeatureEnum.vision],
+ fetch_from: ConfigurationMethodEnum.predefinedModel,
+ model_properties: {},
+ deprecated: false,
+ },
+ ],
+ },
+ ],
+ hasSettedApiKey: true,
+ modelProviders: [],
+ speech2textDefaultModel: null,
+ ttsDefaultModel: null,
+ agentThoughtDefaultModel: null,
+ updateModelList: jest.fn(),
+ onPlanInfoChanged: jest.fn(),
+ refreshModelProviders: jest.fn(),
+ refreshLicenseLimit: jest.fn(),
+ ...overrides,
+ } as ProviderContextState
+}
+
+// ============================================================================
+// Mock External Dependencies ONLY (Following testing.md guidelines)
+// ============================================================================
+
+// Mock service layer (API calls)
+jest.mock('@/service/base', () => ({
+ ssePost: jest.fn(() => Promise.resolve()),
+ post: jest.fn(() => Promise.resolve({ data: {} })),
+ get: jest.fn(() => Promise.resolve({ data: {} })),
+ del: jest.fn(() => Promise.resolve({ data: {} })),
+ patch: jest.fn(() => Promise.resolve({ data: {} })),
+ put: jest.fn(() => Promise.resolve({ data: {} })),
+}))
+
+jest.mock('@/service/fetch', () => ({
+ fetch: jest.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
+}))
+
+const mockFetchConversationMessages = jest.fn()
+const mockFetchSuggestedQuestions = jest.fn()
+const mockStopChatMessageResponding = jest.fn()
+
+jest.mock('@/service/debug', () => ({
+ fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args),
+ fetchSuggestedQuestions: (...args: unknown[]) => mockFetchSuggestedQuestions(...args),
+ stopChatMessageResponding: (...args: unknown[]) => mockStopChatMessageResponding(...args),
+}))
+
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({ push: jest.fn() }),
+ usePathname: () => '/test',
+ useParams: () => ({}),
+}))
+
+// Mock complex context providers
+const mockDebugConfigContext = {
+ appId: 'test-app-id',
+ isAPIKeySet: true,
+ isTrailFinished: false,
+ mode: AppModeEnum.CHAT,
+ modelModeType: ModelModeType.chat,
+ promptMode: PromptMode.simple,
+ setPromptMode: jest.fn(),
+ isAdvancedMode: false,
+ isAgent: false,
+ isFunctionCall: false,
+ isOpenAI: true,
+ collectionList: createMockCollections([
+ { id: 'test-provider', name: 'Test Tool', icon: 'icon-url' },
+ ]),
+ canReturnToSimpleMode: false,
+ setCanReturnToSimpleMode: jest.fn(),
+ chatPromptConfig: {},
+ completionPromptConfig: {},
+ currentAdvancedPrompt: [],
+ showHistoryModal: jest.fn(),
+ conversationHistoriesRole: { user_prefix: 'user', assistant_prefix: 'assistant' },
+ setConversationHistoriesRole: jest.fn(),
+ setCurrentAdvancedPrompt: jest.fn(),
+ hasSetBlockStatus: { context: false, history: false, query: false },
+ conversationId: null,
+ setConversationId: jest.fn(),
+ introduction: '',
+ setIntroduction: jest.fn(),
+ suggestedQuestions: [],
+ setSuggestedQuestions: jest.fn(),
+ controlClearChatMessage: 0,
+ setControlClearChatMessage: jest.fn(),
+ prevPromptConfig: { prompt_template: '', prompt_variables: [] },
+ setPrevPromptConfig: jest.fn(),
+ moreLikeThisConfig: { enabled: false },
+ setMoreLikeThisConfig: jest.fn(),
+ suggestedQuestionsAfterAnswerConfig: { enabled: false },
+ setSuggestedQuestionsAfterAnswerConfig: jest.fn(),
+ speechToTextConfig: { enabled: false },
+ setSpeechToTextConfig: jest.fn(),
+ textToSpeechConfig: { enabled: false, voice: '', language: '' },
+ setTextToSpeechConfig: jest.fn(),
+ citationConfig: { enabled: false },
+ setCitationConfig: jest.fn(),
+ moderationConfig: { enabled: false },
+ annotationConfig: { id: '', enabled: false, score_threshold: 0.7, embedding_model: { embedding_model_name: '', embedding_provider_name: '' } },
+ setAnnotationConfig: jest.fn(),
+ setModerationConfig: jest.fn(),
+ externalDataToolsConfig: [],
+ setExternalDataToolsConfig: jest.fn(),
+ formattingChanged: false,
+ setFormattingChanged: jest.fn(),
+ inputs: { var1: 'test input' },
+ setInputs: jest.fn(),
+ query: '',
+ setQuery: jest.fn(),
+ completionParams: { max_tokens: 100, temperature: 0.7 },
+ setCompletionParams: jest.fn(),
+ modelConfig: createMockModelConfig({
+ agentConfig: {
+ enabled: false,
+ max_iteration: 5,
+ tools: [{
+ tool_name: 'test-tool',
+ provider_id: 'test-provider',
+ provider_type: CollectionType.builtIn,
+ provider_name: 'test-provider',
+ tool_label: 'Test Tool',
+ tool_parameters: {},
+ enabled: true,
+ }],
+ strategy: AgentStrategy.react,
+ },
+ }),
+ setModelConfig: jest.fn(),
+ dataSets: [],
+ showSelectDataSet: jest.fn(),
+ setDataSets: jest.fn(),
+ datasetConfigs: {
+ retrieval_model: 'single',
+ reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
+ top_k: 4,
+ score_threshold_enabled: false,
+ score_threshold: 0.7,
+ datasets: { datasets: [] },
+ } as DatasetConfigs,
+ datasetConfigsRef: createRef(),
+ setDatasetConfigs: jest.fn(),
+ hasSetContextVar: false,
+ isShowVisionConfig: false,
+ visionConfig: { enabled: false, number_limits: 2, detail: Resolution.low, transfer_methods: [] },
+ setVisionConfig: jest.fn(),
+ isAllowVideoUpload: false,
+ isShowDocumentConfig: false,
+ isShowAudioConfig: false,
+ rerankSettingModalOpen: false,
+ setRerankSettingModalOpen: jest.fn(),
+}
+
+jest.mock('@/context/debug-configuration', () => ({
+ useDebugConfigurationContext: jest.fn(() => mockDebugConfigContext),
+}))
+
+const mockProviderContext = createMockProviderContext()
+
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: jest.fn(() => mockProviderContext),
+}))
+
+const mockAppContext = {
+ userProfile: {
+ id: 'user-1',
+ avatar_url: 'https://example.com/avatar.png',
+ name: 'Test User',
+ email: 'test@example.com',
+ },
+ isCurrentWorkspaceManager: false,
+ isCurrentWorkspaceOwner: false,
+ isCurrentWorkspaceDatasetOperator: false,
+ mutateUserProfile: jest.fn(),
+}
+
+jest.mock('@/context/app-context', () => ({
+ useAppContext: jest.fn(() => mockAppContext),
+}))
+
+type FeatureState = {
+ moreLikeThis: { enabled: boolean }
+ opening: { enabled: boolean; opening_statement: string; suggested_questions: string[] }
+ moderation: { enabled: boolean }
+ speech2text: { enabled: boolean }
+ text2speech: { enabled: boolean }
+ file: { enabled: boolean }
+ suggested: { enabled: boolean }
+ citation: { enabled: boolean }
+ annotationReply: { enabled: boolean }
+}
+
+const defaultFeatures: FeatureState = {
+ moreLikeThis: { enabled: false },
+ opening: { enabled: false, opening_statement: '', suggested_questions: [] },
+ moderation: { enabled: false },
+ speech2text: { enabled: false },
+ text2speech: { enabled: false },
+ file: { enabled: false },
+ suggested: { enabled: false },
+ citation: { enabled: false },
+ annotationReply: { enabled: false },
+}
+type FeatureSelector = (state: { features: FeatureState }) => unknown
+
+let mockFeaturesState: FeatureState = { ...defaultFeatures }
+jest.mock('@/app/components/base/features/hooks', () => ({
+ useFeatures: jest.fn(),
+}))
+
+const mockConfigFromDebugContext = {
+ pre_prompt: 'Test prompt',
+ prompt_type: 'simple',
+ user_input_form: [],
+ dataset_query_variable: '',
+ opening_statement: '',
+ more_like_this: { enabled: false },
+ suggested_questions: [],
+ suggested_questions_after_answer: { enabled: false },
+ text_to_speech: { enabled: false },
+ speech_to_text: { enabled: false },
+ retriever_resource: { enabled: false },
+ sensitive_word_avoidance: { enabled: false },
+ agent_mode: {},
+ dataset_configs: {},
+ file_upload: { enabled: false },
+ annotation_reply: { enabled: false },
+ supportAnnotation: true,
+ appId: 'test-app-id',
+ supportCitationHitInfo: true,
+}
+
+jest.mock('../hooks', () => ({
+ useConfigFromDebugContext: jest.fn(() => mockConfigFromDebugContext),
+ useFormattingChangedSubscription: jest.fn(),
+}))
+
+const mockSetShowAppConfigureFeaturesModal = jest.fn()
+
+jest.mock('@/app/components/app/store', () => ({
+ useStore: jest.fn((selector?: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => {
+ if (typeof selector === 'function')
+ return selector({ setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal })
+ return mockSetShowAppConfigureFeaturesModal
+ }),
+}))
+
+// Mock event emitter context
+jest.mock('@/context/event-emitter', () => ({
+ useEventEmitterContextContext: jest.fn(() => ({
+ eventEmitter: null,
+ })),
+}))
+
+// Mock toast context
+jest.mock('@/app/components/base/toast', () => ({
+ useToastContext: jest.fn(() => ({
+ notify: jest.fn(),
+ })),
+}))
+
+// Mock hooks/use-timestamp
+jest.mock('@/hooks/use-timestamp', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ formatTime: jest.fn((timestamp: number) => new Date(timestamp).toLocaleString()),
+ })),
+}))
+
+// Mock audio player manager
+jest.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
+ AudioPlayerManager: {
+ getInstance: jest.fn(() => ({
+ getAudioPlayer: jest.fn(),
+ resetAudioPlayer: jest.fn(),
+ })),
+ },
+}))
+
+type MockChatProps = {
+ chatList?: ChatItem[]
+ isResponding?: boolean
+ onSend?: (message: string, files?: FileEntity[]) => void
+ onRegenerate?: (chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => void
+ onStopResponding?: () => void
+ suggestedQuestions?: string[]
+ questionIcon?: ReactNode
+ answerIcon?: ReactNode
+ onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
+ onAnnotationEdited?: (question: string, answer: string, index: number) => void
+ onAnnotationRemoved?: (index: number) => void
+ switchSibling?: (siblingMessageId: string) => void
+ onFeatureBarClick?: (state: boolean) => void
+}
+
+const mockFile: FileEntity = {
+ id: 'file-1',
+ name: 'test.png',
+ size: 123,
+ type: 'image/png',
+ progress: 100,
+ transferMethod: TransferMethod.local_file,
+ supportFileType: 'image',
+}
+
+// Mock Chat component (complex with many dependencies)
+// This is a pragmatic mock that tests the integration at DebugWithSingleModel level
+jest.mock('@/app/components/base/chat/chat', () => {
+ return function MockChat({
+ chatList,
+ isResponding,
+ onSend,
+ onRegenerate,
+ onStopResponding,
+ suggestedQuestions,
+ questionIcon,
+ answerIcon,
+ onAnnotationAdded,
+ onAnnotationEdited,
+ onAnnotationRemoved,
+ switchSibling,
+ onFeatureBarClick,
+ }: MockChatProps) {
+ const items = chatList || []
+ const suggested = suggestedQuestions ?? []
+ return (
+
+
+ {items.map((item: ChatItem) => (
+
+ {item.content}
+
+ ))}
+
+ {questionIcon &&
{questionIcon}
}
+ {answerIcon &&
{answerIcon}
}
+
+ )
+ }
+})
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+describe('DebugWithSingleModel', () => {
+ let ref: RefObject
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ ref = createRef()
+
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+ const { useProviderContext } = require('@/context/provider-context')
+ const { useAppContext } = require('@/context/app-context')
+ const { useConfigFromDebugContext, useFormattingChangedSubscription } = require('../hooks')
+ const { useFeatures } = require('@/app/components/base/features/hooks') as { useFeatures: jest.Mock }
+
+ useDebugConfigurationContext.mockReturnValue(mockDebugConfigContext)
+ useProviderContext.mockReturnValue(mockProviderContext)
+ useAppContext.mockReturnValue(mockAppContext)
+ useConfigFromDebugContext.mockReturnValue(mockConfigFromDebugContext)
+ useFormattingChangedSubscription.mockReturnValue(undefined)
+ mockFeaturesState = { ...defaultFeatures }
+ useFeatures.mockImplementation((selector?: FeatureSelector) => {
+ if (typeof selector === 'function')
+ return selector({ features: mockFeaturesState })
+ return mockFeaturesState
+ })
+
+ // Reset mock implementations
+ mockFetchConversationMessages.mockResolvedValue({ data: [] })
+ mockFetchSuggestedQuestions.mockResolvedValue({ data: [] })
+ mockStopChatMessageResponding.mockResolvedValue({})
+ })
+
+ // Rendering Tests
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render(} />)
+
+ // Verify Chat component is rendered
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ expect(screen.getByTestId('chat-input')).toBeInTheDocument()
+ expect(screen.getByTestId('send-button')).toBeInTheDocument()
+ })
+
+ it('should render with custom checkCanSend prop', () => {
+ const checkCanSend = jest.fn(() => true)
+
+ render(} checkCanSend={checkCanSend} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+
+ // Props Tests
+ describe('Props', () => {
+ it('should respect checkCanSend returning true', async () => {
+ const checkCanSend = jest.fn(() => true)
+
+ render(} checkCanSend={checkCanSend} />)
+
+ const sendButton = screen.getByTestId('send-button')
+ fireEvent.click(sendButton)
+
+ const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
+ await waitFor(() => {
+ expect(checkCanSend).toHaveBeenCalled()
+ expect(ssePost).toHaveBeenCalled()
+ })
+
+ expect(ssePost.mock.calls[0][0]).toBe('apps/test-app-id/chat-messages')
+ })
+
+ it('should prevent send when checkCanSend returns false', async () => {
+ const checkCanSend = jest.fn(() => false)
+
+ render(} checkCanSend={checkCanSend} />)
+
+ const sendButton = screen.getByTestId('send-button')
+ fireEvent.click(sendButton)
+
+ const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
+ await waitFor(() => {
+ expect(checkCanSend).toHaveBeenCalled()
+ expect(checkCanSend).toHaveReturnedWith(false)
+ })
+ expect(ssePost).not.toHaveBeenCalled()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should open feature configuration when feature bar is clicked', () => {
+ render(} />)
+
+ fireEvent.click(screen.getByTestId('feature-bar-button'))
+
+ expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true)
+ })
+ })
+
+ // Model Configuration Tests
+ describe('Model Configuration', () => {
+ it('should include opening features in request when enabled', async () => {
+ mockFeaturesState = {
+ ...defaultFeatures,
+ opening: { enabled: true, opening_statement: 'Hello!', suggested_questions: ['Q1'] },
+ }
+
+ render(} />)
+
+ fireEvent.click(screen.getByTestId('send-button'))
+
+ const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
+ await waitFor(() => {
+ expect(ssePost).toHaveBeenCalled()
+ })
+
+ const body = ssePost.mock.calls[0][1].body
+ expect(body.model_config.opening_statement).toBe('Hello!')
+ expect(body.model_config.suggested_questions).toEqual(['Q1'])
+ })
+
+ it('should omit opening statement when feature is disabled', async () => {
+ mockFeaturesState = {
+ ...defaultFeatures,
+ opening: { enabled: false, opening_statement: 'Should not appear', suggested_questions: ['Q1'] },
+ }
+
+ render(} />)
+
+ fireEvent.click(screen.getByTestId('send-button'))
+
+ const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
+ await waitFor(() => {
+ expect(ssePost).toHaveBeenCalled()
+ })
+
+ const body = ssePost.mock.calls[0][1].body
+ expect(body.model_config.opening_statement).toBe('')
+ expect(body.model_config.suggested_questions).toEqual([])
+ })
+
+ it('should handle model without vision support', () => {
+ const { useProviderContext } = require('@/context/provider-context')
+
+ useProviderContext.mockReturnValue(createMockProviderContext({
+ textGenerationModelList: [
+ {
+ provider: 'openai',
+ label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
+ icon_small: { en_US: 'icon', zh_Hans: 'icon' },
+ icon_large: { en_US: 'icon', zh_Hans: 'icon' },
+ status: ModelStatusEnum.active,
+ models: [
+ {
+ model: 'gpt-3.5-turbo',
+ label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' },
+ model_type: ModelTypeEnum.textGeneration,
+ features: [], // No vision support
+ fetch_from: ConfigurationMethodEnum.predefinedModel,
+ model_properties: {},
+ deprecated: false,
+ status: ModelStatusEnum.active,
+ load_balancing_enabled: false,
+ },
+ ],
+ },
+ ],
+ }))
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle missing model in provider list', () => {
+ const { useProviderContext } = require('@/context/provider-context')
+
+ useProviderContext.mockReturnValue(createMockProviderContext({
+ textGenerationModelList: [
+ {
+ provider: 'different-provider',
+ label: { en_US: 'Different Provider', zh_Hans: '不同提供商' },
+ icon_small: { en_US: 'icon', zh_Hans: 'icon' },
+ icon_large: { en_US: 'icon', zh_Hans: 'icon' },
+ status: ModelStatusEnum.active,
+ models: [],
+ },
+ ],
+ }))
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+
+ // Input Forms Tests
+ describe('Input Forms', () => {
+ it('should filter out api type prompt variables', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ modelConfig: createMockModelConfig({
+ configs: {
+ prompt_template: 'Test',
+ prompt_variables: [
+ { key: 'var1', name: 'Var 1', type: 'text', required: false },
+ { key: 'var2', name: 'Var 2', type: 'api', required: false },
+ { key: 'var3', name: 'Var 3', type: 'select', required: false },
+ ],
+ },
+ }),
+ })
+
+ render(} />)
+
+ // Component should render successfully with filtered variables
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle empty prompt variables', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ modelConfig: createMockModelConfig({
+ configs: {
+ prompt_template: 'Test',
+ prompt_variables: [],
+ },
+ }),
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+
+ // Tool Icons Tests
+ describe('Tool Icons', () => {
+ it('should map tool icons from collection list', () => {
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle empty tools list', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ modelConfig: createMockModelConfig({
+ agentConfig: {
+ enabled: false,
+ max_iteration: 5,
+ tools: [],
+ strategy: AgentStrategy.react,
+ },
+ }),
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle missing collection for tool', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ modelConfig: createMockModelConfig({
+ agentConfig: {
+ enabled: false,
+ max_iteration: 5,
+ tools: [{
+ tool_name: 'unknown-tool',
+ provider_id: 'unknown-provider',
+ provider_type: CollectionType.builtIn,
+ provider_name: 'unknown-provider',
+ tool_label: 'Unknown Tool',
+ tool_parameters: {},
+ enabled: true,
+ }],
+ strategy: AgentStrategy.react,
+ },
+ }),
+ collectionList: [],
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+
+ // Edge Cases
+ describe('Edge Cases', () => {
+ it('should handle empty inputs', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ inputs: {},
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle missing user profile', () => {
+ const { useAppContext } = require('@/context/app-context')
+
+ useAppContext.mockReturnValue({
+ ...mockAppContext,
+ userProfile: {
+ id: '',
+ avatar_url: '',
+ name: '',
+ email: '',
+ },
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+
+ it('should handle null completion params', () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ completionParams: {},
+ })
+
+ render(} />)
+
+ expect(screen.getByTestId('chat-component')).toBeInTheDocument()
+ })
+ })
+
+ // Imperative Handle Tests
+ describe('Imperative Handle', () => {
+ it('should expose handleRestart method via ref', () => {
+ render(} />)
+
+ expect(ref.current).not.toBeNull()
+ expect(ref.current?.handleRestart).toBeDefined()
+ expect(typeof ref.current?.handleRestart).toBe('function')
+ })
+
+ it('should call handleRestart when invoked via ref', () => {
+ render(} />)
+
+ act(() => {
+ ref.current?.handleRestart()
+ })
+ })
+ })
+
+ // File Upload Tests
+ describe('File Upload', () => {
+ it('should not include files when vision is not supported', async () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+ const { useProviderContext } = require('@/context/provider-context')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ modelConfig: createMockModelConfig({
+ model_id: 'gpt-3.5-turbo',
+ }),
+ })
+
+ useProviderContext.mockReturnValue(createMockProviderContext({
+ textGenerationModelList: [
+ {
+ provider: 'openai',
+ label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
+ icon_small: { en_US: 'icon', zh_Hans: 'icon' },
+ icon_large: { en_US: 'icon', zh_Hans: 'icon' },
+ status: ModelStatusEnum.active,
+ models: [
+ {
+ model: 'gpt-3.5-turbo',
+ label: { en_US: 'GPT-3.5', zh_Hans: 'GPT-3.5' },
+ model_type: ModelTypeEnum.textGeneration,
+ features: [], // No vision
+ fetch_from: ConfigurationMethodEnum.predefinedModel,
+ model_properties: {},
+ deprecated: false,
+ status: ModelStatusEnum.active,
+ load_balancing_enabled: false,
+ },
+ ],
+ },
+ ],
+ }))
+
+ mockFeaturesState = {
+ ...defaultFeatures,
+ file: { enabled: true },
+ }
+
+ render(} />)
+
+ fireEvent.click(screen.getByTestId('send-with-files'))
+
+ const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
+ await waitFor(() => {
+ expect(ssePost).toHaveBeenCalled()
+ })
+
+ const body = ssePost.mock.calls[0][1].body
+ expect(body.files).toEqual([])
+ })
+
+ it('should support files when vision is enabled', async () => {
+ const { useDebugConfigurationContext } = require('@/context/debug-configuration')
+ const { useProviderContext } = require('@/context/provider-context')
+
+ useDebugConfigurationContext.mockReturnValue({
+ ...mockDebugConfigContext,
+ modelConfig: createMockModelConfig({
+ model_id: 'gpt-4-vision',
+ }),
+ })
+
+ useProviderContext.mockReturnValue(createMockProviderContext({
+ textGenerationModelList: [
+ {
+ provider: 'openai',
+ label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
+ icon_small: { en_US: 'icon', zh_Hans: 'icon' },
+ icon_large: { en_US: 'icon', zh_Hans: 'icon' },
+ status: ModelStatusEnum.active,
+ models: [
+ {
+ model: 'gpt-4-vision',
+ label: { en_US: 'GPT-4 Vision', zh_Hans: 'GPT-4 Vision' },
+ model_type: ModelTypeEnum.textGeneration,
+ features: [ModelFeatureEnum.vision],
+ fetch_from: ConfigurationMethodEnum.predefinedModel,
+ model_properties: {},
+ deprecated: false,
+ status: ModelStatusEnum.active,
+ load_balancing_enabled: false,
+ },
+ ],
+ },
+ ],
+ }))
+
+ mockFeaturesState = {
+ ...defaultFeatures,
+ file: { enabled: true },
+ }
+
+ render(} />)
+
+ fireEvent.click(screen.getByTestId('send-with-files'))
+
+ const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
+ await waitFor(() => {
+ expect(ssePost).toHaveBeenCalled()
+ })
+
+ const body = ssePost.mock.calls[0][1].body
+ expect(body.files).toHaveLength(1)
+ })
+ })
+})
diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx
index 005f7f938f..9874664443 100644
--- a/web/app/components/app/configuration/prompt-value-panel/index.tsx
+++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx
@@ -21,7 +21,7 @@ import FeatureBar from '@/app/components/base/features/new-feature-panel/feature
import type { VisionFile, VisionSettings } from '@/types/app'
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
import { useStore as useAppStore } from '@/app/components/app/store'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
export type IPromptValuePanelProps = {
diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx
index a3bf91cb5d..df35a74ec7 100644
--- a/web/app/components/app/create-app-dialog/app-card/index.tsx
+++ b/web/app/components/app/create-app-dialog/app-card/index.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { PlusIcon } from '@heroicons/react/20/solid'
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
import Button from '@/app/components/base/button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { App } from '@/models/explore'
import AppIcon from '@/app/components/base/app-icon'
diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx
index 51b6874d52..4655d7a676 100644
--- a/web/app/components/app/create-app-dialog/app-list/index.tsx
+++ b/web/app/components/app/create-app-dialog/app-list/index.tsx
@@ -11,7 +11,7 @@ import AppCard from '../app-card'
import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar'
import Toast from '@/app/components/base/toast'
import Divider from '@/app/components/base/divider'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import ExploreContext from '@/context/explore-context'
import type { App } from '@/models/explore'
import { fetchAppDetail, fetchAppList } from '@/service/explore'
diff --git a/web/app/components/app/create-app-dialog/app-list/sidebar.tsx b/web/app/components/app/create-app-dialog/app-list/sidebar.tsx
index 85c55c5385..89062cdcf9 100644
--- a/web/app/components/app/create-app-dialog/app-list/sidebar.tsx
+++ b/web/app/components/app/create-app-dialog/app-list/sidebar.tsx
@@ -1,7 +1,7 @@
'use client'
import { RiStickyNoteAddLine, RiThumbUpLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
export enum AppCategories {
@@ -40,13 +40,13 @@ type CategoryItemProps = {
}
function CategoryItem({ category, active, onClick }: CategoryItemProps) {
return { onClick?.(category) }}>
{category === AppCategories.RECOMMENDED &&
}
+ className={cn('system-sm-medium text-components-menu-item-text group-hover:text-components-menu-item-text-hover group-[.active]:text-components-menu-item-text-active', active && 'system-sm-semibold')} />
}
diff --git a/web/app/components/app/create-app-dialog/index.spec.tsx b/web/app/components/app/create-app-dialog/index.spec.tsx
index a64e409b25..db4384a173 100644
--- a/web/app/components/app/create-app-dialog/index.spec.tsx
+++ b/web/app/components/app/create-app-dialog/index.spec.tsx
@@ -26,7 +26,7 @@ jest.mock('./app-list', () => {
})
jest.mock('ahooks', () => ({
- useKeyPress: jest.fn((key: string, callback: () => void) => {
+ useKeyPress: jest.fn((_key: string, _callback: () => void) => {
// Mock implementation for testing
return jest.fn()
}),
@@ -67,7 +67,7 @@ describe('CreateAppTemplateDialog', () => {
})
it('should not render create from blank button when onCreateFromBlank is not provided', () => {
- const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
+ const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
render( )
@@ -259,7 +259,7 @@ describe('CreateAppTemplateDialog', () => {
})
it('should handle missing optional onCreateFromBlank prop', () => {
- const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
+ const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
expect(() => {
render( )
diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx
index a449ec8ef2..d74715187f 100644
--- a/web/app/components/app/create-app-modal/index.tsx
+++ b/web/app/components/app/create-app-modal/index.tsx
@@ -13,7 +13,7 @@ import AppIconPicker from '../../base/app-icon-picker'
import type { AppIconSelection } from '../../base/app-icon-picker'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { basePath } from '@/utils/var'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx
index 3564738dfd..0d30a2abac 100644
--- a/web/app/components/app/create-from-dsl-modal/index.tsx
+++ b/web/app/components/app/create-from-dsl-modal/index.tsx
@@ -25,7 +25,7 @@ import { useProviderContext } from '@/context/provider-context'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { getRedirection } from '@/utils/app-redirection'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { noop } from 'lodash-es'
import { trackEvent } from '@/app/components/base/amplitude'
diff --git a/web/app/components/app/create-from-dsl-modal/uploader.tsx b/web/app/components/app/create-from-dsl-modal/uploader.tsx
index b6644da5a4..2745ca84c6 100644
--- a/web/app/components/app/create-from-dsl-modal/uploader.tsx
+++ b/web/app/components/app/create-from-dsl-modal/uploader.tsx
@@ -8,7 +8,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { formatFileSize } from '@/utils/format'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { Yaml as YamlIcon } from '@/app/components/base/icons/src/public/files'
import { ToastContext } from '@/app/components/base/toast'
import ActionButton from '@/app/components/base/action-button'
diff --git a/web/app/components/app/duplicate-modal/index.tsx b/web/app/components/app/duplicate-modal/index.tsx
index f98fb831ed..f25eb5373d 100644
--- a/web/app/components/app/duplicate-modal/index.tsx
+++ b/web/app/components/app/duplicate-modal/index.tsx
@@ -3,7 +3,7 @@ import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import AppIconPicker from '../../base/app-icon-picker'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
diff --git a/web/app/components/app/log-annotation/index.tsx b/web/app/components/app/log-annotation/index.tsx
index c0b0854b29..e7c2be3eed 100644
--- a/web/app/components/app/log-annotation/index.tsx
+++ b/web/app/components/app/log-annotation/index.tsx
@@ -3,7 +3,7 @@ import type { FC } from 'react'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Log from '@/app/components/app/log'
import WorkflowLog from '@/app/components/app/workflow-log'
import Annotation from '@/app/components/app/annotation'
diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx
index 0ff375d815..e479cbe881 100644
--- a/web/app/components/app/log/list.tsx
+++ b/web/app/components/app/log/list.tsx
@@ -39,7 +39,7 @@ import Tooltip from '@/app/components/base/tooltip'
import CopyIcon from '@/app/components/base/copy-icon'
import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { noop } from 'lodash-es'
import PromptLogModal from '../../base/prompt-log-modal'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
diff --git a/web/app/components/app/log/model-info.tsx b/web/app/components/app/log/model-info.tsx
index 626ef093e9..b3c4f11be5 100644
--- a/web/app/components/app/log/model-info.tsx
+++ b/web/app/components/app/log/model-info.tsx
@@ -13,7 +13,7 @@ import {
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
const PARAM_MAP = {
temperature: 'Temperature',
diff --git a/web/app/components/app/log/var-panel.tsx b/web/app/components/app/log/var-panel.tsx
index dd8c231a56..8915b3438a 100644
--- a/web/app/components/app/log/var-panel.tsx
+++ b/web/app/components/app/log/var-panel.tsx
@@ -9,7 +9,7 @@ import {
} from '@remixicon/react'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
varList: { label: string; value: string }[]
diff --git a/web/app/components/app/overview/apikey-info-panel/index.tsx b/web/app/components/app/overview/apikey-info-panel/index.tsx
index b50b0077cb..47fe7af972 100644
--- a/web/app/components/app/overview/apikey-info-panel/index.tsx
+++ b/web/app/components/app/overview/apikey-info-panel/index.tsx
@@ -3,7 +3,7 @@ import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Button from '@/app/components/base/button'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import { IS_CE_EDITION } from '@/config'
diff --git a/web/app/components/app/overview/embedded/index.tsx b/web/app/components/app/overview/embedded/index.tsx
index 6eba993e1d..d4be58b1b2 100644
--- a/web/app/components/app/overview/embedded/index.tsx
+++ b/web/app/components/app/overview/embedded/index.tsx
@@ -14,7 +14,7 @@ import type { SiteInfo } from '@/models/share'
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
import ActionButton from '@/app/components/base/action-button'
import { basePath } from '@/utils/var'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
siteInfo?: SiteInfo
diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx
index 3b71b8f75c..d079631cf7 100644
--- a/web/app/components/app/overview/settings/index.tsx
+++ b/web/app/components/app/overview/settings/index.tsx
@@ -25,7 +25,7 @@ import { useModalContext } from '@/context/modal-context'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import AppIconPicker from '@/app/components/base/app-icon-picker'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useDocLink } from '@/context/i18n'
export type ISettingsModalProps = {
diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx
index a7e1cea429..742212a44d 100644
--- a/web/app/components/app/switch-app-modal/index.tsx
+++ b/web/app/components/app/switch-app-modal/index.tsx
@@ -6,7 +6,7 @@ import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import AppIconPicker from '../../base/app-icon-picker'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Checkbox from '@/app/components/base/checkbox'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx
index 92d86351e0..d284ecd46e 100644
--- a/web/app/components/app/text-generate/item/index.tsx
+++ b/web/app/components/app/text-generate/item/index.tsx
@@ -30,7 +30,7 @@ import type { SiteInfo } from '@/models/share'
import { useChatContext } from '@/app/components/base/chat/chat/context'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import NewAudioButton from '@/app/components/base/new-audio-button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
const MAX_DEPTH = 3
diff --git a/web/app/components/app/text-generate/saved-items/index.tsx b/web/app/components/app/text-generate/saved-items/index.tsx
index c22a4ca6c2..e6cf264cf2 100644
--- a/web/app/components/app/text-generate/saved-items/index.tsx
+++ b/web/app/components/app/text-generate/saved-items/index.tsx
@@ -8,7 +8,7 @@ import {
import { useTranslation } from 'react-i18next'
import copy from 'copy-to-clipboard'
import NoData from './no-data'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { SavedMessage } from '@/models/debug'
import { Markdown } from '@/app/components/base/markdown'
import Toast from '@/app/components/base/toast'
diff --git a/web/app/components/app/type-selector/index.spec.tsx b/web/app/components/app/type-selector/index.spec.tsx
new file mode 100644
index 0000000000..346c9d5716
--- /dev/null
+++ b/web/app/components/app/type-selector/index.spec.tsx
@@ -0,0 +1,144 @@
+import React from 'react'
+import { fireEvent, render, screen, within } from '@testing-library/react'
+import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from './index'
+import { AppModeEnum } from '@/types/app'
+
+jest.mock('react-i18next')
+
+describe('AppTypeSelector', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // Covers default rendering and the closed dropdown state.
+ describe('Rendering', () => {
+ it('should render "all types" trigger when no types selected', () => {
+ render( )
+
+ expect(screen.getByText('app.typeSelector.all')).toBeInTheDocument()
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+ })
+ })
+
+ // Covers prop-driven trigger variants (empty, single, multiple).
+ describe('Props', () => {
+ it('should render selected type label and clear button when a single type is selected', () => {
+ render( )
+
+ expect(screen.getByText('app.typeSelector.chatbot')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
+ })
+
+ it('should render icon-only trigger when multiple types are selected', () => {
+ render( )
+
+ expect(screen.queryByText('app.typeSelector.all')).not.toBeInTheDocument()
+ expect(screen.queryByText('app.typeSelector.chatbot')).not.toBeInTheDocument()
+ expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
+ })
+ })
+
+ // Covers opening/closing the dropdown and selection updates.
+ describe('User interactions', () => {
+ it('should toggle option list when clicking the trigger', () => {
+ render( )
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('app.typeSelector.all'))
+ expect(screen.getByRole('tooltip')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('app.typeSelector.all'))
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+ })
+
+ it('should call onChange with added type when selecting an unselected item', () => {
+ const onChange = jest.fn()
+ render( )
+
+ fireEvent.click(screen.getByText('app.typeSelector.all'))
+ fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
+
+ expect(onChange).toHaveBeenCalledWith([AppModeEnum.WORKFLOW])
+ })
+
+ it('should call onChange with removed type when selecting an already-selected item', () => {
+ const onChange = jest.fn()
+ render( )
+
+ fireEvent.click(screen.getByText('app.typeSelector.workflow'))
+ fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
+
+ expect(onChange).toHaveBeenCalledWith([])
+ })
+
+ it('should call onChange with appended type when selecting an additional item', () => {
+ const onChange = jest.fn()
+ render( )
+
+ fireEvent.click(screen.getByText('app.typeSelector.chatbot'))
+ fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.agent'))
+
+ expect(onChange).toHaveBeenCalledWith([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT])
+ })
+
+ it('should clear selection without opening the dropdown when clicking clear button', () => {
+ const onChange = jest.fn()
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
+
+ expect(onChange).toHaveBeenCalledWith([])
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+ })
+ })
+})
+
+describe('AppTypeLabel', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // Covers label mapping for each supported app type.
+ it.each([
+ [AppModeEnum.CHAT, 'app.typeSelector.chatbot'],
+ [AppModeEnum.AGENT_CHAT, 'app.typeSelector.agent'],
+ [AppModeEnum.COMPLETION, 'app.typeSelector.completion'],
+ [AppModeEnum.ADVANCED_CHAT, 'app.typeSelector.advanced'],
+ [AppModeEnum.WORKFLOW, 'app.typeSelector.workflow'],
+ ] as const)('should render label %s for type %s', (_type, expectedLabel) => {
+ render( )
+ expect(screen.getByText(expectedLabel)).toBeInTheDocument()
+ })
+
+ // Covers fallback behavior for unexpected app mode values.
+ it('should render empty label for unknown type', () => {
+ const { container } = render( )
+ expect(container.textContent).toBe('')
+ })
+})
+
+describe('AppTypeIcon', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // Covers icon rendering for each supported app type.
+ it.each([
+ [AppModeEnum.CHAT],
+ [AppModeEnum.AGENT_CHAT],
+ [AppModeEnum.COMPLETION],
+ [AppModeEnum.ADVANCED_CHAT],
+ [AppModeEnum.WORKFLOW],
+ ] as const)('should render icon for type %s', (type) => {
+ const { container } = render( )
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ })
+
+ // Covers fallback behavior for unexpected app mode values.
+ it('should render nothing for unknown type', () => {
+ const { container } = render( )
+ expect(container.firstChild).toBeNull()
+ })
+})
diff --git a/web/app/components/app/type-selector/index.tsx b/web/app/components/app/type-selector/index.tsx
index 0f6f050953..f213a89a94 100644
--- a/web/app/components/app/type-selector/index.tsx
+++ b/web/app/components/app/type-selector/index.tsx
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import React, { useState } from 'react'
import { RiArrowDownSLine, RiCloseCircleFill, RiExchange2Fill, RiFilter3Line } from '@remixicon/react'
import Checkbox from '../../base/checkbox'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@@ -20,6 +20,7 @@ const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT
const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
const [open, setOpen] = useState(false)
+ const { t } = useTranslation()
return (
{
'flex cursor-pointer items-center justify-between space-x-1 rounded-md px-2 hover:bg-state-base-hover',
)}>
- {value && value.length > 0 && {
- e.stopPropagation()
- onChange([])
- }}>
-
-
}
+ {value && value.length > 0 && (
+ {
+ e.stopPropagation()
+ onChange([])
+ }}
+ >
+
+
+ )}
diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx
index 0e9b5dd67f..cef8a98f44 100644
--- a/web/app/components/app/workflow-log/list.tsx
+++ b/web/app/components/app/workflow-log/list.tsx
@@ -12,7 +12,7 @@ import Drawer from '@/app/components/base/drawer'
import Indicator from '@/app/components/header/indicator'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { WorkflowRunTriggeredFrom } from '@/models/log'
type ILogs = {
diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx
index b8da0264e4..8140422c0f 100644
--- a/web/app/components/apps/app-card.tsx
+++ b/web/app/components/apps/app-card.tsx
@@ -5,7 +5,7 @@ import { useContext } from 'use-context-selector'
import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { type App, AppModeEnum } from '@/types/app'
import Toast, { ToastContext } from '@/app/components/base/toast'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
diff --git a/web/app/components/apps/new-app-card.tsx b/web/app/components/apps/new-app-card.tsx
index 7a10bc8527..51e4bae8fe 100644
--- a/web/app/components/apps/new-app-card.tsx
+++ b/web/app/components/apps/new-app-card.tsx
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
import { useProviderContext } from '@/context/provider-context'
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import dynamic from 'next/dynamic'
const CreateAppModal = dynamic(() => import('@/app/components/app/create-app-modal'), {
diff --git a/web/app/components/base/action-button/index.tsx b/web/app/components/base/action-button/index.tsx
index f70bfb4448..eff6a43d22 100644
--- a/web/app/components/base/action-button/index.tsx
+++ b/web/app/components/base/action-button/index.tsx
@@ -1,7 +1,7 @@
import type { CSSProperties } from 'react'
import React from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
enum ActionButtonState {
Destructive = 'destructive',
@@ -54,10 +54,8 @@ const ActionButton = ({ className, size, state = ActionButtonState.Default, styl
return (
= ({
background,
imageUrl,
}) => {
- const wrapperClassName = classNames(
- 'flex',
+ const wrapperClassName = cn('flex',
'items-center',
'justify-center',
'w-full',
@@ -30,8 +29,7 @@ const AnswerIcon: FC = ({
'rounded-full',
'border-[0.5px]',
'border-black/5',
- 'text-xl',
- )
+ 'text-xl')
const isValidImageIcon = iconType === 'image' && imageUrl
return = ({
}
return (
-
+
= ({
return (
diff --git a/web/app/components/base/app-unavailable.tsx b/web/app/components/base/app-unavailable.tsx
index c501d36118..e80853086e 100644
--- a/web/app/components/base/app-unavailable.tsx
+++ b/web/app/components/base/app-unavailable.tsx
@@ -1,5 +1,5 @@
'use client'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,7 +20,7 @@ const AppUnavailable: FC = ({
const { t } = useTranslation()
return (
-
+
= ({
}) => {
return (
diff --git a/web/app/components/base/block-input/index.tsx b/web/app/components/base/block-input/index.tsx
index ae6f77fab3..956bd3d766 100644
--- a/web/app/components/base/block-input/index.tsx
+++ b/web/app/components/base/block-input/index.tsx
@@ -5,7 +5,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import VarHighlight from '../../app/configuration/base/var-highlight'
import Toast from '../toast'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { checkKeys } from '@/utils/var'
// regex to match the {{}} and replace it with a span
@@ -61,7 +61,7 @@ const BlockInput: FC
= ({
}
}, [isEditing])
- const style = classNames({
+ 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-input--editing': isEditing,
})
@@ -110,7 +110,7 @@ const BlockInput: FC = ({
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
const TextAreaContentView = () => {
return (
-
+
{renderSafeContent(currentValue || '')}
)
@@ -120,12 +120,12 @@ const BlockInput: FC
= ({
const editAreaClassName = 'focus:outline-none bg-transparent text-sm'
const textAreaContent = (
- !readonly && setIsEditing(true)}>
+
!readonly && setIsEditing(true)}>
{isEditing
?
)
return (
-
+
{textAreaContent}
{/* footer */}
{!readonly && (
diff --git a/web/app/components/base/button/add-button.tsx b/web/app/components/base/button/add-button.tsx
index 420b668141..cecc9ec063 100644
--- a/web/app/components/base/button/add-button.tsx
+++ b/web/app/components/base/button/add-button.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import { RiAddLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
className?: string
diff --git a/web/app/components/base/button/index.tsx b/web/app/components/base/button/index.tsx
index 4f75aec5a5..cb0d0c1fd9 100644
--- a/web/app/components/base/button/index.tsx
+++ b/web/app/components/base/button/index.tsx
@@ -2,7 +2,7 @@ import type { CSSProperties } from 'react'
import React from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import Spinner from '../spinner'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
const buttonVariants = cva(
'btn disabled:btn-disabled',
@@ -42,16 +42,14 @@ const Button = ({ className, variant, size, destructive, loading, styleCss, chil
return (
{children}
- {loading && }
+ {loading && }
)
}
diff --git a/web/app/components/base/button/sync-button.tsx b/web/app/components/base/button/sync-button.tsx
index 013c86889a..a9d4d1022f 100644
--- a/web/app/components/base/button/sync-button.tsx
+++ b/web/app/components/base/button/sync-button.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import { RiRefreshLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import TooltipPlus from '@/app/components/base/tooltip'
type Props = {
diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx
index ab133d67af..535d7e19bf 100644
--- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx
+++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx
@@ -20,7 +20,7 @@ import AppIcon from '@/app/components/base/app-icon'
import AnswerIcon from '@/app/components/base/answer-icon'
import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions'
import { Markdown } from '@/app/components/base/markdown'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { FileEntity } from '../../file-uploader/types'
import { formatBooleanInputs } from '@/utils/model-config'
import Avatar from '../../avatar'
diff --git a/web/app/components/base/chat/chat-with-history/header/index.tsx b/web/app/components/base/chat/chat-with-history/header/index.tsx
index b5c5bccec1..f63c97603b 100644
--- a/web/app/components/base/chat/chat-with-history/header/index.tsx
+++ b/web/app/components/base/chat/chat-with-history/header/index.tsx
@@ -16,7 +16,7 @@ import ViewFormDropdown from '@/app/components/base/chat/chat-with-history/input
import Confirm from '@/app/components/base/confirm'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import type { ConversationItem } from '@/models/share'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
const Header = () => {
const {
diff --git a/web/app/components/base/chat/chat-with-history/header/operation.tsx b/web/app/components/base/chat/chat-with-history/header/operation.tsx
index 0923d712fa..9549e9da26 100644
--- a/web/app/components/base/chat/chat-with-history/header/operation.tsx
+++ b/web/app/components/base/chat/chat-with-history/header/operation.tsx
@@ -7,7 +7,7 @@ import {
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
title: string
diff --git a/web/app/components/base/chat/chat-with-history/index.tsx b/web/app/components/base/chat/chat-with-history/index.tsx
index 6953be4b3c..51ba88b049 100644
--- a/web/app/components/base/chat/chat-with-history/index.tsx
+++ b/web/app/components/base/chat/chat-with-history/index.tsx
@@ -17,7 +17,7 @@ import ChatWrapper from './chat-wrapper'
import type { InstalledApp } from '@/models/explore'
import Loading from '@/app/components/base/loading'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import useDocumentTitle from '@/hooks/use-document-title'
type ChatWithHistoryProps = {
diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx
index 3a1b92089c..643ca1a808 100644
--- a/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx
+++ b/web/app/components/base/chat/chat-with-history/inputs-form/index.tsx
@@ -5,7 +5,7 @@ import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
import { useChatWithHistoryContext } from '../context'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
collapsed: boolean
diff --git a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx
index c6a7063d80..c5f2afd425 100644
--- a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx
+++ b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx
@@ -18,7 +18,7 @@ import Confirm from '@/app/components/base/confirm'
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import type { ConversationItem } from '@/models/share'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
type Props = {
diff --git a/web/app/components/base/chat/chat-with-history/sidebar/item.tsx b/web/app/components/base/chat/chat-with-history/sidebar/item.tsx
index ea17f3f3ea..cd181fd7eb 100644
--- a/web/app/components/base/chat/chat-with-history/sidebar/item.tsx
+++ b/web/app/components/base/chat/chat-with-history/sidebar/item.tsx
@@ -6,7 +6,7 @@ import {
import { useHover } from 'ahooks'
import type { ConversationItem } from '@/models/share'
import Operation from '@/app/components/base/chat/chat-with-history/sidebar/operation'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type ItemProps = {
isPin?: boolean
diff --git a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx
index 19d2aa2cbf..9c4ea6ffb1 100644
--- a/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx
+++ b/web/app/components/base/chat/chat-with-history/sidebar/operation.tsx
@@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
isActive?: boolean
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 6c8a44cf52..cb3791650a 100644
--- a/web/app/components/base/chat/chat/answer/basic-content.tsx
+++ b/web/app/components/base/chat/chat/answer/basic-content.tsx
@@ -2,7 +2,7 @@ import type { FC } from 'react'
import { memo } from 'react'
import type { ChatItem } from '../../types'
import { Markdown } from '@/app/components/base/markdown'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type BasicContentProps = {
item: ChatItem
diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx
index a1b458ba9a..fb5b91054f 100644
--- a/web/app/components/base/chat/chat/answer/index.tsx
+++ b/web/app/components/base/chat/chat/answer/index.tsx
@@ -19,7 +19,7 @@ import Citation from '@/app/components/base/chat/chat/citation'
import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item'
import type { AppData } from '@/models/share'
import AnswerIcon from '@/app/components/base/answer-icon'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { FileList } from '@/app/components/base/file-uploader'
import ContentSwitch from '../content-switch'
diff --git a/web/app/components/base/chat/chat/answer/more.tsx b/web/app/components/base/chat/chat/answer/more.tsx
index e86011ea19..9326c6827f 100644
--- a/web/app/components/base/chat/chat/answer/more.tsx
+++ b/web/app/components/base/chat/chat/answer/more.tsx
@@ -18,20 +18,28 @@ const More: FC
= ({
more && (
<>
{`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
{`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
+ {more.tokens_per_second && (
+
+ {`${more.tokens_per_second} tokens/s`}
+
+ )}
·
{more.time}
diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx
index fca0ae5cae..d068d3e108 100644
--- a/web/app/components/base/chat/chat/answer/operation.tsx
+++ b/web/app/components/base/chat/chat/answer/operation.tsx
@@ -26,7 +26,7 @@ import NewAudioButton from '@/app/components/base/new-audio-button'
import Modal from '@/app/components/base/modal/modal'
import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type OperationProps = {
item: ChatItem
diff --git a/web/app/components/base/chat/chat/answer/tool-detail.tsx b/web/app/components/base/chat/chat/answer/tool-detail.tsx
index 26d1b3bbef..6e6710e053 100644
--- a/web/app/components/base/chat/chat/answer/tool-detail.tsx
+++ b/web/app/components/base/chat/chat/answer/tool-detail.tsx
@@ -7,7 +7,7 @@ import {
RiLoader2Line,
} from '@remixicon/react'
import type { ToolInfoInThought } from '../type'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type ToolDetailProps = {
payload: ToolInfoInThought
diff --git a/web/app/components/base/chat/chat/answer/workflow-process.tsx b/web/app/components/base/chat/chat/answer/workflow-process.tsx
index 0537d3c58b..c36f2b8f72 100644
--- a/web/app/components/base/chat/chat/answer/workflow-process.tsx
+++ b/web/app/components/base/chat/chat/answer/workflow-process.tsx
@@ -10,7 +10,7 @@ import {
import { useTranslation } from 'react-i18next'
import type { ChatItem, WorkflowProcess } from '../../types'
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx
index 5004bb2a92..bea1b3890b 100644
--- a/web/app/components/base/chat/chat/chat-input-area/index.tsx
+++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx
@@ -16,7 +16,7 @@ import type { InputForm } from '../type'
import { useCheckInputsForms } from '../check-input-forms-hooks'
import { useTextAreaHeight } from './hooks'
import Operation from './operation'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { FileListInChatInput } from '@/app/components/base/file-uploader'
import { useFile } from '@/app/components/base/file-uploader/hooks'
import {
diff --git a/web/app/components/base/chat/chat/chat-input-area/operation.tsx b/web/app/components/base/chat/chat/chat-input-area/operation.tsx
index 014ca6651f..2c041be90b 100644
--- a/web/app/components/base/chat/chat/chat-input-area/operation.tsx
+++ b/web/app/components/base/chat/chat/chat-input-area/operation.tsx
@@ -12,7 +12,7 @@ import Button from '@/app/components/base/button'
import ActionButton from '@/app/components/base/action-button'
import { FileUploaderInChatInput } from '@/app/components/base/file-uploader'
import type { FileUpload } from '@/app/components/base/features/types'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type OperationProps = {
fileConfig?: FileUpload
diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts
index a10b359724..3729fd4a6d 100644
--- a/web/app/components/base/chat/chat/hooks.ts
+++ b/web/app/components/base/chat/chat/hooks.ts
@@ -318,6 +318,7 @@ export const useChat = (
return player
}
+
ssePost(
url,
{
@@ -393,6 +394,7 @@ export const useChat = (
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
latency: newResponseItem.provider_response_latency.toFixed(2),
+ tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
},
// for agent log
conversationId: conversationId.current,
diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx
index 0e947f8137..19c7b0da52 100644
--- a/web/app/components/base/chat/chat/index.tsx
+++ b/web/app/components/base/chat/chat/index.tsx
@@ -26,7 +26,7 @@ import ChatInputArea from './chat-input-area'
import TryToAsk from './try-to-ask'
import { ChatContextProvider } from './context'
import type { InputForm } from './type'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { Emoji } from '@/app/components/tools/types'
import Button from '@/app/components/base/button'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
diff --git a/web/app/components/base/chat/chat/loading-anim/index.tsx b/web/app/components/base/chat/chat/loading-anim/index.tsx
index 801c89fce7..90cda3da2d 100644
--- a/web/app/components/base/chat/chat/loading-anim/index.tsx
+++ b/web/app/components/base/chat/chat/loading-anim/index.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import s from './style.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type ILoadingAnimProps = {
type: 'text' | 'avatar'
diff --git a/web/app/components/base/chat/chat/question.tsx b/web/app/components/base/chat/chat/question.tsx
index 21b604b969..a36e7ee160 100644
--- a/web/app/components/base/chat/chat/question.tsx
+++ b/web/app/components/base/chat/chat/question.tsx
@@ -21,7 +21,7 @@ import { RiClipboardLine, RiEditLine } from '@remixicon/react'
import Toast from '../../toast'
import copy from 'copy-to-clipboard'
import { useTranslation } from 'react-i18next'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Textarea from 'react-textarea-autosize'
import Button from '../../button'
import { useChatContext } from './context'
diff --git a/web/app/components/base/chat/chat/type.ts b/web/app/components/base/chat/chat/type.ts
index d4cf460884..98cc05dda4 100644
--- a/web/app/components/base/chat/chat/type.ts
+++ b/web/app/components/base/chat/chat/type.ts
@@ -8,6 +8,7 @@ export type MessageMore = {
time: string
tokens: number
latency: number | string
+ tokens_per_second?: number | string
}
export type FeedbackType = {
diff --git a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx
index a07e6217b0..ebd2e2de14 100644
--- a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx
@@ -22,7 +22,7 @@ import LogoAvatar from '@/app/components/base/logo/logo-embedded-chat-avatar'
import AnswerIcon from '@/app/components/base/answer-icon'
import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions'
import { Markdown } from '@/app/components/base/markdown'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { FileEntity } from '../../file-uploader/types'
import Avatar from '../../avatar'
diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx
index 48f6de5725..16e656171e 100644
--- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx
@@ -12,7 +12,7 @@ import ActionButton from '@/app/components/base/action-button'
import Divider from '@/app/components/base/divider'
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
import DifyLogo from '@/app/components/base/logo/dify-logo'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
export type IHeaderProps = {
diff --git a/web/app/components/base/chat/embedded-chatbot/index.tsx b/web/app/components/base/chat/embedded-chatbot/index.tsx
index 1553d1f153..d908e39787 100644
--- a/web/app/components/base/chat/embedded-chatbot/index.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/index.tsx
@@ -17,7 +17,7 @@ import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
import Header from '@/app/components/base/chat/embedded-chatbot/header'
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
import DifyLogo from '@/app/components/base/logo/dify-logo'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import useDocumentTitle from '@/hooks/use-document-title'
import { useGlobalPublicStore } from '@/context/global-public-context'
diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx
index 88472b5d8f..ac1017c619 100644
--- a/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx
@@ -5,7 +5,7 @@ import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
import { useEmbeddedChatbotContext } from '../context'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
collapsed: boolean
diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx
index d6c89864d9..9d2a6d9824 100644
--- a/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx
@@ -7,7 +7,7 @@ import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigge
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
iconColor?: string
diff --git a/web/app/components/base/checkbox-list/index.tsx b/web/app/components/base/checkbox-list/index.tsx
index ca8333a200..efb1b588d1 100644
--- a/web/app/components/base/checkbox-list/index.tsx
+++ b/web/app/components/base/checkbox-list/index.tsx
@@ -3,7 +3,7 @@ import Badge from '@/app/components/base/badge'
import Checkbox from '@/app/components/base/checkbox'
import SearchInput from '@/app/components/base/search-input'
import SearchMenu from '@/assets/search-menu.svg'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Image from 'next/image'
import type { FC } from 'react'
import { useCallback, useMemo, useState } from 'react'
diff --git a/web/app/components/base/checkbox/index.tsx b/web/app/components/base/checkbox/index.tsx
index 9495292ea6..5d222f5723 100644
--- a/web/app/components/base/checkbox/index.tsx
+++ b/web/app/components/base/checkbox/index.tsx
@@ -1,5 +1,5 @@
import { RiCheckLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import IndeterminateIcon from './assets/indeterminate-icon'
type CheckboxProps = {
diff --git a/web/app/components/base/chip/index.tsx b/web/app/components/base/chip/index.tsx
index eeaf2b19c6..919f2e1ab1 100644
--- a/web/app/components/base/chip/index.tsx
+++ b/web/app/components/base/chip/index.tsx
@@ -1,7 +1,7 @@
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { RiArrowDownSLine, RiCheckLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
diff --git a/web/app/components/base/content-dialog/index.tsx b/web/app/components/base/content-dialog/index.tsx
index 5efab57a40..4367744f4d 100644
--- a/web/app/components/base/content-dialog/index.tsx
+++ b/web/app/components/base/content-dialog/index.tsx
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Transition, TransitionChild } from '@headlessui/react'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type ContentDialogProps = {
className?: string
@@ -23,24 +23,20 @@ const ContentDialog = ({
>
-
+ className)}>
{children}
diff --git a/web/app/components/base/corner-label/index.tsx b/web/app/components/base/corner-label/index.tsx
index 0807ed4659..25cd228ba5 100644
--- a/web/app/components/base/corner-label/index.tsx
+++ b/web/app/components/base/corner-label/index.tsx
@@ -1,5 +1,5 @@
import { Corner } from '../icons/src/vender/solid/shapes'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type CornerLabelProps = {
label: string
diff --git a/web/app/components/base/date-and-time-picker/calendar/item.tsx b/web/app/components/base/date-and-time-picker/calendar/item.tsx
index 7132d7bdfb..991ab84043 100644
--- a/web/app/components/base/date-and-time-picker/calendar/item.tsx
+++ b/web/app/components/base/date-and-time-picker/calendar/item.tsx
@@ -1,6 +1,6 @@
import React, { type FC } from 'react'
import type { CalendarItemProps } from '../types'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import dayjs from '../utils/dayjs'
const Item: FC
= ({
diff --git a/web/app/components/base/date-and-time-picker/common/option-list-item.tsx b/web/app/components/base/date-and-time-picker/common/option-list-item.tsx
index 0144a7c6ec..fcb1e5299e 100644
--- a/web/app/components/base/date-and-time-picker/common/option-list-item.tsx
+++ b/web/app/components/base/date-and-time-picker/common/option-list-item.tsx
@@ -1,5 +1,5 @@
import React, { type FC, useEffect, useRef } from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type OptionListItemProps = {
isSelected: boolean
diff --git a/web/app/components/base/date-and-time-picker/date-picker/footer.tsx b/web/app/components/base/date-and-time-picker/date-picker/footer.tsx
index 6351a8235b..9c7136f67a 100644
--- a/web/app/components/base/date-and-time-picker/date-picker/footer.tsx
+++ b/web/app/components/base/date-and-time-picker/date-picker/footer.tsx
@@ -2,7 +2,7 @@ import React, { type FC } from 'react'
import Button from '../../button'
import { type DatePickerFooterProps, ViewType } from '../types'
import { RiTimeLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
const Footer: FC = ({
diff --git a/web/app/components/base/date-and-time-picker/date-picker/index.tsx b/web/app/components/base/date-and-time-picker/date-picker/index.tsx
index db089d10d0..a0ccfa153d 100644
--- a/web/app/components/base/date-and-time-picker/date-picker/index.tsx
+++ b/web/app/components/base/date-and-time-picker/date-picker/index.tsx
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { RiCalendarLine, RiCloseCircleFill } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { DatePickerProps, Period } from '../types'
import { ViewType } from '../types'
import type { Dayjs } from 'dayjs'
diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx
index 9577a107e5..316164bfac 100644
--- a/web/app/components/base/date-and-time-picker/time-picker/index.tsx
+++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx
@@ -18,7 +18,7 @@ import Options from './options'
import Header from './header'
import { useTranslation } from 'react-i18next'
import { RiCloseCircleFill, RiTimeLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import TimezoneLabel from '@/app/components/base/timezone-label'
const to24Hour = (hour12: string, period: Period) => {
diff --git a/web/app/components/base/dialog/index.tsx b/web/app/components/base/dialog/index.tsx
index d4c0f10b40..3a56942537 100644
--- a/web/app/components/base/dialog/index.tsx
+++ b/web/app/components/base/dialog/index.tsx
@@ -1,7 +1,7 @@
import { Fragment, useCallback } from 'react'
import type { ElementType, ReactNode } from 'react'
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
// https://headlessui.com/react/dialog
@@ -35,37 +35,33 @@ const CustomDialog = ({
-
+ 'data-[leave]:opacity-0')} />
-
+ className)}>
{Boolean(title) && (
{title}
)}
-
+
{children}
{Boolean(footer) && (
-
+
{footer}
)}
diff --git a/web/app/components/base/divider/index.tsx b/web/app/components/base/divider/index.tsx
index 387f24a5e9..0fe4af0f1e 100644
--- a/web/app/components/base/divider/index.tsx
+++ b/web/app/components/base/divider/index.tsx
@@ -1,7 +1,7 @@
import type { CSSProperties, FC } from 'react'
import React from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
const dividerVariants = cva('',
{
@@ -29,7 +29,7 @@ export type DividerProps = {
const Divider: FC
= ({ type, bgStyle, className = '', style }) => {
return (
-
+
)
}
diff --git a/web/app/components/base/drawer-plus/index.tsx b/web/app/components/base/drawer-plus/index.tsx
index 33a1948181..4d822a0576 100644
--- a/web/app/components/base/drawer-plus/index.tsx
+++ b/web/app/components/base/drawer-plus/index.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React, { useRef } from 'react'
import { RiCloseLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Drawer from '@/app/components/base/drawer'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
diff --git a/web/app/components/base/drawer/index.tsx b/web/app/components/base/drawer/index.tsx
index 101ac22b6c..dca9f555c9 100644
--- a/web/app/components/base/drawer/index.tsx
+++ b/web/app/components/base/drawer/index.tsx
@@ -3,7 +3,7 @@ import { Dialog, DialogBackdrop, DialogTitle } from '@headlessui/react'
import { useTranslation } from 'react-i18next'
import { XMarkIcon } from '@heroicons/react/24/outline'
import Button from '../button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type IDrawerProps = {
title?: string
diff --git a/web/app/components/base/dropdown/index.tsx b/web/app/components/base/dropdown/index.tsx
index 121fb06000..728f8098c5 100644
--- a/web/app/components/base/dropdown/index.tsx
+++ b/web/app/components/base/dropdown/index.tsx
@@ -1,6 +1,6 @@
import type { FC } from 'react'
import { useState } from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import {
RiMoreFill,
} from '@remixicon/react'
diff --git a/web/app/components/base/effect/index.tsx b/web/app/components/base/effect/index.tsx
index 95afb1ba5f..85fc5a7cd8 100644
--- a/web/app/components/base/effect/index.tsx
+++ b/web/app/components/base/effect/index.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type EffectProps = {
className?: string
diff --git a/web/app/components/base/emoji-picker/Inner.tsx b/web/app/components/base/emoji-picker/Inner.tsx
index 6299ea7aef..c023f26d1c 100644
--- a/web/app/components/base/emoji-picker/Inner.tsx
+++ b/web/app/components/base/emoji-picker/Inner.tsx
@@ -12,7 +12,7 @@ import {
import Input from '@/app/components/base/input'
import Divider from '@/app/components/base/divider'
import { searchEmoji } from '@/utils/emoji'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
init({ data })
diff --git a/web/app/components/base/emoji-picker/index.tsx b/web/app/components/base/emoji-picker/index.tsx
index 7b91c62797..d12393f574 100644
--- a/web/app/components/base/emoji-picker/index.tsx
+++ b/web/app/components/base/emoji-picker/index.tsx
@@ -3,7 +3,7 @@ import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import EmojiPickerInner from './Inner'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
diff --git a/web/app/components/base/encrypted-bottom/index.tsx b/web/app/components/base/encrypted-bottom/index.tsx
index 8416217517..be1862fc1b 100644
--- a/web/app/components/base/encrypted-bottom/index.tsx
+++ b/web/app/components/base/encrypted-bottom/index.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { RiLock2Fill } from '@remixicon/react'
import Link from 'next/link'
import { useTranslation } from 'react-i18next'
diff --git a/web/app/components/base/error-boundary/index.tsx b/web/app/components/base/error-boundary/index.tsx
index e3df2c2ca8..0c226299d0 100644
--- a/web/app/components/base/error-boundary/index.tsx
+++ b/web/app/components/base/error-boundary/index.tsx
@@ -3,7 +3,7 @@ import type { ErrorInfo, ReactNode } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { RiAlertLine, RiBugLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type ErrorBoundaryState = {
hasError: boolean
diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx
index 3050249bb6..ef1fb183c8 100644
--- a/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx
+++ b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx
@@ -41,7 +41,7 @@ const AnnotationCtrlButton: FC = ({
setShowAnnotationFullModal()
return
}
- const res: any = await addAnnotation(appId, {
+ const res = await addAnnotation(appId, {
message_id: messageId,
question: query,
answer,
@@ -50,7 +50,7 @@ const AnnotationCtrlButton: FC = ({
message: t('common.api.actionSuccess') as string,
type: 'success',
})
- onAdded(res.id, res.account?.name)
+ onAdded(res.id, res.account?.name ?? '')
}
return (
diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx
index cc8e125e6b..b97f18ae87 100644
--- a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx
+++ b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx
@@ -1,6 +1,6 @@
import ReactSlider from 'react-slider'
import s from './style.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type ISliderProps = {
className?: string
diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx
index 9d2236c1a4..daf1fe4f70 100644
--- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx
+++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx
@@ -14,7 +14,7 @@ import { getInputKeys } from '@/app/components/base/block-input'
import type { PromptVariable } from '@/models/debug'
import type { InputVar } from '@/app/components/workflow/types'
import { getNewVar } from '@/utils/var'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { noop } from 'lodash-es'
import { checkKeys } from '@/utils/var'
diff --git a/web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx b/web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx
index 9efd072e00..3c82150e01 100644
--- a/web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx
+++ b/web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx
@@ -1,7 +1,7 @@
import { Fragment, useCallback } from 'react'
import type { ReactNode } from 'react'
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type DialogProps = {
className?: string
diff --git a/web/app/components/base/features/new-feature-panel/feature-bar.tsx b/web/app/components/base/features/new-feature-panel/feature-bar.tsx
index bea26d8bb7..b32ef3e4f7 100644
--- a/web/app/components/base/features/new-feature-panel/feature-bar.tsx
+++ b/web/app/components/base/features/new-feature-panel/feature-bar.tsx
@@ -6,7 +6,7 @@ import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import VoiceSettings from '@/app/components/base/features/new-feature-panel/text-to-speech/voice-settings'
import { useFeatures } from '@/app/components/base/features/hooks'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
isChatMode?: boolean
diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx
index ff45a7ea4c..53f5362103 100644
--- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx
+++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx
@@ -23,7 +23,7 @@ import { LanguagesSupported } from '@/i18n-config/language'
import { InfoCircle } from '@/app/components/base/icons/src/vender/line/general'
import { useModalContext } from '@/context/modal-context'
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { noop } from 'lodash-es'
import { useDocLink } from '@/context/i18n'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
index 6b8bf2d567..ab67a0bae0 100644
--- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
+++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
@@ -14,7 +14,7 @@ import AudioBtn from '@/app/components/base/audio-btn'
import { languages } from '@/i18n-config/language'
import { TtsAutoPlay } from '@/types/app'
import type { OnFeaturesChange } from '@/app/components/base/features/types'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useAppVoices } from '@/service/use-apps'
type VoiceParamConfigProps = {
@@ -93,7 +93,7 @@ const VoiceParamConfig = ({
-
+
{languageItem?.name ? t(`common.voice.language.${languageItem?.value.replace('-', '')}`) : localLanguagePlaceholder}
@@ -124,12 +124,10 @@ const VoiceParamConfig = ({
{({ /* active, */ selected }) => (
<>
{t(`common.voice.language.${(item.value).toString().replace('-', '')}`)}
+ className={cn('block', selected && 'font-normal')}>{t(`common.voice.language.${(item.value).toString().replace('-', '')}`)}
{(selected || item.value === text2speech?.language) && (
@@ -161,7 +159,7 @@ const VoiceParamConfig = ({
{voiceItem?.name ?? localVoicePlaceholder}
+ className={cn('block truncate text-left text-text-secondary', !voiceItem?.name && 'text-text-tertiary')}>{voiceItem?.name ?? localVoicePlaceholder}
{({ /* active, */ selected }) => (
<>
- {item.name}
+ {item.name}
{(selected || item.value === text2speech?.voice) && (
diff --git a/web/app/components/base/file-thumb/index.tsx b/web/app/components/base/file-thumb/index.tsx
index 2b9004545a..36d1a91533 100644
--- a/web/app/components/base/file-thumb/index.tsx
+++ b/web/app/components/base/file-thumb/index.tsx
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react'
import ImageRender from './image-render'
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { getFileAppearanceType } from '../file-uploader/utils'
import { FileTypeIcon } from '../file-uploader'
import Tooltip from '../tooltip'
diff --git a/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx b/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx
index 9fae0abafa..b9d22c0325 100644
--- a/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx
+++ b/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx
@@ -15,7 +15,7 @@ import {
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import type { FileUpload } from '@/app/components/base/features/types'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type FileFromLinkOrLocalProps = {
showFromLink?: boolean
diff --git a/web/app/components/base/file-uploader/file-image-render.tsx b/web/app/components/base/file-uploader/file-image-render.tsx
index d6135051dd..ff2e2901e7 100644
--- a/web/app/components/base/file-uploader/file-image-render.tsx
+++ b/web/app/components/base/file-uploader/file-image-render.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type FileImageRenderProps = {
imageUrl: string
diff --git a/web/app/components/base/file-uploader/file-list-in-log.tsx b/web/app/components/base/file-uploader/file-list-in-log.tsx
index 186e8fcc2c..76d5c1412e 100644
--- a/web/app/components/base/file-uploader/file-list-in-log.tsx
+++ b/web/app/components/base/file-uploader/file-list-in-log.tsx
@@ -10,7 +10,7 @@ import {
} from './utils'
import Tooltip from '@/app/components/base/tooltip'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
fileList: {
diff --git a/web/app/components/base/file-uploader/file-type-icon.tsx b/web/app/components/base/file-uploader/file-type-icon.tsx
index 850b08c71f..0d8d69a116 100644
--- a/web/app/components/base/file-uploader/file-type-icon.tsx
+++ b/web/app/components/base/file-uploader/file-type-icon.tsx
@@ -15,7 +15,7 @@ import {
} from '@remixicon/react'
import { FileAppearanceTypeEnum } from './types'
import type { FileAppearanceType } from './types'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
const FILE_TYPE_ICON_MAP = {
[FileAppearanceTypeEnum.pdf]: {
diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx
index b308e8d758..e9e19d46a5 100644
--- a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx
+++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx
@@ -19,7 +19,7 @@ import type { FileEntity } from '../types'
import ActionButton from '@/app/components/base/action-button'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { formatFileSize } from '@/utils/format'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx
index 87a5411eab..936419b25d 100644
--- a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx
+++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx
@@ -16,7 +16,7 @@ import FileInput from '../file-input'
import { useFile } from '../hooks'
import FileItem from './file-item'
import Button from '@/app/components/base/button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { FileUpload } from '@/app/components/base/features/types'
import { TransferMethod } from '@/types/app'
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 667bf7cc15..8f3959dca4 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
@@ -11,7 +11,7 @@ import {
} from '../utils'
import FileTypeIcon from '../file-type-icon'
import type { FileEntity } from '../types'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { formatFileSize } from '@/utils/format'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.tsx
index ba909040c3..7770d07153 100644
--- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.tsx
+++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/file-list.tsx
@@ -5,7 +5,7 @@ import FileImageItem from './file-image-item'
import FileItem from './file-item'
import type { FileUpload } from '@/app/components/base/features/types'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type FileListProps = {
className?: string
diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx
index 7e6e190ddb..291388ca02 100644
--- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx
+++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx
@@ -7,7 +7,7 @@ import {
} from '@remixicon/react'
import FileFromLinkOrLocal from '../file-from-link-or-local'
import ActionButton from '@/app/components/base/action-button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { FileUpload } from '@/app/components/base/features/types'
import { TransferMethod } from '@/types/app'
diff --git a/web/app/components/base/form/components/base/base-field.tsx b/web/app/components/base/form/components/base/base-field.tsx
index db57059b82..c51da5fc06 100644
--- a/web/app/components/base/form/components/base/base-field.tsx
+++ b/web/app/components/base/form/components/base/base-field.tsx
@@ -8,7 +8,7 @@ import PureSelect from '@/app/components/base/select/pure'
import Tooltip from '@/app/components/base/tooltip'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { RiExternalLinkLine } from '@remixicon/react'
import type { AnyFieldApi } from '@tanstack/react-form'
import { useStore } from '@tanstack/react-form'
diff --git a/web/app/components/base/form/components/base/base-form.tsx b/web/app/components/base/form/components/base/base-form.tsx
index 0d35380523..4cf9ab52ec 100644
--- a/web/app/components/base/form/components/base/base-form.tsx
+++ b/web/app/components/base/form/components/base/base-form.tsx
@@ -26,7 +26,7 @@ import {
import type {
BaseFieldProps,
} from '.'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import {
useGetFormValues,
useGetValidators,
diff --git a/web/app/components/base/form/components/field/checkbox.tsx b/web/app/components/base/form/components/field/checkbox.tsx
index 855dbd80fe..526e8e3853 100644
--- a/web/app/components/base/form/components/field/checkbox.tsx
+++ b/web/app/components/base/form/components/field/checkbox.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useFieldContext } from '../..'
import Checkbox from '../../../checkbox'
diff --git a/web/app/components/base/form/components/field/custom-select.tsx b/web/app/components/base/form/components/field/custom-select.tsx
index 0e605184dc..fb6bb18e1b 100644
--- a/web/app/components/base/form/components/field/custom-select.tsx
+++ b/web/app/components/base/form/components/field/custom-select.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useFieldContext } from '../..'
import type { CustomSelectProps, Option } from '../../../select/custom'
import CustomSelect from '../../../select/custom'
diff --git a/web/app/components/base/form/components/field/file-types.tsx b/web/app/components/base/form/components/field/file-types.tsx
index 44c77dc894..2a05a7035b 100644
--- a/web/app/components/base/form/components/field/file-types.tsx
+++ b/web/app/components/base/form/components/field/file-types.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { LabelProps } from '../label'
import { useFieldContext } from '../..'
import Label from '../label'
diff --git a/web/app/components/base/form/components/field/file-uploader.tsx b/web/app/components/base/form/components/field/file-uploader.tsx
index 2e4e26b5d6..3e447702d5 100644
--- a/web/app/components/base/form/components/field/file-uploader.tsx
+++ b/web/app/components/base/form/components/field/file-uploader.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import { useFieldContext } from '../..'
import type { LabelProps } from '../label'
import Label from '../label'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { FileUploaderInAttachmentWrapperProps } from '../../../file-uploader/file-uploader-in-attachment'
import FileUploaderInAttachmentWrapper from '../../../file-uploader/file-uploader-in-attachment'
import type { FileEntity } from '../../../file-uploader/types'
diff --git a/web/app/components/base/form/components/field/input-type-select/index.tsx b/web/app/components/base/form/components/field/input-type-select/index.tsx
index 256fd872d2..d3961e158c 100644
--- a/web/app/components/base/form/components/field/input-type-select/index.tsx
+++ b/web/app/components/base/form/components/field/input-type-select/index.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useFieldContext } from '../../..'
import type { CustomSelectProps } from '../../../../select/custom'
import CustomSelect from '../../../../select/custom'
diff --git a/web/app/components/base/form/components/field/input-type-select/trigger.tsx b/web/app/components/base/form/components/field/input-type-select/trigger.tsx
index d71f3b7628..270ead6001 100644
--- a/web/app/components/base/form/components/field/input-type-select/trigger.tsx
+++ b/web/app/components/base/form/components/field/input-type-select/trigger.tsx
@@ -1,6 +1,6 @@
import React from 'react'
import Badge from '@/app/components/base/badge'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { RiArrowDownSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { FileTypeSelectOption } from './types'
diff --git a/web/app/components/base/form/components/field/mixed-variable-text-input/index.tsx b/web/app/components/base/form/components/field/mixed-variable-text-input/index.tsx
index 4bb562ba3a..c8614db7dd 100644
--- a/web/app/components/base/form/components/field/mixed-variable-text-input/index.tsx
+++ b/web/app/components/base/form/components/field/mixed-variable-text-input/index.tsx
@@ -2,7 +2,7 @@ import {
memo,
} from 'react'
import PromptEditor from '@/app/components/base/prompt-editor'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Placeholder from './placeholder'
type MixedVariableTextInputProps = {
diff --git a/web/app/components/base/form/components/field/number-input.tsx b/web/app/components/base/form/components/field/number-input.tsx
index 46d2ced8b6..5bb3494ed8 100644
--- a/web/app/components/base/form/components/field/number-input.tsx
+++ b/web/app/components/base/form/components/field/number-input.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import { useFieldContext } from '../..'
import type { LabelProps } from '../label'
import Label from '../label'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { InputNumberProps } from '../../../input-number'
import { InputNumber } from '../../../input-number'
diff --git a/web/app/components/base/form/components/field/number-slider.tsx b/web/app/components/base/form/components/field/number-slider.tsx
index 1e8fc4e912..0dbffb7578 100644
--- a/web/app/components/base/form/components/field/number-slider.tsx
+++ b/web/app/components/base/form/components/field/number-slider.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { LabelProps } from '../label'
import { useFieldContext } from '../..'
import Label from '../label'
diff --git a/web/app/components/base/form/components/field/options.tsx b/web/app/components/base/form/components/field/options.tsx
index 9ba9c4d398..6cfffc3c43 100644
--- a/web/app/components/base/form/components/field/options.tsx
+++ b/web/app/components/base/form/components/field/options.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useFieldContext } from '../..'
import type { LabelProps } from '../label'
import Label from '../label'
diff --git a/web/app/components/base/form/components/field/select.tsx b/web/app/components/base/form/components/field/select.tsx
index 8a36a49510..be6337e42c 100644
--- a/web/app/components/base/form/components/field/select.tsx
+++ b/web/app/components/base/form/components/field/select.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useFieldContext } from '../..'
import type { Option, PureSelectProps } from '../../../select/pure'
import PureSelect from '../../../select/pure'
diff --git a/web/app/components/base/form/components/field/text-area.tsx b/web/app/components/base/form/components/field/text-area.tsx
index 2392d0609e..675f73d69c 100644
--- a/web/app/components/base/form/components/field/text-area.tsx
+++ b/web/app/components/base/form/components/field/text-area.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import { useFieldContext } from '../..'
import type { LabelProps } from '../label'
import Label from '../label'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { TextareaProps } from '../../../textarea'
import Textarea from '../../../textarea'
diff --git a/web/app/components/base/form/components/field/text.tsx b/web/app/components/base/form/components/field/text.tsx
index ed6cab9423..9a18ee4db6 100644
--- a/web/app/components/base/form/components/field/text.tsx
+++ b/web/app/components/base/form/components/field/text.tsx
@@ -3,7 +3,7 @@ import { useFieldContext } from '../..'
import Input, { type InputProps } from '../../../input'
import type { LabelProps } from '../label'
import Label from '../label'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type TextFieldProps = {
label: string
diff --git a/web/app/components/base/form/components/field/upload-method.tsx b/web/app/components/base/form/components/field/upload-method.tsx
index 8aac32f638..8ec619ba00 100644
--- a/web/app/components/base/form/components/field/upload-method.tsx
+++ b/web/app/components/base/form/components/field/upload-method.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { LabelProps } from '../label'
import { useFieldContext } from '../..'
import Label from '../label'
diff --git a/web/app/components/base/form/components/field/variable-or-constant-input.tsx b/web/app/components/base/form/components/field/variable-or-constant-input.tsx
index b8a96c5401..78a1bb0c8e 100644
--- a/web/app/components/base/form/components/field/variable-or-constant-input.tsx
+++ b/web/app/components/base/form/components/field/variable-or-constant-input.tsx
@@ -1,7 +1,7 @@
import type { ChangeEvent } from 'react'
import { useCallback, useState } from 'react'
import { RiEditLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import SegmentedControl from '@/app/components/base/segmented-control'
import { VariableX } from '@/app/components/base/icons/src/vender/workflow'
import Input from '@/app/components/base/input'
diff --git a/web/app/components/base/form/components/field/variable-selector.tsx b/web/app/components/base/form/components/field/variable-selector.tsx
index 3c4042b118..c945eb93c6 100644
--- a/web/app/components/base/form/components/field/variable-selector.tsx
+++ b/web/app/components/base/form/components/field/variable-selector.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import type { LabelProps } from '../label'
import Label from '../label'
diff --git a/web/app/components/base/form/components/label.tsx b/web/app/components/base/form/components/label.tsx
index 4b104c9dc6..47b74d28e0 100644
--- a/web/app/components/base/form/components/label.tsx
+++ b/web/app/components/base/form/components/label.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Tooltip from '../../tooltip'
import { useTranslation } from 'react-i18next'
diff --git a/web/app/components/base/fullscreen-modal/index.tsx b/web/app/components/base/fullscreen-modal/index.tsx
index ba96ae13bd..b6a1ee8a32 100644
--- a/web/app/components/base/fullscreen-modal/index.tsx
+++ b/web/app/components/base/fullscreen-modal/index.tsx
@@ -1,6 +1,6 @@
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
import { RiCloseLargeLine } from '@remixicon/react'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { noop } from 'lodash-es'
type IModal = {
@@ -26,14 +26,12 @@ export default function FullScreenModal({
}: IModal) {
return (
-
+
-
+ 'data-[leave]:opacity-0')} />
-
+ className)}>
{closable
&& = ({
gradientClassName,
}) => {
return (
-
-
-
+
)
diff --git a/web/app/components/base/icons/script.mjs b/web/app/components/base/icons/script.mjs
index 764bbf1987..f2bf43d930 100644
--- a/web/app/components/base/icons/script.mjs
+++ b/web/app/components/base/icons/script.mjs
@@ -113,7 +113,7 @@ const generateImageComponent = async (entry, pathList) => {
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import s from './<%= fileName %>.module.css'
const Icon = (
diff --git a/web/app/components/base/icons/src/image/llm/BaichuanTextCn.tsx b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.tsx
index be9a407eb2..7c2f24b6b7 100644
--- a/web/app/components/base/icons/src/image/llm/BaichuanTextCn.tsx
+++ b/web/app/components/base/icons/src/image/llm/BaichuanTextCn.tsx
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import s from './BaichuanTextCn.module.css'
const Icon = (
diff --git a/web/app/components/base/icons/src/image/llm/Minimax.tsx b/web/app/components/base/icons/src/image/llm/Minimax.tsx
index 7df7e3fcbc..9a4f81e374 100644
--- a/web/app/components/base/icons/src/image/llm/Minimax.tsx
+++ b/web/app/components/base/icons/src/image/llm/Minimax.tsx
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import s from './Minimax.module.css'
const Icon = (
diff --git a/web/app/components/base/icons/src/image/llm/MinimaxText.tsx b/web/app/components/base/icons/src/image/llm/MinimaxText.tsx
index 840e8cb439..a11210a9c0 100644
--- a/web/app/components/base/icons/src/image/llm/MinimaxText.tsx
+++ b/web/app/components/base/icons/src/image/llm/MinimaxText.tsx
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import s from './MinimaxText.module.css'
const Icon = (
diff --git a/web/app/components/base/icons/src/image/llm/Tongyi.tsx b/web/app/components/base/icons/src/image/llm/Tongyi.tsx
index 2f62f1a355..966a99e041 100644
--- a/web/app/components/base/icons/src/image/llm/Tongyi.tsx
+++ b/web/app/components/base/icons/src/image/llm/Tongyi.tsx
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import s from './Tongyi.module.css'
const Icon = (
diff --git a/web/app/components/base/icons/src/image/llm/TongyiText.tsx b/web/app/components/base/icons/src/image/llm/TongyiText.tsx
index a52f63c248..e82fcc6361 100644
--- a/web/app/components/base/icons/src/image/llm/TongyiText.tsx
+++ b/web/app/components/base/icons/src/image/llm/TongyiText.tsx
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import s from './TongyiText.module.css'
const Icon = (
diff --git a/web/app/components/base/icons/src/image/llm/TongyiTextCn.tsx b/web/app/components/base/icons/src/image/llm/TongyiTextCn.tsx
index c982c73aed..8fb41b60d1 100644
--- a/web/app/components/base/icons/src/image/llm/TongyiTextCn.tsx
+++ b/web/app/components/base/icons/src/image/llm/TongyiTextCn.tsx
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import s from './TongyiTextCn.module.css'
const Icon = (
diff --git a/web/app/components/base/icons/src/image/llm/Wxyy.tsx b/web/app/components/base/icons/src/image/llm/Wxyy.tsx
index a3c494811e..988c289215 100644
--- a/web/app/components/base/icons/src/image/llm/Wxyy.tsx
+++ b/web/app/components/base/icons/src/image/llm/Wxyy.tsx
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import s from './Wxyy.module.css'
const Icon = (
diff --git a/web/app/components/base/icons/src/image/llm/WxyyText.tsx b/web/app/components/base/icons/src/image/llm/WxyyText.tsx
index e5dd6e8803..87402fd856 100644
--- a/web/app/components/base/icons/src/image/llm/WxyyText.tsx
+++ b/web/app/components/base/icons/src/image/llm/WxyyText.tsx
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import s from './WxyyText.module.css'
const Icon = (
diff --git a/web/app/components/base/icons/src/image/llm/WxyyTextCn.tsx b/web/app/components/base/icons/src/image/llm/WxyyTextCn.tsx
index 32108adab4..f7d6464c13 100644
--- a/web/app/components/base/icons/src/image/llm/WxyyTextCn.tsx
+++ b/web/app/components/base/icons/src/image/llm/WxyyTextCn.tsx
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import s from './WxyyTextCn.module.css'
const Icon = (
diff --git a/web/app/components/base/image-gallery/index.tsx b/web/app/components/base/image-gallery/index.tsx
index fdb9711292..6bef84a724 100644
--- a/web/app/components/base/image-gallery/index.tsx
+++ b/web/app/components/base/image-gallery/index.tsx
@@ -1,6 +1,6 @@
'use client'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { FC } from 'react'
import React, { useState } from 'react'
import s from './style.module.css'
diff --git a/web/app/components/base/image-uploader/chat-image-uploader.tsx b/web/app/components/base/image-uploader/chat-image-uploader.tsx
index bc563b80e3..6e2503dfbe 100644
--- a/web/app/components/base/image-uploader/chat-image-uploader.tsx
+++ b/web/app/components/base/image-uploader/chat-image-uploader.tsx
@@ -3,7 +3,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from './uploader'
import ImageLinkInput from './image-link-input'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
import { TransferMethod } from '@/types/app'
import {
diff --git a/web/app/components/base/image-uploader/image-list.tsx b/web/app/components/base/image-uploader/image-list.tsx
index 3b5f6dee9c..fe88bdc68f 100644
--- a/web/app/components/base/image-uploader/image-list.tsx
+++ b/web/app/components/base/image-uploader/image-list.tsx
@@ -5,7 +5,7 @@ import {
RiCloseLine,
RiLoader2Line,
} from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
diff --git a/web/app/components/base/inline-delete-confirm/index.tsx b/web/app/components/base/inline-delete-confirm/index.tsx
index eb671609cf..22942a179a 100644
--- a/web/app/components/base/inline-delete-confirm/index.tsx
+++ b/web/app/components/base/inline-delete-confirm/index.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type InlineDeleteConfirmProps = {
title?: string
diff --git a/web/app/components/base/input-number/index.tsx b/web/app/components/base/input-number/index.tsx
index a5a171a9fc..5726bb8bbd 100644
--- a/web/app/components/base/input-number/index.tsx
+++ b/web/app/components/base/input-number/index.tsx
@@ -1,7 +1,7 @@
import { type FC, useCallback } from 'react'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import Input, { type InputProps } from '../input'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type InputNumberProps = {
unit?: string
@@ -81,11 +81,11 @@ export const InputNumber: FC
= (props) => {
onChange(parsed)
}, [isValidValue, onChange])
- return
+ return
= (props) => {
unit={unit}
size={size}
/>
-
@@ -104,12 +103,10 @@ export const InputNumber: FC
= (props) => {
onClick={inc}
disabled={disabled}
aria-label='increment'
- className={classNames(
- size === 'regular' ? 'pt-1' : 'pt-1.5',
+ className={cn(size === 'regular' ? 'pt-1' : 'pt-1.5',
'px-1.5 hover:bg-components-input-bg-hover',
disabled && 'cursor-not-allowed hover:bg-transparent',
- controlClassName,
- )}
+ controlClassName)}
>
@@ -118,12 +115,10 @@ export const InputNumber: FC = (props) => {
onClick={dec}
disabled={disabled}
aria-label='decrement'
- className={classNames(
- size === 'regular' ? 'pb-1' : 'pb-1.5',
+ className={cn(size === 'regular' ? 'pb-1' : 'pb-1.5',
'px-1.5 hover:bg-components-input-bg-hover',
disabled && 'cursor-not-allowed hover:bg-transparent',
- controlClassName,
- )}
+ controlClassName)}
>
diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx
index 87b7de5005..6c0c7e45aa 100644
--- a/web/app/components/base/input-with-copy/index.tsx
+++ b/web/app/components/base/input-with-copy/index.tsx
@@ -7,7 +7,7 @@ import copy from 'copy-to-clipboard'
import type { InputProps } from '../input'
import Tooltip from '../tooltip'
import ActionButton from '../action-button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type InputWithCopyProps = {
showCopyButton?: boolean
diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx
index 60f80d560b..8171b6f9b8 100644
--- a/web/app/components/base/input/index.tsx
+++ b/web/app/components/base/input/index.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react'
import { type VariantProps, cva } from 'class-variance-authority'
import { noop } from 'lodash-es'
diff --git a/web/app/components/base/linked-apps-panel/index.tsx b/web/app/components/base/linked-apps-panel/index.tsx
index 561bd49c2a..7fa8d9c1e9 100644
--- a/web/app/components/base/linked-apps-panel/index.tsx
+++ b/web/app/components/base/linked-apps-panel/index.tsx
@@ -3,7 +3,7 @@ import type { FC } from 'react'
import React from 'react'
import Link from 'next/link'
import { RiArrowRightUpLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import AppIcon from '@/app/components/base/app-icon'
import type { RelatedApp } from '@/models/datasets'
import { AppModeEnum } from '@/types/app'
diff --git a/web/app/components/base/logo/dify-logo.tsx b/web/app/components/base/logo/dify-logo.tsx
index 5369144e1c..765d0fedf7 100644
--- a/web/app/components/base/logo/dify-logo.tsx
+++ b/web/app/components/base/logo/dify-logo.tsx
@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import useTheme from '@/hooks/use-theme'
import { basePath } from '@/utils/var'
export type LogoStyle = 'default' | 'monochromeWhite'
@@ -35,7 +35,7 @@ const DifyLogo: FC = ({
return (
)
diff --git a/web/app/components/base/logo/logo-embedded-chat-header.tsx b/web/app/components/base/logo/logo-embedded-chat-header.tsx
index 38451abc5e..a580ad944f 100644
--- a/web/app/components/base/logo/logo-embedded-chat-header.tsx
+++ b/web/app/components/base/logo/logo-embedded-chat-header.tsx
@@ -1,4 +1,4 @@
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { FC } from 'react'
import { basePath } from '@/utils/var'
@@ -16,7 +16,7 @@ const LogoEmbeddedChatHeader: FC = ({
}
diff --git a/web/app/components/base/logo/logo-site.tsx b/web/app/components/base/logo/logo-site.tsx
index fd606ee8c3..3795a072c8 100644
--- a/web/app/components/base/logo/logo-site.tsx
+++ b/web/app/components/base/logo/logo-site.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import { basePath } from '@/utils/var'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type LogoSiteProps = {
className?: string
@@ -13,7 +13,7 @@ const LogoSite: FC = ({
return (
)
diff --git a/web/app/components/base/markdown-blocks/button.tsx b/web/app/components/base/markdown-blocks/button.tsx
index 315653bcd0..1036315842 100644
--- a/web/app/components/base/markdown-blocks/button.tsx
+++ b/web/app/components/base/markdown-blocks/button.tsx
@@ -1,6 +1,6 @@
import { useChatContext } from '@/app/components/base/chat/chat/context'
import Button from '@/app/components/base/button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { isValidUrl } from './utils'
const MarkdownButton = ({ node }: any) => {
const { onSend } = useChatContext()
diff --git a/web/app/components/base/markdown-blocks/think-block.tsx b/web/app/components/base/markdown-blocks/think-block.tsx
index 9c43578e4c..b345612ebc 100644
--- a/web/app/components/base/markdown-blocks/think-block.tsx
+++ b/web/app/components/base/markdown-blocks/think-block.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useChatContext } from '../chat/chat/context'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
const hasEndThink = (children: any): boolean => {
if (typeof children === 'string')
diff --git a/web/app/components/base/markdown/index.tsx b/web/app/components/base/markdown/index.tsx
index bb49fe1b14..2c881dc2eb 100644
--- a/web/app/components/base/markdown/index.tsx
+++ b/web/app/components/base/markdown/index.tsx
@@ -1,7 +1,7 @@
import dynamic from 'next/dynamic'
import 'katex/dist/katex.min.css'
import { flow } from 'lodash-es'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { preprocessLaTeX, preprocessThinkTag } from './markdown-utils'
import type { ReactMarkdownWrapperProps, SimplePluginInfo } from './react-markdown-wrapper'
diff --git a/web/app/components/base/mermaid/index.tsx b/web/app/components/base/mermaid/index.tsx
index bf35c8c94c..73709bdc8e 100644
--- a/web/app/components/base/mermaid/index.tsx
+++ b/web/app/components/base/mermaid/index.tsx
@@ -8,11 +8,12 @@ import {
isMermaidCodeComplete,
prepareMermaidCode,
processSvgForTheme,
+ sanitizeMermaidCode,
svgToBase64,
waitForDOMElement,
} from './utils'
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import { Theme } from '@/types/app'
@@ -71,7 +72,7 @@ const initMermaid = () => {
const config: MermaidConfig = {
startOnLoad: false,
fontFamily: 'sans-serif',
- securityLevel: 'loose',
+ securityLevel: 'strict',
flowchart: {
htmlLabels: true,
useMaxWidth: true,
@@ -267,6 +268,8 @@ const Flowchart = (props: FlowchartProps) => {
finalCode = prepareMermaidCode(primitiveCode, look)
}
+ finalCode = sanitizeMermaidCode(finalCode)
+
// Step 2: Render chart
const svgGraph = await renderMermaidChart(finalCode, look)
@@ -297,9 +300,9 @@ const Flowchart = (props: FlowchartProps) => {
const configureMermaid = useCallback((primitiveCode: string) => {
if (typeof window !== 'undefined' && isInitialized) {
const themeVars = THEMES[currentTheme]
- const config: any = {
+ const config: MermaidConfig = {
startOnLoad: false,
- securityLevel: 'loose',
+ securityLevel: 'strict',
fontFamily: 'sans-serif',
maxTextSize: 50000,
gantt: {
@@ -325,7 +328,8 @@ const Flowchart = (props: FlowchartProps) => {
config.theme = currentTheme === 'dark' ? 'dark' : 'neutral'
if (isFlowchart) {
- config.flowchart = {
+ type FlowchartConfigWithRanker = NonNullable & { ranker?: string }
+ const flowchartConfig: FlowchartConfigWithRanker = {
htmlLabels: true,
useMaxWidth: true,
nodeSpacing: 60,
@@ -333,6 +337,7 @@ const Flowchart = (props: FlowchartProps) => {
curve: 'linear',
ranker: 'tight-tree',
}
+ config.flowchart = flowchartConfig as unknown as MermaidConfig['flowchart']
}
if (currentTheme === 'dark') {
@@ -531,7 +536,7 @@ const Flowchart = (props: FlowchartProps) => {
{isLoading && !svgString && (
-
+
{t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
@@ -564,7 +569,7 @@ const Flowchart = (props: FlowchartProps) => {
{errMsg && (
diff --git a/web/app/components/base/mermaid/utils.spec.ts b/web/app/components/base/mermaid/utils.spec.ts
index 6ea7f17bfa..7a73aa1fc9 100644
--- a/web/app/components/base/mermaid/utils.spec.ts
+++ b/web/app/components/base/mermaid/utils.spec.ts
@@ -1,4 +1,4 @@
-import { cleanUpSvgCode } from './utils'
+import { cleanUpSvgCode, prepareMermaidCode, sanitizeMermaidCode } from './utils'
describe('cleanUpSvgCode', () => {
it('replaces old-style
tags with the new style', () => {
@@ -6,3 +6,54 @@ describe('cleanUpSvgCode', () => {
expect(result).toEqual('
test
')
})
})
+
+describe('sanitizeMermaidCode', () => {
+ it('removes click directives to prevent link/callback injection', () => {
+ const unsafeProtocol = ['java', 'script:'].join('')
+ const input = [
+ 'gantt',
+ 'title Demo',
+ 'section S1',
+ 'Task 1 :a1, 2020-01-01, 1d',
+ `click A href "${unsafeProtocol}alert(location.href)"`,
+ 'click B call callback()',
+ ].join('\n')
+
+ const result = sanitizeMermaidCode(input)
+
+ expect(result).toContain('gantt')
+ expect(result).toContain('Task 1')
+ expect(result).not.toContain('click A')
+ expect(result).not.toContain('click B')
+ expect(result).not.toContain(unsafeProtocol)
+ })
+
+ it('removes Mermaid init directives to prevent config overrides', () => {
+ const input = [
+ '%%{init: {"securityLevel":"loose"}}%%',
+ 'graph TD',
+ 'A-->B',
+ ].join('\n')
+
+ const result = sanitizeMermaidCode(input)
+
+ expect(result).toEqual(['graph TD', 'A-->B'].join('\n'))
+ })
+})
+
+describe('prepareMermaidCode', () => {
+ it('sanitizes click directives in flowcharts', () => {
+ const unsafeProtocol = ['java', 'script:'].join('')
+ const input = [
+ 'graph TD',
+ 'A[Click]-->B',
+ `click A href "${unsafeProtocol}alert(1)"`,
+ ].join('\n')
+
+ const result = prepareMermaidCode(input, 'classic')
+
+ expect(result).toContain('graph TD')
+ expect(result).not.toContain('click ')
+ expect(result).not.toContain(unsafeProtocol)
+ })
+})
diff --git a/web/app/components/base/mermaid/utils.ts b/web/app/components/base/mermaid/utils.ts
index 7e59869de1..e4abed3e44 100644
--- a/web/app/components/base/mermaid/utils.ts
+++ b/web/app/components/base/mermaid/utils.ts
@@ -2,6 +2,28 @@ export function cleanUpSvgCode(svgCode: string): string {
return svgCode.replaceAll('
', '
')
}
+export const sanitizeMermaidCode = (mermaidCode: string): string => {
+ if (!mermaidCode || typeof mermaidCode !== 'string')
+ return ''
+
+ return mermaidCode
+ .split('\n')
+ .filter((line) => {
+ const trimmed = line.trimStart()
+
+ // Mermaid directives can override config; treat as untrusted in chat context.
+ if (trimmed.startsWith('%%{'))
+ return false
+
+ // Mermaid click directives can create JS callbacks/links inside rendered SVG.
+ if (trimmed.startsWith('click '))
+ return false
+
+ return true
+ })
+ .join('\n')
+}
+
/**
* Prepares mermaid code for rendering by sanitizing common syntax issues.
* @param {string} mermaidCode - The mermaid code to prepare
@@ -12,10 +34,7 @@ export const prepareMermaidCode = (mermaidCode: string, style: 'classic' | 'hand
if (!mermaidCode || typeof mermaidCode !== 'string')
return ''
- let code = mermaidCode.trim()
-
- // Security: Sanitize against javascript: protocol in click events (XSS vector)
- code = code.replace(/(\bclick\s+\w+\s+")javascript:[^"]*(")/g, '$1#$2')
+ let code = sanitizeMermaidCode(mermaidCode.trim())
// Convenience: Basic BR replacement. This is a common and safe operation.
code = code.replace(/
/g, '\n')
diff --git a/web/app/components/base/message-log-modal/index.tsx b/web/app/components/base/message-log-modal/index.tsx
index 12746f5982..d57a191953 100644
--- a/web/app/components/base/message-log-modal/index.tsx
+++ b/web/app/components/base/message-log-modal/index.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState } from 'react'
import { useClickAway } from 'ahooks'
import { RiCloseLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import Run from '@/app/components/workflow/run'
import { useStore } from '@/app/components/app/store'
diff --git a/web/app/components/base/modal-like-wrap/index.tsx b/web/app/components/base/modal-like-wrap/index.tsx
index cf18ef13cd..ecbcd503d1 100644
--- a/web/app/components/base/modal-like-wrap/index.tsx
+++ b/web/app/components/base/modal-like-wrap/index.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
import Button from '../button'
import { RiCloseLine } from '@remixicon/react'
diff --git a/web/app/components/base/modal/index.tsx b/web/app/components/base/modal/index.tsx
index f091717191..6ae1f299a0 100644
--- a/web/app/components/base/modal/index.tsx
+++ b/web/app/components/base/modal/index.tsx
@@ -1,7 +1,7 @@
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { Fragment } from 'react'
import { RiCloseLine } from '@remixicon/react'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { noop } from 'lodash-es'
// https://headlessui.com/react/dialog
@@ -38,15 +38,13 @@ export default function Modal({
}: IModal) {
return (
-
+
-
+ 'data-[leave]:opacity-0')} />
-
+
-
+ className)}>
{title &&
{message ?? defaultMessage}
{children}
diff --git a/web/app/components/base/notion-icon/index.tsx b/web/app/components/base/notion-icon/index.tsx
index 75fea8c378..9dbb909332 100644
--- a/web/app/components/base/notion-icon/index.tsx
+++ b/web/app/components/base/notion-icon/index.tsx
@@ -1,5 +1,5 @@
import { RiFileTextLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { DataSourceNotionPage } from '@/models/common'
type IconTypes = 'workspace' | 'page'
diff --git a/web/app/components/base/notion-page-selector/page-selector/index.tsx b/web/app/components/base/notion-page-selector/page-selector/index.tsx
index 3541997c67..c3c5636b0c 100644
--- a/web/app/components/base/notion-page-selector/page-selector/index.tsx
+++ b/web/app/components/base/notion-page-selector/page-selector/index.tsx
@@ -5,7 +5,7 @@ import type { ListChildComponentProps } from 'react-window'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import Checkbox from '../../checkbox'
import NotionIcon from '../../notion-icon'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
type PageSelectorProps = {
diff --git a/web/app/components/base/notion-page-selector/search-input/index.tsx b/web/app/components/base/notion-page-selector/search-input/index.tsx
index 6bf819e148..b5035ff483 100644
--- a/web/app/components/base/notion-page-selector/search-input/index.tsx
+++ b/web/app/components/base/notion-page-selector/search-input/index.tsx
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import type { ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseCircleFill, RiSearchLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type SearchInputProps = {
value: string
diff --git a/web/app/components/base/pagination/index.tsx b/web/app/components/base/pagination/index.tsx
index e0c02df253..54848b54fc 100644
--- a/web/app/components/base/pagination/index.tsx
+++ b/web/app/components/base/pagination/index.tsx
@@ -6,7 +6,7 @@ import { useDebounceFn } from 'ahooks'
import { Pagination } from './pagination'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type Props = {
className?: string
diff --git a/web/app/components/base/pagination/pagination.tsx b/web/app/components/base/pagination/pagination.tsx
index 07ace7bcf2..733ba5dd82 100644
--- a/web/app/components/base/pagination/pagination.tsx
+++ b/web/app/components/base/pagination/pagination.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import cn from 'classnames'
+import { cn } from '@/utils/classnames'
import usePagination from './hook'
import type {
ButtonProps,
diff --git a/web/app/components/base/popover/index.tsx b/web/app/components/base/popover/index.tsx
index 2387737d02..fb7e86ebce 100644
--- a/web/app/components/base/popover/index.tsx
+++ b/web/app/components/base/popover/index.tsx
@@ -1,6 +1,6 @@
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react'
import { Fragment, cloneElement, isValidElement, useRef } from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type HtmlContentProps = {
open?: boolean
diff --git a/web/app/components/base/portal-to-follow-elem/index.tsx b/web/app/components/base/portal-to-follow-elem/index.tsx
index e1192fe73b..685ac33a0b 100644
--- a/web/app/components/base/portal-to-follow-elem/index.tsx
+++ b/web/app/components/base/portal-to-follow-elem/index.tsx
@@ -17,7 +17,7 @@ import {
} from '@floating-ui/react'
import type { OffsetOptions, Placement } from '@floating-ui/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type PortalToFollowElemOptions = {
/*
* top, bottom, left, right
diff --git a/web/app/components/base/premium-badge/index.tsx b/web/app/components/base/premium-badge/index.tsx
index 7bf85cdcc3..c10ed8d0b7 100644
--- a/web/app/components/base/premium-badge/index.tsx
+++ b/web/app/components/base/premium-badge/index.tsx
@@ -2,7 +2,7 @@ import type { CSSProperties, ReactNode } from 'react'
import React from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import { Highlight } from '@/app/components/base/icons/src/public/common'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import './index.css'
const PremiumBadgeVariants = cva(
@@ -52,19 +52,15 @@ const PremiumBadge: React.FC = ({
}) => {
return (
{children}
)
diff --git a/web/app/components/base/progress-bar/progress-circle.tsx b/web/app/components/base/progress-bar/progress-circle.tsx
index b9b280eea3..1123e412e3 100644
--- a/web/app/components/base/progress-bar/progress-circle.tsx
+++ b/web/app/components/base/progress-bar/progress-circle.tsx
@@ -1,5 +1,5 @@
import { memo } from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type ProgressCircleProps = {
className?: string
diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx
index 50fdc1f920..eb780d90a4 100644
--- a/web/app/components/base/prompt-editor/index.tsx
+++ b/web/app/components/base/prompt-editor/index.tsx
@@ -78,7 +78,7 @@ import {
UPDATE_HISTORY_EVENT_EMITTER,
} from './constants'
import { useEventEmitterContextContext } from '@/context/event-emitter'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type PromptEditorProps = {
instanceId?: string
diff --git a/web/app/components/base/prompt-editor/plugins/current-block/component.tsx b/web/app/components/base/prompt-editor/plugins/current-block/component.tsx
index 1d1cb7bde5..b01fe0626b 100644
--- a/web/app/components/base/prompt-editor/plugins/current-block/component.tsx
+++ b/web/app/components/base/prompt-editor/plugins/current-block/component.tsx
@@ -3,7 +3,7 @@ import { GeneratorType } from '@/app/components/app/configuration/config/automat
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useSelectOrDelete } from '../../hooks'
import { CurrentBlockNode, DELETE_CURRENT_BLOCK_COMMAND } from '.'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { CodeAssistant, MagicEdit } from '../../../icons/src/vender/line/general'
type CurrentBlockComponentProps = {
diff --git a/web/app/components/base/prompt-editor/plugins/error-message-block/component.tsx b/web/app/components/base/prompt-editor/plugins/error-message-block/component.tsx
index 3fc4db2323..1f98c51cb5 100644
--- a/web/app/components/base/prompt-editor/plugins/error-message-block/component.tsx
+++ b/web/app/components/base/prompt-editor/plugins/error-message-block/component.tsx
@@ -2,7 +2,7 @@ import { type FC, useEffect } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useSelectOrDelete } from '../../hooks'
import { DELETE_ERROR_MESSAGE_COMMAND, ErrorMessageBlockNode } from '.'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { Variable02 } from '../../../icons/src/vender/solid/development'
type Props = {
diff --git a/web/app/components/base/prompt-editor/plugins/last-run-block/component.tsx b/web/app/components/base/prompt-editor/plugins/last-run-block/component.tsx
index e7f96cd4da..3518fb99ef 100644
--- a/web/app/components/base/prompt-editor/plugins/last-run-block/component.tsx
+++ b/web/app/components/base/prompt-editor/plugins/last-run-block/component.tsx
@@ -2,7 +2,7 @@ import { type FC, useEffect } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useSelectOrDelete } from '../../hooks'
import { DELETE_LAST_RUN_COMMAND, LastRunBlockNode } from '.'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { Variable02 } from '../../../icons/src/vender/solid/development'
type Props = {
diff --git a/web/app/components/base/prompt-editor/plugins/placeholder.tsx b/web/app/components/base/prompt-editor/plugins/placeholder.tsx
index 187b574cea..70b338858e 100644
--- a/web/app/components/base/prompt-editor/plugins/placeholder.tsx
+++ b/web/app/components/base/prompt-editor/plugins/placeholder.tsx
@@ -1,7 +1,7 @@
import { memo } from 'react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
const Placeholder = ({
compact,
diff --git a/web/app/components/base/radio-card/index.tsx b/web/app/components/base/radio-card/index.tsx
index 417897270b..605f3dccc7 100644
--- a/web/app/components/base/radio-card/index.tsx
+++ b/web/app/components/base/radio-card/index.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { noop } from 'lodash-es'
type Props = {
diff --git a/web/app/components/base/radio-card/simple/index.tsx b/web/app/components/base/radio-card/simple/index.tsx
index 7bb5c0f19a..fff7fe9c83 100644
--- a/web/app/components/base/radio-card/simple/index.tsx
+++ b/web/app/components/base/radio-card/simple/index.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import s from './style.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
className?: string
diff --git a/web/app/components/base/radio/component/group/index.tsx b/web/app/components/base/radio/component/group/index.tsx
index 8bc90e2b1b..aa11fdddb7 100644
--- a/web/app/components/base/radio/component/group/index.tsx
+++ b/web/app/components/base/radio/component/group/index.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import RadioGroupContext from '../../context'
import s from '../../style.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type TRadioGroupProps = {
children?: ReactNode | ReactNode[]
diff --git a/web/app/components/base/radio/component/radio/index.tsx b/web/app/components/base/radio/component/radio/index.tsx
index 3f94e8b33f..d31507cdd4 100644
--- a/web/app/components/base/radio/component/radio/index.tsx
+++ b/web/app/components/base/radio/component/radio/index.tsx
@@ -3,7 +3,7 @@ import { useId } from 'react'
import { useContext } from 'use-context-selector'
import RadioGroupContext from '../../context'
import s from '../../style.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type IRadioProps = {
className?: string
diff --git a/web/app/components/base/radio/ui.tsx b/web/app/components/base/radio/ui.tsx
index c10e324adc..4d1ce7a300 100644
--- a/web/app/components/base/radio/ui.tsx
+++ b/web/app/components/base/radio/ui.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
isChecked: boolean
diff --git a/web/app/components/base/search-input/index.tsx b/web/app/components/base/search-input/index.tsx
index abf1817e88..3b20a8816c 100644
--- a/web/app/components/base/search-input/index.tsx
+++ b/web/app/components/base/search-input/index.tsx
@@ -2,7 +2,7 @@ import type { FC } from 'react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseCircleFill, RiSearchLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type SearchInputProps = {
placeholder?: string
diff --git a/web/app/components/base/segmented-control/index.tsx b/web/app/components/base/segmented-control/index.tsx
index 6c2d7fab0e..92c4c0c829 100644
--- a/web/app/components/base/segmented-control/index.tsx
+++ b/web/app/components/base/segmented-control/index.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { RemixiconComponentType } from '@remixicon/react'
import Divider from '../divider'
import type { VariantProps } from 'class-variance-authority'
diff --git a/web/app/components/base/select/custom.tsx b/web/app/components/base/select/custom.tsx
index f9032658c3..d17c8c7a7e 100644
--- a/web/app/components/base/select/custom.tsx
+++ b/web/app/components/base/select/custom.tsx
@@ -15,7 +15,7 @@ import {
import type {
PortalToFollowElemOptions,
} from '@/app/components/base/portal-to-follow-elem'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type Option = {
label: string
diff --git a/web/app/components/base/select/index.tsx b/web/app/components/base/select/index.tsx
index 1a096d7f93..d42c9fa2b9 100644
--- a/web/app/components/base/select/index.tsx
+++ b/web/app/components/base/select/index.tsx
@@ -6,7 +6,7 @@ import { ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/s
import Badge from '../badge/index'
import { RiCheckLine, RiLoader4Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@@ -101,7 +101,7 @@ const Select: FC = ({
onSelect(value)
}
}}>
-
+
{allowSearch
?
= ({
if (!disabled)
setOpen(!open)
}
- } className={classNames(`flex h-9 w-full items-center rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6`, optionClassName)}>
+ } className={cn(`flex h-9 w-full items-center rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6`, optionClassName)}>
{selectedItem?.name}
}
= ({
key={item.value}
value={item}
className={({ active }: { active: boolean }) =>
- classNames(
- 'relative cursor-default select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
+ cn('relative cursor-default select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
active ? 'bg-state-base-hover' : '',
- optionClassName,
- )
+ optionClassName)
}
>
{({ /* active, */ selected }) => (
@@ -150,12 +148,10 @@ const Select: FC = ({
? renderOption({ item, selected })
: (
<>
- {item.name}
+ {item.name}
{selected && (
@@ -221,13 +217,13 @@ const SimpleSelect: FC = ({
}}
>
{({ open }) => (
-
+
{renderTrigger &&
{renderTrigger(selectedItem, open)} }
{!renderTrigger && (
{
onOpenChange?.(open)
- }} className={classNames(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}>
- {selectedItem?.name ?? localPlaceholder}
+ }} className={cn(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}>
+ {selectedItem?.name ?? localPlaceholder}
{isLoading ?
: (selectedItem && !notClearable)
@@ -260,7 +256,7 @@ const SimpleSelect: FC = ({
)}
{(!disabled) && (
-
+
{items.map((item: Item) =>
item.isGroup ? (
= ({
= ({
{renderOption
? renderOption({ item, selected })
: (<>
- {item.name}
+ {item.name}
{selected && !hideChecked && (
@@ -356,9 +348,9 @@ const PortalSelect: FC = ({
? renderTrigger(selectedItem)
: (
= ({
{items.map((item: Item) => (
export const SkeletonContainer: FC
= (props) => {
const { className, children, ...rest } = props
return (
-
+
{children}
)
@@ -15,7 +15,7 @@ export const SkeletonContainer: FC
= (props) => {
export const SkeletonRow: FC = (props) => {
const { className, children, ...rest } = props
return (
-
+
{children}
)
@@ -24,7 +24,7 @@ export const SkeletonRow: FC
= (props) => {
export const SkeletonRectangle: FC = (props) => {
const { className, children, ...rest } = props
return (
-
+
{children}
)
@@ -33,7 +33,7 @@ export const SkeletonRectangle: FC
= (props) => {
export const SkeletonPoint: FC = (props) => {
const { className, ...rest } = props
return (
- ·
+ ·
)
}
/** Usage
diff --git a/web/app/components/base/slider/index.tsx b/web/app/components/base/slider/index.tsx
index 1b41ee64c1..ea45b3ac7a 100644
--- a/web/app/components/base/slider/index.tsx
+++ b/web/app/components/base/slider/index.tsx
@@ -1,5 +1,5 @@
import ReactSlider from 'react-slider'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import './style.css'
type ISliderProps = {
diff --git a/web/app/components/base/sort/index.tsx b/web/app/components/base/sort/index.tsx
index 3823b13d1a..14f3b0c59e 100644
--- a/web/app/components/base/sort/index.tsx
+++ b/web/app/components/base/sort/index.tsx
@@ -2,7 +2,7 @@ import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine, RiCheckLine, RiSortAsc, RiSortDesc } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
diff --git a/web/app/components/base/svg/index.tsx b/web/app/components/base/svg/index.tsx
index d29fd17f0d..ee4b7efff4 100644
--- a/web/app/components/base/svg/index.tsx
+++ b/web/app/components/base/svg/index.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import s from './style.module.css'
import ActionButton from '../action-button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type ISVGBtnProps = {
isSVG: boolean
diff --git a/web/app/components/base/switch/index.tsx b/web/app/components/base/switch/index.tsx
index bca98d0820..af96489982 100644
--- a/web/app/components/base/switch/index.tsx
+++ b/web/app/components/base/switch/index.tsx
@@ -1,7 +1,7 @@
'use client'
import React, { useEffect, useState } from 'react'
import { Switch as OriginalSwitch } from '@headlessui/react'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type SwitchProps = {
onChange?: (value: boolean) => void
@@ -60,23 +60,19 @@ const Switch = (
setEnabled(checked)
onChange?.(checked)
}}
- className={classNames(
- wrapStyle[size],
+ className={cn(wrapStyle[size],
enabled ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
'relative inline-flex shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out',
disabled ? '!cursor-not-allowed !opacity-50' : '',
size === 'xs' && 'rounded-sm',
- className,
- )}
+ className)}
>
)
diff --git a/web/app/components/base/tab-header/index.tsx b/web/app/components/base/tab-header/index.tsx
index 846277e5db..0d49f84cbf 100644
--- a/web/app/components/base/tab-header/index.tsx
+++ b/web/app/components/base/tab-header/index.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Item = {
id: string
diff --git a/web/app/components/base/tab-slider-new/index.tsx b/web/app/components/base/tab-slider-new/index.tsx
index cf68abff1d..464226ee02 100644
--- a/web/app/components/base/tab-slider-new/index.tsx
+++ b/web/app/components/base/tab-slider-new/index.tsx
@@ -1,5 +1,5 @@
import type { FC } from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Option = {
value: string
diff --git a/web/app/components/base/tab-slider-plain/index.tsx b/web/app/components/base/tab-slider-plain/index.tsx
index b9b39657af..85454ab41d 100644
--- a/web/app/components/base/tab-slider-plain/index.tsx
+++ b/web/app/components/base/tab-slider-plain/index.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Option = {
value: string
diff --git a/web/app/components/base/tab-slider/index.tsx b/web/app/components/base/tab-slider/index.tsx
index 7c9364baf9..3e1eb69f0f 100644
--- a/web/app/components/base/tab-slider/index.tsx
+++ b/web/app/components/base/tab-slider/index.tsx
@@ -1,6 +1,6 @@
import type { FC, ReactNode } from 'react'
import { useEffect, useState } from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Badge, { BadgeState } from '@/app/components/base/badge/index'
import { useInstalledPluginList } from '@/service/use-plugins'
type Option = {
diff --git a/web/app/components/base/tag-input/index.tsx b/web/app/components/base/tag-input/index.tsx
index 415afc7549..d3d43a9c52 100644
--- a/web/app/components/base/tag-input/index.tsx
+++ b/web/app/components/base/tag-input/index.tsx
@@ -3,7 +3,7 @@ import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import { useTranslation } from 'react-i18next'
import AutosizeInput from 'react-18-input-autosize'
import { RiAddLine, RiCloseLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useToastContext } from '@/app/components/base/toast'
type TagInputProps = {
diff --git a/web/app/components/base/tag-management/filter.tsx b/web/app/components/base/tag-management/filter.tsx
index 36be042160..5f8ab11dda 100644
--- a/web/app/components/base/tag-management/filter.tsx
+++ b/web/app/components/base/tag-management/filter.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useDebounceFn, useMount } from 'ahooks'
import { RiArrowDownSLine } from '@remixicon/react'
import { useStore as useTagStore } from './store'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
diff --git a/web/app/components/base/tag-management/selector.tsx b/web/app/components/base/tag-management/selector.tsx
index bb1eb98642..150c06688f 100644
--- a/web/app/components/base/tag-management/selector.tsx
+++ b/web/app/components/base/tag-management/selector.tsx
@@ -1,7 +1,7 @@
import type { FC } from 'react'
import { useCallback, useMemo } from 'react'
import { useStore as useTagStore } from './store'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import CustomPopover from '@/app/components/base/popover'
import type { Tag } from '@/app/components/base/tag-management/constant'
import { fetchTagList } from '@/service/tag'
diff --git a/web/app/components/base/tag-management/tag-item-editor.tsx b/web/app/components/base/tag-management/tag-item-editor.tsx
index 0fdfc5079a..a9b7efa737 100644
--- a/web/app/components/base/tag-management/tag-item-editor.tsx
+++ b/web/app/components/base/tag-management/tag-item-editor.tsx
@@ -9,7 +9,7 @@ import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import { useStore as useTagStore } from './store'
import Confirm from '@/app/components/base/confirm'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { Tag } from '@/app/components/base/tag-management/constant'
import { ToastContext } from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
diff --git a/web/app/components/base/tag-management/tag-remove-modal.tsx b/web/app/components/base/tag-management/tag-remove-modal.tsx
index 85f1831ac1..a41dd98cf3 100644
--- a/web/app/components/base/tag-management/tag-remove-modal.tsx
+++ b/web/app/components/base/tag-management/tag-remove-modal.tsx
@@ -2,7 +2,7 @@
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
diff --git a/web/app/components/base/tag/index.tsx b/web/app/components/base/tag/index.tsx
index 1d24e2c825..f85abe372e 100644
--- a/web/app/components/base/tag/index.tsx
+++ b/web/app/components/base/tag/index.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type ITagProps = {
children: string | React.ReactNode
@@ -31,7 +31,7 @@ const COLOR_MAP = {
export default function Tag({ children, color = 'green', className = '', bordered = false, hideBg = false }: ITagProps) {
return (
({
__esModule: true,
default: (props: { className?: string }) => {
- mockUsageProps = props
return (
usage
@@ -14,11 +12,9 @@ jest.mock('./usage', () => ({
},
}))
-let mockUpgradeBtnProps: { loc?: string } | null = null
jest.mock('../upgrade-btn', () => ({
__esModule: true,
default: (props: { loc?: string }) => {
- mockUpgradeBtnProps = props
return (
{props.loc}
@@ -30,8 +26,6 @@ jest.mock('../upgrade-btn', () => ({
describe('AnnotationFull', () => {
beforeEach(() => {
jest.clearAllMocks()
- mockUsageProps = null
- mockUpgradeBtnProps = null
})
// Rendering marketing copy with action button
diff --git a/web/app/components/billing/annotation-full/index.tsx b/web/app/components/billing/annotation-full/index.tsx
index 88ed5f1716..9aee529c8c 100644
--- a/web/app/components/billing/annotation-full/index.tsx
+++ b/web/app/components/billing/annotation-full/index.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
import UpgradeBtn from '../upgrade-btn'
import Usage from './usage'
import s from './style.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import GridMask from '@/app/components/base/grid-mask'
const AnnotationFull: FC = () => {
diff --git a/web/app/components/billing/annotation-full/modal.spec.tsx b/web/app/components/billing/annotation-full/modal.spec.tsx
index 150b2ced08..f898402218 100644
--- a/web/app/components/billing/annotation-full/modal.spec.tsx
+++ b/web/app/components/billing/annotation-full/modal.spec.tsx
@@ -1,11 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AnnotationFullModal from './modal'
-let mockUsageProps: { className?: string } | null = null
jest.mock('./usage', () => ({
__esModule: true,
default: (props: { className?: string }) => {
- mockUsageProps = props
return (
usage
@@ -59,7 +57,6 @@ jest.mock('../../base/modal', () => ({
describe('AnnotationFullModal', () => {
beforeEach(() => {
jest.clearAllMocks()
- mockUsageProps = null
mockUpgradeBtnProps = null
mockModalProps = null
})
diff --git a/web/app/components/billing/annotation-full/modal.tsx b/web/app/components/billing/annotation-full/modal.tsx
index 324a4dcc36..a5b04c67b6 100644
--- a/web/app/components/billing/annotation-full/modal.tsx
+++ b/web/app/components/billing/annotation-full/modal.tsx
@@ -6,7 +6,7 @@ import UpgradeBtn from '../upgrade-btn'
import Modal from '../../base/modal'
import Usage from './usage'
import s from './style.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import GridMask from '@/app/components/base/grid-mask'
type Props = {
diff --git a/web/app/components/billing/apps-full-in-dialog/index.tsx b/web/app/components/billing/apps-full-in-dialog/index.tsx
index fda3213713..8d06d2a534 100644
--- a/web/app/components/billing/apps-full-in-dialog/index.tsx
+++ b/web/app/components/billing/apps-full-in-dialog/index.tsx
@@ -10,7 +10,7 @@ import { useProviderContext } from '@/context/provider-context'
import { useAppContext } from '@/context/app-context'
import { Plan } from '@/app/components/billing/type'
import s from './style.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
const LOW = 50
const MIDDLE = 80
diff --git a/web/app/components/billing/header-billing-btn/index.tsx b/web/app/components/billing/header-billing-btn/index.tsx
index f34fa0bce4..aad4d2b9e9 100644
--- a/web/app/components/billing/header-billing-btn/index.tsx
+++ b/web/app/components/billing/header-billing-btn/index.tsx
@@ -3,7 +3,7 @@ import type { FC } from 'react'
import React from 'react'
import UpgradeBtn from '../upgrade-btn'
import { Plan } from '../type'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useProviderContext } from '@/context/provider-context'
type Props = {
diff --git a/web/app/components/billing/plan-upgrade-modal/index.tsx b/web/app/components/billing/plan-upgrade-modal/index.tsx
index 4f5d1ed3a6..f7e19b7621 100644
--- a/web/app/components/billing/plan-upgrade-modal/index.tsx
+++ b/web/app/components/billing/plan-upgrade-modal/index.tsx
@@ -33,7 +33,10 @@ const PlanUpgradeModal: FC
= ({
const handleUpgrade = useCallback(() => {
onClose()
- onUpgrade ? onUpgrade() : setShowPricingModal()
+ if (onUpgrade)
+ onUpgrade()
+ else
+ setShowPricingModal()
}, [onClose, onUpgrade, setShowPricingModal])
return (
diff --git a/web/app/components/billing/pricing/footer.tsx b/web/app/components/billing/pricing/footer.tsx
index fd713eb3da..4605aad998 100644
--- a/web/app/components/billing/pricing/footer.tsx
+++ b/web/app/components/billing/pricing/footer.tsx
@@ -3,7 +3,7 @@ import Link from 'next/link'
import { useTranslation } from 'react-i18next'
import { RiArrowRightUpLine } from '@remixicon/react'
import { type Category, CategoryEnum } from '.'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type FooterProps = {
pricingPageURL: string
diff --git a/web/app/components/billing/pricing/plan-switcher/tab.tsx b/web/app/components/billing/pricing/plan-switcher/tab.tsx
index 417c9ff1fb..4be0973ead 100644
--- a/web/app/components/billing/pricing/plan-switcher/tab.tsx
+++ b/web/app/components/billing/pricing/plan-switcher/tab.tsx
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type TabProps = {
Icon: React.ComponentType<{ isActive: boolean }>
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx
index 1f16632ef2..e24faab2c9 100644
--- a/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx
+++ b/web/app/components/billing/pricing/plans/cloud-plan-item/button.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import type { BasicPlan } from '../../../type'
import { Plan } from '../../../type'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { RiArrowRightLine } from '@remixicon/react'
const BUTTON_CLASSNAME = {
diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx
index ffa4dbcb65..f6ebb2cb3f 100644
--- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx
+++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx
@@ -2,7 +2,7 @@ import React, { useMemo } from 'react'
import { SelfHostedPlan } from '../../../type'
import { AwsMarketplaceDark, AwsMarketplaceLight } from '@/app/components/base/icons/src/public/billing'
import { RiArrowRightLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx
index db32f6c750..2f14365d8f 100644
--- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx
+++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
import { SelfHostedPlan } from '../../../type'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
import Toast from '../../../../base/toast'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useAppContext } from '@/context/app-context'
import Button from './button'
import List from './list'
diff --git a/web/app/components/billing/priority-label/index.tsx b/web/app/components/billing/priority-label/index.tsx
index 98c6a982b8..8893263470 100644
--- a/web/app/components/billing/priority-label/index.tsx
+++ b/web/app/components/billing/priority-label/index.tsx
@@ -4,7 +4,7 @@ import {
DocumentProcessingPriority,
Plan,
} from '../type'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useProviderContext } from '@/context/provider-context'
import Tooltip from '@/app/components/base/tooltip'
import { RiAedFill } from '@remixicon/react'
diff --git a/web/app/components/billing/progress-bar/index.tsx b/web/app/components/billing/progress-bar/index.tsx
index 6397b43967..6728fd617c 100644
--- a/web/app/components/billing/progress-bar/index.tsx
+++ b/web/app/components/billing/progress-bar/index.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type ProgressBarProps = {
percent: number
diff --git a/web/app/components/billing/upgrade-btn/index.spec.tsx b/web/app/components/billing/upgrade-btn/index.spec.tsx
new file mode 100644
index 0000000000..d106dbe327
--- /dev/null
+++ b/web/app/components/billing/upgrade-btn/index.spec.tsx
@@ -0,0 +1,543 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import UpgradeBtn from './index'
+
+// ✅ Import real project components (DO NOT mock these)
+// PremiumBadge, Button, SparklesSoft are all base components
+
+// ✅ Mock external dependencies only
+const mockSetShowPricingModal = jest.fn()
+jest.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowPricingModal: mockSetShowPricingModal,
+ }),
+}))
+
+// Mock gtag for tracking tests
+let mockGtag: jest.Mock | undefined
+
+describe('UpgradeBtn', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockGtag = jest.fn()
+ ;(window as any).gtag = mockGtag
+ })
+
+ afterEach(() => {
+ delete (window as any).gtag
+ })
+
+ // Rendering tests (REQUIRED)
+ describe('Rendering', () => {
+ it('should render without crashing with default props', () => {
+ // Act
+ render( )
+
+ // Assert - should render with default text
+ expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
+ })
+
+ it('should render premium badge by default', () => {
+ // Act
+ render( )
+
+ // Assert - PremiumBadge renders with text content
+ expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
+ })
+
+ it('should render plain button when isPlain is true', () => {
+ // Act
+ render( )
+
+ // Assert - Button should be rendered with plain text
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument()
+ })
+
+ it('should render short text when isShort is true', () => {
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/billing\.upgradeBtn\.encourageShort/i)).toBeInTheDocument()
+ })
+
+ it('should render custom label when labelKey is provided', () => {
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument()
+ })
+
+ it('should render custom label in plain button when labelKey is provided with isPlain', () => {
+ // Act
+ render( )
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument()
+ })
+ })
+
+ // Props tests (REQUIRED)
+ describe('Props', () => {
+ it('should apply custom className to premium badge', () => {
+ // Arrange
+ const customClass = 'custom-upgrade-btn'
+
+ // Act
+ const { container } = render( )
+
+ // Assert - Check the root element has the custom class
+ const rootElement = container.firstChild as HTMLElement
+ expect(rootElement).toHaveClass(customClass)
+ })
+
+ it('should apply custom className to plain button', () => {
+ // Arrange
+ const customClass = 'custom-button-class'
+
+ // Act
+ render( )
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass(customClass)
+ })
+
+ it('should apply custom style to premium badge', () => {
+ // Arrange
+ const customStyle = { backgroundColor: 'red', padding: '10px' }
+
+ // Act
+ const { container } = render( )
+
+ // Assert
+ const rootElement = container.firstChild as HTMLElement
+ expect(rootElement).toHaveStyle(customStyle)
+ })
+
+ it('should apply custom style to plain button', () => {
+ // Arrange
+ const customStyle = { backgroundColor: 'blue', margin: '5px' }
+
+ // Act
+ render( )
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toHaveStyle(customStyle)
+ })
+
+ it('should render with size "s"', () => {
+ // Act
+ render( )
+
+ // Assert - Component renders successfully with size prop
+ expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
+ })
+
+ it('should render with size "m" by default', () => {
+ // Act
+ render( )
+
+ // Assert - Component renders successfully
+ expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
+ })
+
+ it('should render with size "custom"', () => {
+ // Act
+ render( )
+
+ // Assert - Component renders successfully with custom size
+ expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
+ })
+ })
+
+ // User Interactions
+ describe('User Interactions', () => {
+ it('should call custom onClick when provided and premium badge is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+ await user.click(badge)
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ expect(mockSetShowPricingModal).not.toHaveBeenCalled()
+ })
+
+ it('should call custom onClick when provided and plain button is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render( )
+ const button = screen.getByRole('button')
+ await user.click(button)
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ expect(mockSetShowPricingModal).not.toHaveBeenCalled()
+ })
+
+ it('should open pricing modal when no custom onClick is provided and premium badge is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+ await user.click(badge)
+
+ // Assert
+ expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('should open pricing modal when no custom onClick is provided and plain button is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ const button = screen.getByRole('button')
+ await user.click(button)
+
+ // Assert
+ expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('should track gtag event when loc is provided and badge is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const loc = 'header-navigation'
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+ await user.click(badge)
+
+ // Assert
+ expect(mockGtag).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
+ loc,
+ })
+ })
+
+ it('should track gtag event when loc is provided and plain button is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const loc = 'footer-section'
+
+ // Act
+ render( )
+ const button = screen.getByRole('button')
+ await user.click(button)
+
+ // Assert
+ expect(mockGtag).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
+ loc,
+ })
+ })
+
+ it('should not track gtag event when loc is not provided', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+ await user.click(badge)
+
+ // Assert
+ expect(mockGtag).not.toHaveBeenCalled()
+ })
+
+ it('should not track gtag event when gtag is not available', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ delete (window as any).gtag
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+ await user.click(badge)
+
+ // Assert - should not throw error
+ expect(mockGtag).not.toHaveBeenCalled()
+ })
+
+ it('should call both custom onClick and track gtag when both are provided', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+ const loc = 'settings-page'
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+ await user.click(badge)
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
+ loc,
+ })
+ })
+ })
+
+ // Edge Cases (REQUIRED)
+ describe('Edge Cases', () => {
+ it('should handle undefined className', () => {
+ // Act
+ render( )
+
+ // Assert - should render without error
+ expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
+ })
+
+ it('should handle undefined style', () => {
+ // Act
+ render( )
+
+ // Assert - should render without error
+ expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
+ })
+
+ it('should handle undefined onClick', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+ await user.click(badge)
+
+ // Assert - should fall back to setShowPricingModal
+ expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
+ })
+
+ it('should handle undefined loc', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+ await user.click(badge)
+
+ // Assert - should not attempt to track gtag
+ expect(mockGtag).not.toHaveBeenCalled()
+ })
+
+ it('should handle undefined labelKey', () => {
+ // Act
+ render( )
+
+ // Assert - should use default label
+ expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
+ })
+
+ it('should handle empty string className', () => {
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
+ })
+
+ it('should handle empty string loc', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+ await user.click(badge)
+
+ // Assert - empty loc should not trigger gtag
+ expect(mockGtag).not.toHaveBeenCalled()
+ })
+
+ it('should handle empty string labelKey', () => {
+ // Act
+ render( )
+
+ // Assert - empty labelKey is falsy, so it falls back to default label
+ expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
+ })
+ })
+
+ // Prop Combinations
+ describe('Prop Combinations', () => {
+ it('should handle isPlain with isShort', () => {
+ // Act
+ render( )
+
+ // Assert - isShort should not affect plain button text
+ expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument()
+ })
+
+ it('should handle isPlain with custom labelKey', () => {
+ // Act
+ render( )
+
+ // Assert - labelKey should override plain text
+ expect(screen.getByText(/custom\.key/i)).toBeInTheDocument()
+ expect(screen.queryByText(/billing\.upgradeBtn\.plain/i)).not.toBeInTheDocument()
+ })
+
+ it('should handle isShort with custom labelKey', () => {
+ // Act
+ render( )
+
+ // Assert - labelKey should override isShort behavior
+ expect(screen.getByText(/custom\.short\.key/i)).toBeInTheDocument()
+ expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
+ })
+
+ it('should handle all custom props together', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+ const customStyle = { margin: '10px' }
+ const customClass = 'all-custom'
+
+ // Act
+ const { container } = render(
+ ,
+ )
+ const badge = screen.getByText(/custom\.all/i)
+ await user.click(badge)
+
+ // Assert
+ const rootElement = container.firstChild as HTMLElement
+ expect(rootElement).toHaveClass(customClass)
+ expect(rootElement).toHaveStyle(customStyle)
+ expect(screen.getByText(/custom\.all/i)).toBeInTheDocument()
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
+ loc: 'test-loc',
+ })
+ })
+ })
+
+ // Accessibility Tests
+ describe('Accessibility', () => {
+ it('should be keyboard accessible with plain button', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render( )
+ const button = screen.getByRole('button')
+
+ // Tab to button
+ await user.tab()
+ expect(button).toHaveFocus()
+
+ // Press Enter
+ await user.keyboard('{Enter}')
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should be keyboard accessible with Space key', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render( )
+
+ // Tab to button and press Space
+ await user.tab()
+ await user.keyboard(' ')
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should be clickable for premium badge variant', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+
+ // Click badge
+ await user.click(badge)
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should have proper button role when isPlain is true', () => {
+ // Act
+ render( )
+
+ // Assert - Plain button should have button role
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ })
+ })
+
+ // Integration Tests
+ describe('Integration', () => {
+ it('should work with modal context for pricing modal', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+ await user.click(badge)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should integrate onClick with analytics tracking', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const handleClick = jest.fn()
+
+ // Act
+ render( )
+ const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
+ await user.click(badge)
+
+ // Assert - Both onClick and gtag should be called
+ await waitFor(() => {
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
+ loc: 'integration-test',
+ })
+ })
+ })
+ })
+})
diff --git a/web/app/components/billing/usage-info/index.tsx b/web/app/components/billing/usage-info/index.tsx
index 668d49d698..a8be0aef53 100644
--- a/web/app/components/billing/usage-info/index.tsx
+++ b/web/app/components/billing/usage-info/index.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
import ProgressBar from '../progress-bar'
import { NUM_INFINITE } from '../config'
import Tooltip from '@/app/components/base/tooltip'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type Props = {
className?: string
diff --git a/web/app/components/billing/vector-space-full/index.tsx b/web/app/components/billing/vector-space-full/index.tsx
index 58d9db068a..026277948e 100644
--- a/web/app/components/billing/vector-space-full/index.tsx
+++ b/web/app/components/billing/vector-space-full/index.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
import UpgradeBtn from '../upgrade-btn'
import VectorSpaceInfo from '../usage-info/vector-space-info'
import s from './style.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import GridMask from '@/app/components/base/grid-mask'
const VectorSpaceFull: FC = () => {
diff --git a/web/app/components/custom/custom-web-app-brand/index.tsx b/web/app/components/custom/custom-web-app-brand/index.tsx
index fee0bf75f7..e919144b1e 100644
--- a/web/app/components/custom/custom-web-app-brand/index.tsx
+++ b/web/app/components/custom/custom-web-app-brand/index.tsx
@@ -23,7 +23,7 @@ import {
updateCurrentWorkspace,
} from '@/service/common'
import { useAppContext } from '@/context/app-context'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
const ALLOW_FILE_EXTENSIONS = ['svg', 'png']
diff --git a/web/app/components/datasets/common/credential-icon.tsx b/web/app/components/datasets/common/credential-icon.tsx
index d4e6fd69ac..041df770a8 100644
--- a/web/app/components/datasets/common/credential-icon.tsx
+++ b/web/app/components/datasets/common/credential-icon.tsx
@@ -1,4 +1,4 @@
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import React, { useCallback, useMemo, useState } from 'react'
type CredentialIconProps = {
diff --git a/web/app/components/datasets/common/document-picker/document-list.tsx b/web/app/components/datasets/common/document-picker/document-list.tsx
index 20ec949bbd..2ab6334383 100644
--- a/web/app/components/datasets/common/document-picker/document-list.tsx
+++ b/web/app/components/datasets/common/document-picker/document-list.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React, { useCallback } from 'react'
import FileIcon from '../document-file-icon'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { DocumentItem } from '@/models/datasets'
type Props = {
diff --git a/web/app/components/datasets/common/document-picker/index.tsx b/web/app/components/datasets/common/document-picker/index.tsx
index 0629ff0e48..5fee49d7db 100644
--- a/web/app/components/datasets/common/document-picker/index.tsx
+++ b/web/app/components/datasets/common/document-picker/index.tsx
@@ -13,7 +13,7 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import SearchInput from '@/app/components/base/search-input'
import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
import { useDocumentList } from '@/service/knowledge/use-document'
diff --git a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx
index b6ffa520a8..907d075893 100644
--- a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx
+++ b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx
@@ -11,7 +11,7 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Loading from '@/app/components/base/loading'
import type { DocumentItem } from '@/models/datasets'
diff --git a/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx b/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx
index 0314b26fde..9edad28e65 100644
--- a/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx
+++ b/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx
@@ -2,7 +2,7 @@
import { RiAlertFill, RiCheckboxCircleFill, RiErrorWarningFill, RiInformation2Fill } from '@remixicon/react'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
type Status = 'success' | 'error' | 'warning' | 'info'
diff --git a/web/app/components/datasets/common/image-list/index.tsx b/web/app/components/datasets/common/image-list/index.tsx
index 8b0cf62e4a..8084b6eac6 100644
--- a/web/app/components/datasets/common/image-list/index.tsx
+++ b/web/app/components/datasets/common/image-list/index.tsx
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from 'react'
import type { FileEntity } from '@/app/components/base/file-thumb'
import FileThumb from '@/app/components/base/file-thumb'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import More from './more'
import type { ImageInfo } from '../image-previewer'
import ImagePreviewer from '../image-previewer'
diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.tsx
index 3e15b92705..002ee705ba 100644
--- a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.tsx
+++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-input.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { RiUploadCloud2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useUpload } from '../hooks/use-upload'
diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.tsx
index 3efa3a19d7..5dc9971fd6 100644
--- a/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.tsx
+++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/index.tsx
@@ -6,7 +6,7 @@ import type { FileEntity } from '../types'
import FileItem from './image-item'
import { useUpload } from '../hooks/use-upload'
import ImageInput from './image-input'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useCallback, useState } from 'react'
import type { ImageInfo } from '@/app/components/datasets/common/image-previewer'
import ImagePreviewer from '@/app/components/datasets/common/image-previewer'
diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.tsx
index 2d04132842..7fa4cca507 100644
--- a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.tsx
+++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/index.tsx
@@ -8,7 +8,7 @@ import {
import type { FileEntity } from '../types'
import { useUpload } from '../hooks/use-upload'
import ImageInput from './image-input'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
import { useFileStoreWithSelector } from '../store'
import ImageItem from './image-item'
diff --git a/web/app/components/datasets/common/retrieval-param-config/index.tsx b/web/app/components/datasets/common/retrieval-param-config/index.tsx
index 2b703cc44d..be47ea6b8f 100644
--- a/web/app/components/datasets/common/retrieval-param-config/index.tsx
+++ b/web/app/components/datasets/common/retrieval-param-config/index.tsx
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import Image from 'next/image'
import ProgressIndicator from '../../create/assets/progress-indicator.svg'
import Reranking from '../../create/assets/rerank.svg'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import TopKItem from '@/app/components/base/param-item/top-k-item'
import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item'
import { RETRIEVE_METHOD } from '@/types/app'
diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/item.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/item.tsx
index 8474a832e4..5d95db5f5c 100644
--- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/item.tsx
+++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/item.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type ItemProps = {
isActive: boolean
diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx
index 57509b646f..96413e8aed 100644
--- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx
+++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx
@@ -9,7 +9,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { formatFileSize } from '@/utils/format'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { ToastContext } from '@/app/components/base/toast'
import ActionButton from '@/app/components/base/action-button'
diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/chunk-structure-card.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/chunk-structure-card.tsx
index f3ed24c8f3..5fa80cfe39 100644
--- a/web/app/components/datasets/create-from-pipeline/list/template-card/details/chunk-structure-card.tsx
+++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/chunk-structure-card.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { Option } from './types'
import { EffectColor } from './types'
diff --git a/web/app/components/datasets/create/embedding-process/index.tsx b/web/app/components/datasets/create/embedding-process/index.tsx
index 4e78eb2034..22612b19bc 100644
--- a/web/app/components/datasets/create/embedding-process/index.tsx
+++ b/web/app/components/datasets/create/embedding-process/index.tsx
@@ -13,7 +13,7 @@ import Image from 'next/image'
import { indexMethodIcon, retrievalIcon } from '../icons'
import { IndexingType } from '../step-two'
import DocumentFileIcon from '../../common/document-file-icon'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata'
import Button from '@/app/components/base/button'
import type {
diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/index.spec.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/index.spec.tsx
new file mode 100644
index 0000000000..4023948555
--- /dev/null
+++ b/web/app/components/datasets/create/empty-dataset-creation-modal/index.spec.tsx
@@ -0,0 +1,777 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import React from 'react'
+import EmptyDatasetCreationModal from './index'
+import { createEmptyDataset } from '@/service/datasets'
+import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
+
+// Mock Next.js router
+const mockPush = jest.fn()
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: mockPush,
+ }),
+}))
+
+// Mock createEmptyDataset API
+jest.mock('@/service/datasets', () => ({
+ createEmptyDataset: jest.fn(),
+}))
+
+// Mock useInvalidDatasetList hook
+jest.mock('@/service/knowledge/use-dataset', () => ({
+ useInvalidDatasetList: jest.fn(),
+}))
+
+// Mock ToastContext - need to mock both createContext and useContext from use-context-selector
+const mockNotify = jest.fn()
+jest.mock('use-context-selector', () => ({
+ createContext: jest.fn(() => ({
+ Provider: ({ children }: { children: React.ReactNode }) => children,
+ })),
+ useContext: jest.fn(() => ({ notify: mockNotify })),
+}))
+
+// Type cast mocked functions
+const mockCreateEmptyDataset = createEmptyDataset as jest.MockedFunction
+const mockInvalidDatasetList = jest.fn()
+const mockUseInvalidDatasetList = useInvalidDatasetList as jest.MockedFunction
+
+// Test data builder for props
+const createDefaultProps = (overrides?: Partial<{ show: boolean; onHide: () => void }>) => ({
+ show: true,
+ onHide: jest.fn(),
+ ...overrides,
+})
+
+describe('EmptyDatasetCreationModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseInvalidDatasetList.mockReturnValue(mockInvalidDatasetList)
+ mockCreateEmptyDataset.mockResolvedValue({
+ id: 'dataset-123',
+ name: 'Test Dataset',
+ } as ReturnType extends Promise ? T : never)
+ })
+
+ // ==========================================
+ // Rendering Tests - Verify component renders correctly
+ // ==========================================
+ describe('Rendering', () => {
+ it('should render without crashing when show is true', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert - Check modal title is rendered
+ expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
+ })
+
+ it('should render modal with correct elements', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.stepOne.modal.tip')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.stepOne.modal.input')).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.stepOne.modal.confirmButton')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.stepOne.modal.cancelButton')).toBeInTheDocument()
+ })
+
+ it('should render input with empty value initially', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
+ expect(input.value).toBe('')
+ })
+
+ it('should not render modal content when show is false', () => {
+ // Arrange
+ const props = createDefaultProps({ show: false })
+
+ // Act
+ render( )
+
+ // Assert - Modal should not be visible (check for absence of title)
+ expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==========================================
+ // Props Testing - Verify all prop variations work correctly
+ // ==========================================
+ describe('Props', () => {
+ describe('show prop', () => {
+ it('should show modal when show is true', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
+ })
+
+ it('should hide modal when show is false', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument()
+ })
+
+ it('should toggle visibility when show prop changes', () => {
+ // Arrange
+ const onHide = jest.fn()
+ const { rerender } = render( )
+
+ // Act & Assert - Initially hidden
+ expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument()
+
+ // Act & Assert - Show modal
+ rerender( )
+ expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
+ })
+ })
+
+ describe('onHide prop', () => {
+ it('should call onHide when cancel button is clicked', () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+
+ // Act
+ const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton')
+ fireEvent.click(cancelButton)
+
+ // Assert
+ expect(mockOnHide).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onHide when close icon is clicked', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+
+ // Act - Wait for modal to be rendered, then find the close span
+ // The close span is located in the modalHeader div, next to the title
+ const titleElement = await screen.findByText('datasetCreation.stepOne.modal.title')
+ const headerDiv = titleElement.parentElement
+ const closeButton = headerDiv?.querySelector('span')
+
+ expect(closeButton).toBeInTheDocument()
+ fireEvent.click(closeButton!)
+
+ // Assert
+ expect(mockOnHide).toHaveBeenCalledTimes(1)
+ })
+ })
+ })
+
+ // ==========================================
+ // State Management - Test input state updates
+ // ==========================================
+ describe('State Management', () => {
+ it('should update input value when user types', () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
+
+ // Act
+ fireEvent.change(input, { target: { value: 'My Dataset' } })
+
+ // Assert
+ expect(input.value).toBe('My Dataset')
+ })
+
+ it('should persist input value when modal is hidden and shown again via rerender', () => {
+ // Arrange
+ const onHide = jest.fn()
+ const { rerender } = render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
+
+ // Act - Type in input
+ fireEvent.change(input, { target: { value: 'Test Dataset' } })
+ expect(input.value).toBe('Test Dataset')
+
+ // Hide and show modal via rerender (component is not unmounted, state persists)
+ rerender( )
+ rerender( )
+
+ // Assert - Input value persists because component state is preserved during rerender
+ const newInput = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
+ expect(newInput.value).toBe('Test Dataset')
+ })
+
+ it('should handle consecutive input changes', () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
+
+ // Act & Assert
+ fireEvent.change(input, { target: { value: 'A' } })
+ expect(input.value).toBe('A')
+
+ fireEvent.change(input, { target: { value: 'AB' } })
+ expect(input.value).toBe('AB')
+
+ fireEvent.change(input, { target: { value: 'ABC' } })
+ expect(input.value).toBe('ABC')
+ })
+ })
+
+ // ==========================================
+ // User Interactions - Test event handlers
+ // ==========================================
+ describe('User Interactions', () => {
+ it('should submit form when confirm button is clicked with valid input', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Valid Dataset Name' } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Valid Dataset Name' })
+ })
+ })
+
+ it('should show error notification when input is empty', async () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act - Click confirm without entering a name
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'datasetCreation.stepOne.modal.nameNotEmpty',
+ })
+ })
+ expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
+ })
+
+ it('should show error notification when input exceeds 40 characters', async () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act - Enter a name longer than 40 characters
+ const longName = 'A'.repeat(41)
+ fireEvent.change(input, { target: { value: longName } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'datasetCreation.stepOne.modal.nameLengthInvalid',
+ })
+ })
+ expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
+ })
+
+ it('should allow exactly 40 characters', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act - Enter exactly 40 characters
+ const exactLengthName = 'A'.repeat(40)
+ fireEvent.change(input, { target: { value: exactLengthName } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: exactLengthName })
+ })
+ })
+
+ it('should close modal on cancel button click', () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton')
+
+ // Act
+ fireEvent.click(cancelButton)
+
+ // Assert
+ expect(mockOnHide).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // ==========================================
+ // API Calls - Test API interactions
+ // ==========================================
+ describe('API Calls', () => {
+ it('should call createEmptyDataset with correct parameters', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'New Dataset' } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'New Dataset' })
+ })
+ })
+
+ it('should call invalidDatasetList after successful creation', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Test Dataset' } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockInvalidDatasetList).toHaveBeenCalled()
+ })
+ })
+
+ it('should call onHide after successful creation', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Test Dataset' } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockOnHide).toHaveBeenCalled()
+ })
+ })
+
+ it('should show error notification on API failure', async () => {
+ // Arrange
+ mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Test Dataset' } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'datasetCreation.stepOne.modal.failed',
+ })
+ })
+ })
+
+ it('should not call onHide on API failure', async () => {
+ // Arrange
+ mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Test Dataset' } })
+ fireEvent.click(confirmButton)
+
+ // Assert - Wait for API call to complete
+ await waitFor(() => {
+ expect(mockCreateEmptyDataset).toHaveBeenCalled()
+ })
+ // onHide should not be called on failure
+ expect(mockOnHide).not.toHaveBeenCalled()
+ })
+
+ it('should not invalidate dataset list on API failure', async () => {
+ // Arrange
+ mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
+ const props = createDefaultProps()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Test Dataset' } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalled()
+ })
+ expect(mockInvalidDatasetList).not.toHaveBeenCalled()
+ })
+ })
+
+ // ==========================================
+ // Router Navigation - Test Next.js router
+ // ==========================================
+ describe('Router Navigation', () => {
+ it('should navigate to dataset documents page after successful creation', async () => {
+ // Arrange
+ mockCreateEmptyDataset.mockResolvedValue({
+ id: 'test-dataset-456',
+ name: 'Test',
+ } as ReturnType extends Promise ? T : never)
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Test' } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-456/documents')
+ })
+ })
+
+ it('should not navigate on validation error', async () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act - Click confirm with empty input
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalled()
+ })
+ expect(mockPush).not.toHaveBeenCalled()
+ })
+
+ it('should not navigate on API error', async () => {
+ // Arrange
+ mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
+ const props = createDefaultProps()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Test' } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalled()
+ })
+ expect(mockPush).not.toHaveBeenCalled()
+ })
+ })
+
+ // ==========================================
+ // Edge Cases - Test boundary conditions and error handling
+ // ==========================================
+ describe('Edge Cases', () => {
+ it('should handle whitespace-only input as valid (component behavior)', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act - Enter whitespace only
+ fireEvent.change(input, { target: { value: ' ' } })
+ fireEvent.click(confirmButton)
+
+ // Assert - Current implementation treats whitespace as valid input
+ await waitFor(() => {
+ expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: ' ' })
+ })
+ })
+
+ it('should handle special characters in input', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Test @#$% Dataset!' } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Test @#$% Dataset!' })
+ })
+ })
+
+ it('should handle Unicode characters in input', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: '数据集测试 🚀' } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: '数据集测试 🚀' })
+ })
+ })
+
+ it('should handle input at exactly 40 character boundary', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act - Test boundary: 40 characters is valid
+ const name40Chars = 'A'.repeat(40)
+ fireEvent.change(input, { target: { value: name40Chars } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: name40Chars })
+ })
+ })
+
+ it('should reject input at 41 character boundary', async () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act - Test boundary: 41 characters is invalid
+ const name41Chars = 'A'.repeat(41)
+ fireEvent.change(input, { target: { value: name41Chars } })
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'datasetCreation.stepOne.modal.nameLengthInvalid',
+ })
+ })
+ expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
+ })
+
+ it('should handle rapid consecutive submits', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act - Rapid clicks
+ fireEvent.change(input, { target: { value: 'Test' } })
+ fireEvent.click(confirmButton)
+ fireEvent.click(confirmButton)
+ fireEvent.click(confirmButton)
+
+ // Assert - API will be called multiple times (no debounce in current implementation)
+ await waitFor(() => {
+ expect(mockCreateEmptyDataset).toHaveBeenCalled()
+ })
+ })
+
+ it('should handle input with leading/trailing spaces', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: ' Dataset Name ' } })
+ fireEvent.click(confirmButton)
+
+ // Assert - Current implementation does not trim spaces
+ await waitFor(() => {
+ expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: ' Dataset Name ' })
+ })
+ })
+
+ it('should handle newline characters in input (browser strips newlines)', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Line1\nLine2' } })
+ fireEvent.click(confirmButton)
+
+ // Assert - HTML input elements strip newline characters (expected browser behavior)
+ await waitFor(() => {
+ expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Line1Line2' })
+ })
+ })
+ })
+
+ // ==========================================
+ // Validation Tests - Test input validation
+ // ==========================================
+ describe('Validation', () => {
+ it('should not submit when input is empty string', async () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.click(confirmButton)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'datasetCreation.stepOne.modal.nameNotEmpty',
+ })
+ })
+ })
+
+ it('should validate length before calling API', async () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'A'.repeat(50) } })
+ fireEvent.click(confirmButton)
+
+ // Assert - Should show error before API call
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'datasetCreation.stepOne.modal.nameLengthInvalid',
+ })
+ })
+ expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
+ })
+
+ it('should validate empty string before length check', async () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act - Don't enter anything
+ fireEvent.click(confirmButton)
+
+ // Assert - Should show empty error, not length error
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'datasetCreation.stepOne.modal.nameNotEmpty',
+ })
+ })
+ })
+ })
+
+ // ==========================================
+ // Integration Tests - Test complete flows
+ // ==========================================
+ describe('Integration', () => {
+ it('should complete full successful creation flow', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ mockCreateEmptyDataset.mockResolvedValue({
+ id: 'new-id-789',
+ name: 'Complete Flow Test',
+ } as ReturnType extends Promise ? T : never)
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Complete Flow Test' } })
+ fireEvent.click(confirmButton)
+
+ // Assert - Verify complete flow
+ await waitFor(() => {
+ // 1. API called
+ expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Complete Flow Test' })
+ // 2. Dataset list invalidated
+ expect(mockInvalidDatasetList).toHaveBeenCalled()
+ // 3. Modal closed
+ expect(mockOnHide).toHaveBeenCalled()
+ // 4. Navigation happened
+ expect(mockPush).toHaveBeenCalledWith('/datasets/new-id-789/documents')
+ })
+ })
+
+ it('should handle error flow correctly', async () => {
+ // Arrange
+ const mockOnHide = jest.fn()
+ mockCreateEmptyDataset.mockRejectedValue(new Error('Server Error'))
+ render( )
+ const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
+ const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
+
+ // Act
+ fireEvent.change(input, { target: { value: 'Error Test' } })
+ fireEvent.click(confirmButton)
+
+ // Assert - Verify error handling
+ await waitFor(() => {
+ // 1. API was called
+ expect(mockCreateEmptyDataset).toHaveBeenCalled()
+ // 2. Error notification shown
+ expect(mockNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'datasetCreation.stepOne.modal.failed',
+ })
+ })
+
+ // 3. These should NOT happen on error
+ expect(mockInvalidDatasetList).not.toHaveBeenCalled()
+ expect(mockOnHide).not.toHaveBeenCalled()
+ expect(mockPush).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx
index 29986e4fca..db5aa8e48d 100644
--- a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx
+++ b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx
@@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import s from './index.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Modal from '@/app/components/base/modal'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
diff --git a/web/app/components/datasets/create/file-preview/index.spec.tsx b/web/app/components/datasets/create/file-preview/index.spec.tsx
new file mode 100644
index 0000000000..b7d7b489b4
--- /dev/null
+++ b/web/app/components/datasets/create/file-preview/index.spec.tsx
@@ -0,0 +1,873 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import FilePreview from './index'
+import type { CustomFile as File } from '@/models/datasets'
+import { fetchFilePreview } from '@/service/common'
+
+// Mock the fetchFilePreview service
+jest.mock('@/service/common', () => ({
+ fetchFilePreview: jest.fn(),
+}))
+
+const mockFetchFilePreview = fetchFilePreview as jest.MockedFunction
+
+// Factory function to create mock file objects
+const createMockFile = (overrides: Partial = {}): File => {
+ const file = new window.File(['test content'], 'test-file.txt', {
+ type: 'text/plain',
+ }) as File
+ return Object.assign(file, {
+ id: 'file-123',
+ extension: 'txt',
+ mime_type: 'text/plain',
+ created_by: 'user-1',
+ created_at: Date.now(),
+ ...overrides,
+ })
+}
+
+// Helper to render FilePreview with default props
+const renderFilePreview = (props: Partial<{ file?: File; hidePreview: () => void }> = {}) => {
+ const defaultProps = {
+ file: createMockFile(),
+ hidePreview: jest.fn(),
+ ...props,
+ }
+ return {
+ ...render( ),
+ props: defaultProps,
+ }
+}
+
+// Helper to find the loading spinner element
+const findLoadingSpinner = (container: HTMLElement) => {
+ return container.querySelector('.spin-animation')
+}
+
+// ============================================================================
+// FilePreview Component Tests
+// ============================================================================
+describe('FilePreview', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ // Default successful API response
+ mockFetchFilePreview.mockResolvedValue({ content: 'Preview content here' })
+ })
+
+ // --------------------------------------------------------------------------
+ // Rendering Tests - Verify component renders properly
+ // --------------------------------------------------------------------------
+ describe('Rendering', () => {
+ it('should render without crashing', async () => {
+ // Arrange & Act
+ renderFilePreview()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
+ })
+ })
+
+ it('should render file preview header', async () => {
+ // Arrange & Act
+ renderFilePreview()
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
+ })
+
+ it('should render close button with XMarkIcon', async () => {
+ // Arrange & Act
+ const { container } = renderFilePreview()
+
+ // Assert
+ const closeButton = container.querySelector('.cursor-pointer')
+ expect(closeButton).toBeInTheDocument()
+ const xMarkIcon = closeButton?.querySelector('svg')
+ expect(xMarkIcon).toBeInTheDocument()
+ })
+
+ it('should render file name without extension', async () => {
+ // Arrange
+ const file = createMockFile({ name: 'document.pdf' })
+
+ // Act
+ renderFilePreview({ file })
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('document')).toBeInTheDocument()
+ })
+ })
+
+ it('should render file extension', async () => {
+ // Arrange
+ const file = createMockFile({ extension: 'pdf' })
+
+ // Act
+ renderFilePreview({ file })
+
+ // Assert
+ expect(screen.getByText('.pdf')).toBeInTheDocument()
+ })
+
+ it('should apply correct CSS classes to container', async () => {
+ // Arrange & Act
+ const { container } = renderFilePreview()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('h-full')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Loading State Tests
+ // --------------------------------------------------------------------------
+ describe('Loading State', () => {
+ it('should show loading indicator initially', async () => {
+ // Arrange - Delay API response to keep loading state
+ mockFetchFilePreview.mockImplementation(
+ () => new Promise(resolve => setTimeout(() => resolve({ content: 'test' }), 100)),
+ )
+
+ // Act
+ const { container } = renderFilePreview()
+
+ // Assert - Loading should be visible initially (using spin-animation class)
+ const loadingElement = findLoadingSpinner(container)
+ expect(loadingElement).toBeInTheDocument()
+ })
+
+ it('should hide loading indicator after content loads', async () => {
+ // Arrange
+ mockFetchFilePreview.mockResolvedValue({ content: 'Loaded content' })
+
+ // Act
+ const { container } = renderFilePreview()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('Loaded content')).toBeInTheDocument()
+ })
+ // Loading should be gone
+ const loadingElement = findLoadingSpinner(container)
+ expect(loadingElement).not.toBeInTheDocument()
+ })
+
+ it('should show loading when file changes', async () => {
+ // Arrange
+ const file1 = createMockFile({ id: 'file-1', name: 'file1.txt' })
+ const file2 = createMockFile({ id: 'file-2', name: 'file2.txt' })
+
+ let resolveFirst: (value: { content: string }) => void
+ let resolveSecond: (value: { content: string }) => void
+
+ mockFetchFilePreview
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
+
+ // Act - Initial render
+ const { rerender, container } = render(
+ ,
+ )
+
+ // First file loading - spinner should be visible
+ expect(findLoadingSpinner(container)).toBeInTheDocument()
+
+ // Resolve first file
+ await act(async () => {
+ resolveFirst({ content: 'Content 1' })
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Content 1')).toBeInTheDocument()
+ })
+
+ // Rerender with new file
+ rerender( )
+
+ // Should show loading again
+ await waitFor(() => {
+ expect(findLoadingSpinner(container)).toBeInTheDocument()
+ })
+
+ // Resolve second file
+ await act(async () => {
+ resolveSecond({ content: 'Content 2' })
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Content 2')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // API Call Tests
+ // --------------------------------------------------------------------------
+ describe('API Calls', () => {
+ it('should call fetchFilePreview with correct fileID', async () => {
+ // Arrange
+ const file = createMockFile({ id: 'test-file-id' })
+
+ // Act
+ renderFilePreview({ file })
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'test-file-id' })
+ })
+ })
+
+ it('should not call fetchFilePreview when file is undefined', async () => {
+ // Arrange & Act
+ renderFilePreview({ file: undefined })
+
+ // Assert
+ expect(mockFetchFilePreview).not.toHaveBeenCalled()
+ })
+
+ it('should not call fetchFilePreview when file has no id', async () => {
+ // Arrange
+ const file = createMockFile({ id: undefined })
+
+ // Act
+ renderFilePreview({ file })
+
+ // Assert
+ expect(mockFetchFilePreview).not.toHaveBeenCalled()
+ })
+
+ it('should call fetchFilePreview again when file changes', async () => {
+ // Arrange
+ const file1 = createMockFile({ id: 'file-1' })
+ const file2 = createMockFile({ id: 'file-2' })
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-1' })
+ })
+
+ rerender( )
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-2' })
+ expect(mockFetchFilePreview).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ it('should handle API success and display content', async () => {
+ // Arrange
+ mockFetchFilePreview.mockResolvedValue({ content: 'File preview content from API' })
+
+ // Act
+ renderFilePreview()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('File preview content from API')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle API error gracefully', async () => {
+ // Arrange
+ mockFetchFilePreview.mockRejectedValue(new Error('Network error'))
+
+ // Act
+ const { container } = renderFilePreview()
+
+ // Assert - Component should not crash, loading may persist
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ // No error thrown, component still rendered
+ expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
+ })
+
+ it('should handle empty content response', async () => {
+ // Arrange
+ mockFetchFilePreview.mockResolvedValue({ content: '' })
+
+ // Act
+ const { container } = renderFilePreview()
+
+ // Assert - Should still render without loading
+ await waitFor(() => {
+ const loadingElement = findLoadingSpinner(container)
+ expect(loadingElement).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // User Interactions Tests
+ // --------------------------------------------------------------------------
+ describe('User Interactions', () => {
+ it('should call hidePreview when close button is clicked', async () => {
+ // Arrange
+ const hidePreview = jest.fn()
+ const { container } = renderFilePreview({ hidePreview })
+
+ // Act
+ const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
+ fireEvent.click(closeButton)
+
+ // Assert
+ expect(hidePreview).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call hidePreview with event object when clicked', async () => {
+ // Arrange
+ const hidePreview = jest.fn()
+ const { container } = renderFilePreview({ hidePreview })
+
+ // Act
+ const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
+ fireEvent.click(closeButton)
+
+ // Assert - onClick receives the event object
+ expect(hidePreview).toHaveBeenCalled()
+ expect(hidePreview.mock.calls[0][0]).toBeDefined()
+ })
+
+ it('should handle multiple clicks on close button', async () => {
+ // Arrange
+ const hidePreview = jest.fn()
+ const { container } = renderFilePreview({ hidePreview })
+
+ // Act
+ const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
+ fireEvent.click(closeButton)
+ fireEvent.click(closeButton)
+ fireEvent.click(closeButton)
+
+ // Assert
+ expect(hidePreview).toHaveBeenCalledTimes(3)
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // State Management Tests
+ // --------------------------------------------------------------------------
+ describe('State Management', () => {
+ it('should initialize with loading state true', async () => {
+ // Arrange - Keep loading indefinitely (never resolves)
+ mockFetchFilePreview.mockImplementation(() => new Promise(() => { /* intentionally empty */ }))
+
+ // Act
+ const { container } = renderFilePreview()
+
+ // Assert
+ const loadingElement = findLoadingSpinner(container)
+ expect(loadingElement).toBeInTheDocument()
+ })
+
+ it('should update previewContent state after successful fetch', async () => {
+ // Arrange
+ mockFetchFilePreview.mockResolvedValue({ content: 'New preview content' })
+
+ // Act
+ renderFilePreview()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('New preview content')).toBeInTheDocument()
+ })
+ })
+
+ it('should reset loading to true when file changes', async () => {
+ // Arrange
+ const file1 = createMockFile({ id: 'file-1' })
+ const file2 = createMockFile({ id: 'file-2' })
+
+ mockFetchFilePreview
+ .mockResolvedValueOnce({ content: 'Content 1' })
+ .mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
+
+ // Act
+ const { rerender, container } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByText('Content 1')).toBeInTheDocument()
+ })
+
+ // Change file
+ rerender( )
+
+ // Assert - Loading should be shown again
+ await waitFor(() => {
+ const loadingElement = findLoadingSpinner(container)
+ expect(loadingElement).toBeInTheDocument()
+ })
+ })
+
+ it('should preserve content until new content loads', async () => {
+ // Arrange
+ const file1 = createMockFile({ id: 'file-1' })
+ const file2 = createMockFile({ id: 'file-2' })
+
+ let resolveSecond: (value: { content: string }) => void
+
+ mockFetchFilePreview
+ .mockResolvedValueOnce({ content: 'Content 1' })
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByText('Content 1')).toBeInTheDocument()
+ })
+
+ // Change file - loading should replace content
+ rerender( )
+
+ // Resolve second fetch
+ await act(async () => {
+ resolveSecond({ content: 'Content 2' })
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Content 2')).toBeInTheDocument()
+ expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Props Testing
+ // --------------------------------------------------------------------------
+ describe('Props', () => {
+ describe('file prop', () => {
+ it('should render correctly with file prop', async () => {
+ // Arrange
+ const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' })
+
+ // Act
+ renderFilePreview({ file })
+
+ // Assert
+ expect(screen.getByText('my-document')).toBeInTheDocument()
+ expect(screen.getByText('.pdf')).toBeInTheDocument()
+ })
+
+ it('should render correctly without file prop', async () => {
+ // Arrange & Act
+ renderFilePreview({ file: undefined })
+
+ // Assert - Header should still render
+ expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
+ })
+
+ it('should handle file with multiple dots in name', async () => {
+ // Arrange
+ const file = createMockFile({ name: 'my.document.v2.pdf' })
+
+ // Act
+ renderFilePreview({ file })
+
+ // Assert - Should join all parts except last with comma
+ expect(screen.getByText('my,document,v2')).toBeInTheDocument()
+ })
+
+ it('should handle file with no extension in name', async () => {
+ // Arrange
+ const file = createMockFile({ name: 'README' })
+
+ // Act
+ const { container } = renderFilePreview({ file })
+
+ // Assert - getFileName returns empty for single segment, but component still renders
+ const fileNameElement = container.querySelector('.fileName')
+ expect(fileNameElement).toBeInTheDocument()
+ // The first span (file name) should be empty
+ const fileNameSpan = fileNameElement?.querySelector('span:first-child')
+ expect(fileNameSpan?.textContent).toBe('')
+ })
+
+ it('should handle file with empty name', async () => {
+ // Arrange
+ const file = createMockFile({ name: '' })
+
+ // Act
+ const { container } = renderFilePreview({ file })
+
+ // Assert - Should not crash
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ describe('hidePreview prop', () => {
+ it('should accept hidePreview callback', async () => {
+ // Arrange
+ const hidePreview = jest.fn()
+
+ // Act
+ renderFilePreview({ hidePreview })
+
+ // Assert - No errors thrown
+ expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Edge Cases Tests
+ // --------------------------------------------------------------------------
+ describe('Edge Cases', () => {
+ it('should handle file with undefined id', async () => {
+ // Arrange
+ const file = createMockFile({ id: undefined })
+
+ // Act
+ const { container } = renderFilePreview({ file })
+
+ // Assert - Should not call API, remain in loading state
+ expect(mockFetchFilePreview).not.toHaveBeenCalled()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle file with empty string id', async () => {
+ // Arrange
+ const file = createMockFile({ id: '' })
+
+ // Act
+ renderFilePreview({ file })
+
+ // Assert - Empty string is falsy, should not call API
+ expect(mockFetchFilePreview).not.toHaveBeenCalled()
+ })
+
+ it('should handle very long file names', async () => {
+ // Arrange
+ const longName = `${'a'.repeat(200)}.pdf`
+ const file = createMockFile({ name: longName })
+
+ // Act
+ renderFilePreview({ file })
+
+ // Assert
+ expect(screen.getByText('a'.repeat(200))).toBeInTheDocument()
+ })
+
+ it('should handle file with special characters in name', async () => {
+ // Arrange
+ const file = createMockFile({ name: 'file-with_special@#$%.txt' })
+
+ // Act
+ renderFilePreview({ file })
+
+ // Assert
+ expect(screen.getByText('file-with_special@#$%')).toBeInTheDocument()
+ })
+
+ it('should handle very long preview content', async () => {
+ // Arrange
+ const longContent = 'x'.repeat(10000)
+ mockFetchFilePreview.mockResolvedValue({ content: longContent })
+
+ // Act
+ renderFilePreview()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(longContent)).toBeInTheDocument()
+ })
+ })
+
+ it('should handle preview content with special characters safely', async () => {
+ // Arrange
+ const specialContent = '\n\t& < > "'
+ mockFetchFilePreview.mockResolvedValue({ content: specialContent })
+
+ // Act
+ const { container } = renderFilePreview()
+
+ // Assert - Should render as text, not execute scripts
+ await waitFor(() => {
+ const contentDiv = container.querySelector('.fileContent')
+ expect(contentDiv).toBeInTheDocument()
+ // Content is escaped by React, so HTML entities are displayed
+ expect(contentDiv?.textContent).toContain('alert')
+ })
+ })
+
+ it('should handle preview content with unicode', async () => {
+ // Arrange
+ const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs'
+ mockFetchFilePreview.mockResolvedValue({ content: unicodeContent })
+
+ // Act
+ renderFilePreview()
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(unicodeContent)).toBeInTheDocument()
+ })
+ })
+
+ it('should handle preview content with newlines', async () => {
+ // Arrange
+ const multilineContent = 'Line 1\nLine 2\nLine 3'
+ mockFetchFilePreview.mockResolvedValue({ content: multilineContent })
+
+ // Act
+ const { container } = renderFilePreview()
+
+ // Assert - Content should be in the DOM
+ await waitFor(() => {
+ const contentDiv = container.querySelector('.fileContent')
+ expect(contentDiv).toBeInTheDocument()
+ expect(contentDiv?.textContent).toContain('Line 1')
+ expect(contentDiv?.textContent).toContain('Line 2')
+ expect(contentDiv?.textContent).toContain('Line 3')
+ })
+ })
+
+ it('should handle null content from API', async () => {
+ // Arrange
+ mockFetchFilePreview.mockResolvedValue({ content: null as unknown as string })
+
+ // Act
+ const { container } = renderFilePreview()
+
+ // Assert - Should not crash
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Side Effects and Cleanup Tests
+ // --------------------------------------------------------------------------
+ describe('Side Effects and Cleanup', () => {
+ it('should trigger effect when file prop changes', async () => {
+ // Arrange
+ const file1 = createMockFile({ id: 'file-1' })
+ const file2 = createMockFile({ id: 'file-2' })
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
+ })
+
+ rerender( )
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchFilePreview).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ it('should not trigger effect when hidePreview changes', async () => {
+ // Arrange
+ const file = createMockFile()
+ const hidePreview1 = jest.fn()
+ const hidePreview2 = jest.fn()
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
+ })
+
+ rerender( )
+
+ // Assert - Should not call API again (file didn't change)
+ // Note: This depends on useEffect dependency array only including [file]
+ await waitFor(() => {
+ expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('should handle rapid file changes', async () => {
+ // Arrange
+ const files = Array.from({ length: 5 }, (_, i) =>
+ createMockFile({ id: `file-${i}` }),
+ )
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ // Rapidly change files
+ for (let i = 1; i < files.length; i++)
+ rerender( )
+
+ // Assert - Should have called API for each file
+ await waitFor(() => {
+ expect(mockFetchFilePreview).toHaveBeenCalledTimes(5)
+ })
+ })
+
+ it('should handle unmount during loading', async () => {
+ // Arrange
+ mockFetchFilePreview.mockImplementation(
+ () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)),
+ )
+
+ // Act
+ const { unmount } = renderFilePreview()
+
+ // Unmount before API resolves
+ unmount()
+
+ // Assert - No errors should be thrown (React handles state updates on unmounted)
+ expect(true).toBe(true)
+ })
+
+ it('should handle file changing from defined to undefined', async () => {
+ // Arrange
+ const file = createMockFile()
+
+ // Act
+ const { rerender, container } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
+ })
+
+ rerender( )
+
+ // Assert - Should not crash, API should not be called again
+ expect(container.firstChild).toBeInTheDocument()
+ expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // getFileName Helper Tests
+ // --------------------------------------------------------------------------
+ describe('getFileName Helper', () => {
+ it('should extract name without extension for simple filename', async () => {
+ // Arrange
+ const file = createMockFile({ name: 'document.pdf' })
+
+ // Act
+ renderFilePreview({ file })
+
+ // Assert
+ expect(screen.getByText('document')).toBeInTheDocument()
+ })
+
+ it('should handle filename with multiple dots', async () => {
+ // Arrange
+ const file = createMockFile({ name: 'file.name.with.dots.txt' })
+
+ // Act
+ renderFilePreview({ file })
+
+ // Assert - Should join all parts except last with comma
+ expect(screen.getByText('file,name,with,dots')).toBeInTheDocument()
+ })
+
+ it('should return empty for filename without dot', async () => {
+ // Arrange
+ const file = createMockFile({ name: 'nodotfile' })
+
+ // Act
+ const { container } = renderFilePreview({ file })
+
+ // Assert - slice(0, -1) on single element array returns empty
+ const fileNameElement = container.querySelector('.fileName')
+ const firstSpan = fileNameElement?.querySelector('span:first-child')
+ expect(firstSpan?.textContent).toBe('')
+ })
+
+ it('should return empty string when file is undefined', async () => {
+ // Arrange & Act
+ const { container } = renderFilePreview({ file: undefined })
+
+ // Assert - File name area should have empty first span
+ const fileNameElement = container.querySelector('.system-xs-medium')
+ expect(fileNameElement).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Accessibility Tests
+ // --------------------------------------------------------------------------
+ describe('Accessibility', () => {
+ it('should have clickable close button with visual indicator', async () => {
+ // Arrange & Act
+ const { container } = renderFilePreview()
+
+ // Assert
+ const closeButton = container.querySelector('.cursor-pointer')
+ expect(closeButton).toBeInTheDocument()
+ expect(closeButton).toHaveClass('cursor-pointer')
+ })
+
+ it('should have proper heading structure', async () => {
+ // Arrange & Act
+ renderFilePreview()
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Error Handling Tests
+ // --------------------------------------------------------------------------
+ describe('Error Handling', () => {
+ it('should not crash on API network error', async () => {
+ // Arrange
+ mockFetchFilePreview.mockRejectedValue(new Error('Network Error'))
+
+ // Act
+ const { container } = renderFilePreview()
+
+ // Assert - Component should still render
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ it('should not crash on API timeout', async () => {
+ // Arrange
+ mockFetchFilePreview.mockRejectedValue(new Error('Timeout'))
+
+ // Act
+ const { container } = renderFilePreview()
+
+ // Assert
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ it('should not crash on malformed API response', async () => {
+ // Arrange
+ mockFetchFilePreview.mockResolvedValue({} as { content: string })
+
+ // Act
+ const { container } = renderFilePreview()
+
+ // Assert
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/file-preview/index.tsx b/web/app/components/datasets/create/file-preview/index.tsx
index 8a88834ebe..012f3608dc 100644
--- a/web/app/components/datasets/create/file-preview/index.tsx
+++ b/web/app/components/datasets/create/file-preview/index.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import { XMarkIcon } from '@heroicons/react/20/solid'
import Loading from '@/app/components/base/loading'
import s from './index.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { CustomFile as File } from '@/models/datasets'
import { fetchFilePreview } from '@/service/common'
diff --git a/web/app/components/datasets/create/file-uploader/index.tsx b/web/app/components/datasets/create/file-uploader/index.tsx
index 700a5f7680..20c8ea3633 100644
--- a/web/app/components/datasets/create/file-uploader/index.tsx
+++ b/web/app/components/datasets/create/file-uploader/index.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiDeleteBinLine, RiUploadCloud2Line } from '@remixicon/react'
import DocumentFileIcon from '../../common/document-file-icon'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { ToastContext } from '@/app/components/base/toast'
import SimplePieChart from '@/app/components/base/simple-pie-chart'
diff --git a/web/app/components/datasets/create/index.spec.tsx b/web/app/components/datasets/create/index.spec.tsx
new file mode 100644
index 0000000000..b0bac1a1cb
--- /dev/null
+++ b/web/app/components/datasets/create/index.spec.tsx
@@ -0,0 +1,1282 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import React from 'react'
+import DatasetUpdateForm from './index'
+import { ChunkingMode, DataSourceType, DatasetPermission } from '@/models/datasets'
+import type { DataSet } from '@/models/datasets'
+import { DataSourceProvider } from '@/models/common'
+import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
+import { RETRIEVE_METHOD } from '@/types/app'
+
+// IndexingType values from step-two (defined here since we mock step-two)
+// Using type assertion to match the expected IndexingType enum from step-two
+const IndexingTypeValues = {
+ QUALIFIED: 'high_quality' as const,
+ ECONOMICAL: 'economy' as const,
+}
+
+// ==========================================
+// Mock External Dependencies
+// ==========================================
+
+// Mock react-i18next (handled by __mocks__/react-i18next.ts but we override for custom messages)
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+// Mock next/link
+jest.mock('next/link', () => {
+ return function MockLink({ children, href }: { children: React.ReactNode; href: string }) {
+ return {children}
+ }
+})
+
+// Mock modal context
+const mockSetShowAccountSettingModal = jest.fn()
+jest.mock('@/context/modal-context', () => ({
+ useModalContextSelector: (selector: (state: any) => any) => {
+ const state = {
+ setShowAccountSettingModal: mockSetShowAccountSettingModal,
+ }
+ return selector(state)
+ },
+}))
+
+// Mock dataset detail context
+let mockDatasetDetail: DataSet | undefined
+jest.mock('@/context/dataset-detail', () => ({
+ useDatasetDetailContextWithSelector: (selector: (state: any) => any) => {
+ const state = {
+ dataset: mockDatasetDetail,
+ }
+ return selector(state)
+ },
+}))
+
+// Mock useDefaultModel hook
+let mockEmbeddingsDefaultModel: { model: string; provider: string } | undefined
+jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useDefaultModel: () => ({
+ data: mockEmbeddingsDefaultModel,
+ mutate: jest.fn(),
+ isLoading: false,
+ }),
+}))
+
+// Mock useGetDefaultDataSourceListAuth hook
+let mockDataSourceList: { result: DataSourceAuth[] } | undefined
+let mockIsLoadingDataSourceList = false
+let mockFetchingError = false
+jest.mock('@/service/use-datasource', () => ({
+ useGetDefaultDataSourceListAuth: () => ({
+ data: mockDataSourceList,
+ isLoading: mockIsLoadingDataSourceList,
+ isError: mockFetchingError,
+ }),
+}))
+
+// ==========================================
+// Mock Child Components
+// ==========================================
+
+// Track props passed to child components
+let stepOneProps: Record = {}
+let stepTwoProps: Record = {}
+let stepThreeProps: Record = {}
+// _topBarProps is assigned but not directly used in assertions - values checked via data-testid
+let _topBarProps: Record = {}
+
+jest.mock('./step-one', () => ({
+ __esModule: true,
+ default: (props: Record) => {
+ stepOneProps = props
+ return (
+
+ {props.dataSourceType}
+ {props.files?.length || 0}
+ {props.notionPages?.length || 0}
+ {props.websitePages?.length || 0}
+ Next Step
+ Open Settings
+ props.changeType(DataSourceType.NOTION)}
+ >
+ Change Type
+
+ props.updateFileList([{ fileID: 'test-1', file: { name: 'test.txt' }, progress: 0 }])}
+ >
+ Add File
+
+ {
+ const mockFile = { fileID: 'test-1', file: { name: 'test.txt' }, progress: 0 }
+ props.updateFile(mockFile, 50, [mockFile])
+ }}
+ >
+ Update File Progress
+
+ props.updateNotionPages([{ page_id: 'page-1', type: 'page' }])}
+ >
+ Add Notion Page
+
+ props.updateNotionCredentialId('credential-123')}
+ >
+ Update Credential
+
+ props.updateWebsitePages([{ title: 'Test', markdown: '', description: '', source_url: 'https://test.com' }])}
+ >
+ Add Website Page
+
+ props.onCrawlOptionsChange({ ...props.crawlOptions, limit: 20 })}
+ >
+ Update Crawl Options
+
+ props.onWebsiteCrawlProviderChange(DataSourceProvider.fireCrawl)}
+ >
+ Update Crawl Provider
+
+ props.onWebsiteCrawlJobIdChange('job-123')}
+ >
+ Update Job ID
+
+
+ )
+ },
+}))
+
+jest.mock('./step-two', () => ({
+ __esModule: true,
+ default: (props: Record) => {
+ stepTwoProps = props
+ return (
+
+ {String(props.isAPIKeySet)}
+ {props.dataSourceType}
+ {props.files?.length || 0}
+ props.onStepChange(-1)}>Prev Step
+ props.onStepChange(1)}>Next Step
+ Open Settings
+ props.updateIndexingTypeCache('high_quality')}
+ >
+ Update Indexing Cache
+
+ props.updateRetrievalMethodCache('semantic_search')}
+ >
+ Update Retrieval Cache
+
+ props.updateResultCache({ batch: 'batch-1', documents: [] })}
+ >
+ Update Result Cache
+
+
+ )
+ },
+}))
+
+jest.mock('./step-three', () => ({
+ __esModule: true,
+ default: (props: Record) => {
+ stepThreeProps = props
+ return (
+
+ {props.datasetId || 'none'}
+ {props.datasetName || 'none'}
+ {props.indexingType || 'none'}
+ {props.retrievalMethod || 'none'}
+
+ )
+ },
+}))
+
+jest.mock('./top-bar', () => ({
+ TopBar: (props: Record) => {
+ _topBarProps = props
+ return (
+
+ {props.activeIndex}
+ {props.datasetId || 'none'}
+
+ )
+ },
+}))
+
+// ==========================================
+// Test Data Builders
+// ==========================================
+
+const createMockDataset = (overrides?: Partial): DataSet => ({
+ id: 'dataset-123',
+ name: 'Test Dataset',
+ indexing_status: 'completed',
+ icon_info: { icon: '', icon_background: '', icon_type: 'emoji' as const },
+ description: 'Test description',
+ permission: DatasetPermission.onlyMe,
+ data_source_type: DataSourceType.FILE,
+ indexing_technique: IndexingTypeValues.QUALIFIED as any,
+ created_by: 'user-1',
+ updated_by: 'user-1',
+ updated_at: Date.now(),
+ app_count: 0,
+ doc_form: ChunkingMode.text,
+ document_count: 0,
+ total_document_count: 0,
+ word_count: 0,
+ provider: 'openai',
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ embedding_available: true,
+ retrieval_model_dict: {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_mode: undefined,
+ reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
+ weights: undefined,
+ top_k: 3,
+ score_threshold_enabled: false,
+ score_threshold: 0,
+ },
+ retrieval_model: {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_mode: undefined,
+ reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
+ weights: undefined,
+ top_k: 3,
+ score_threshold_enabled: false,
+ score_threshold: 0,
+ },
+ tags: [],
+ external_knowledge_info: {
+ external_knowledge_id: '',
+ external_knowledge_api_id: '',
+ external_knowledge_api_name: '',
+ external_knowledge_api_endpoint: '',
+ },
+ external_retrieval_model: {
+ top_k: 3,
+ score_threshold: 0.5,
+ score_threshold_enabled: false,
+ },
+ built_in_field_enabled: false,
+ runtime_mode: 'general' as const,
+ enable_api: false,
+ is_multimodal: false,
+ ...overrides,
+})
+
+const createMockDataSourceAuth = (overrides?: Partial): DataSourceAuth => ({
+ credential_id: 'cred-1',
+ provider: 'notion',
+ plugin_id: 'plugin-1',
+ ...overrides,
+} as DataSourceAuth)
+
+// ==========================================
+// Test Suite
+// ==========================================
+
+describe('DatasetUpdateForm', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ // Reset mock state
+ mockDatasetDetail = undefined
+ mockEmbeddingsDefaultModel = { model: 'text-embedding-ada-002', provider: 'openai' }
+ mockDataSourceList = { result: [createMockDataSourceAuth()] }
+ mockIsLoadingDataSourceList = false
+ mockFetchingError = false
+ // Reset captured props
+ stepOneProps = {}
+ stepTwoProps = {}
+ stepThreeProps = {}
+ _topBarProps = {}
+ })
+
+ // ==========================================
+ // Rendering Tests - Verify component renders correctly in different states
+ // ==========================================
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('top-bar')).toBeInTheDocument()
+ expect(screen.getByTestId('step-one')).toBeInTheDocument()
+ })
+
+ it('should render TopBar with correct active index for step 1', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('top-bar-active-index')).toHaveTextContent('0')
+ })
+
+ it('should render StepOne by default', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('step-one')).toBeInTheDocument()
+ expect(screen.queryByTestId('step-two')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('step-three')).not.toBeInTheDocument()
+ })
+
+ it('should show loading state when data source list is loading', () => {
+ // Arrange
+ mockIsLoadingDataSourceList = true
+
+ // Act
+ render( )
+
+ // Assert - Loading component should be rendered (not the steps)
+ expect(screen.queryByTestId('step-one')).not.toBeInTheDocument()
+ })
+
+ it('should show error state when fetching fails', () => {
+ // Arrange
+ mockFetchingError = true
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('datasetCreation.error.unavailable')).toBeInTheDocument()
+ })
+ })
+
+ // ==========================================
+ // Props Testing - Verify datasetId prop behavior
+ // ==========================================
+ describe('Props', () => {
+ describe('datasetId prop', () => {
+ it('should pass datasetId to TopBar', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('top-bar-dataset-id')).toHaveTextContent('dataset-abc')
+ })
+
+ it('should pass datasetId to StepOne', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(stepOneProps.datasetId).toBe('dataset-abc')
+ })
+
+ it('should render without datasetId', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('top-bar-dataset-id')).toHaveTextContent('none')
+ expect(stepOneProps.datasetId).toBeUndefined()
+ })
+ })
+ })
+
+ // ==========================================
+ // State Management - Test state initialization and transitions
+ // ==========================================
+ describe('State Management', () => {
+ describe('dataSourceType state', () => {
+ it('should initialize with FILE data source type', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('step-one-data-source-type')).toHaveTextContent(DataSourceType.FILE)
+ })
+
+ it('should update dataSourceType when changeType is called', () => {
+ // Arrange
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-change-type'))
+
+ // Assert
+ expect(screen.getByTestId('step-one-data-source-type')).toHaveTextContent(DataSourceType.NOTION)
+ })
+ })
+
+ describe('step state', () => {
+ it('should initialize at step 1', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('step-one')).toBeInTheDocument()
+ expect(screen.getByTestId('top-bar-active-index')).toHaveTextContent('0')
+ })
+
+ it('should transition to step 2 when nextStep is called', () => {
+ // Arrange
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Assert
+ expect(screen.queryByTestId('step-one')).not.toBeInTheDocument()
+ expect(screen.getByTestId('step-two')).toBeInTheDocument()
+ expect(screen.getByTestId('top-bar-active-index')).toHaveTextContent('1')
+ })
+
+ it('should transition to step 3 from step 2', () => {
+ // Arrange
+ render( )
+
+ // First go to step 2
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Act - go to step 3
+ fireEvent.click(screen.getByTestId('step-two-next'))
+
+ // Assert
+ expect(screen.queryByTestId('step-two')).not.toBeInTheDocument()
+ expect(screen.getByTestId('step-three')).toBeInTheDocument()
+ expect(screen.getByTestId('top-bar-active-index')).toHaveTextContent('2')
+ })
+
+ it('should go back to step 1 from step 2', () => {
+ // Arrange
+ render( )
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-two-prev'))
+
+ // Assert
+ expect(screen.getByTestId('step-one')).toBeInTheDocument()
+ expect(screen.queryByTestId('step-two')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('fileList state', () => {
+ it('should initialize with empty file list', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('step-one-files-count')).toHaveTextContent('0')
+ })
+
+ it('should update file list when updateFileList is called', () => {
+ // Arrange
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-update-files'))
+
+ // Assert
+ expect(screen.getByTestId('step-one-files-count')).toHaveTextContent('1')
+ })
+ })
+
+ describe('notionPages state', () => {
+ it('should initialize with empty notion pages', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('step-one-notion-pages-count')).toHaveTextContent('0')
+ })
+
+ it('should update notion pages when updateNotionPages is called', () => {
+ // Arrange
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-update-notion-pages'))
+
+ // Assert
+ expect(screen.getByTestId('step-one-notion-pages-count')).toHaveTextContent('1')
+ })
+ })
+
+ describe('websitePages state', () => {
+ it('should initialize with empty website pages', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('step-one-website-pages-count')).toHaveTextContent('0')
+ })
+
+ it('should update website pages when setWebsitePages is called', () => {
+ // Arrange
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-update-website-pages'))
+
+ // Assert
+ expect(screen.getByTestId('step-one-website-pages-count')).toHaveTextContent('1')
+ })
+ })
+ })
+
+ // ==========================================
+ // Callback Stability - Test memoization of callbacks
+ // ==========================================
+ describe('Callback Stability and Memoization', () => {
+ it('should provide stable updateNotionPages callback reference', () => {
+ // Arrange
+ const { rerender } = render( )
+ const initialCallback = stepOneProps.updateNotionPages
+
+ // Act - trigger a rerender
+ rerender( )
+
+ // Assert - callback reference should be the same due to useCallback
+ expect(stepOneProps.updateNotionPages).toBe(initialCallback)
+ })
+
+ it('should provide stable updateNotionCredentialId callback reference', () => {
+ // Arrange
+ const { rerender } = render( )
+ const initialCallback = stepOneProps.updateNotionCredentialId
+
+ // Act
+ rerender( )
+
+ // Assert
+ expect(stepOneProps.updateNotionCredentialId).toBe(initialCallback)
+ })
+
+ it('should provide stable updateFileList callback reference', () => {
+ // Arrange
+ const { rerender } = render( )
+ const initialCallback = stepOneProps.updateFileList
+
+ // Act
+ rerender( )
+
+ // Assert
+ expect(stepOneProps.updateFileList).toBe(initialCallback)
+ })
+
+ it('should provide stable updateFile callback reference', () => {
+ // Arrange
+ const { rerender } = render( )
+ const initialCallback = stepOneProps.updateFile
+
+ // Act
+ rerender( )
+
+ // Assert
+ expect(stepOneProps.updateFile).toBe(initialCallback)
+ })
+
+ it('should provide stable updateIndexingTypeCache callback reference', () => {
+ // Arrange
+ const { rerender } = render( )
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ const initialCallback = stepTwoProps.updateIndexingTypeCache
+
+ // Act - trigger a rerender without changing step
+ rerender( )
+
+ // Assert - callbacks with same dependencies should be stable
+ expect(stepTwoProps.updateIndexingTypeCache).toBe(initialCallback)
+ })
+ })
+
+ // ==========================================
+ // User Interactions - Test event handlers
+ // ==========================================
+ describe('User Interactions', () => {
+ it('should open account settings when onSetting is called from StepOne', () => {
+ // Arrange
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-setting'))
+
+ // Assert
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' })
+ })
+
+ it('should open provider settings when onSetting is called from StepTwo', () => {
+ // Arrange
+ render( )
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-two-setting'))
+
+ // Assert
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' })
+ })
+
+ it('should update crawl options when onCrawlOptionsChange is called', () => {
+ // Arrange
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-update-crawl-options'))
+
+ // Assert
+ expect(stepOneProps.crawlOptions.limit).toBe(20)
+ })
+
+ it('should update crawl provider when onWebsiteCrawlProviderChange is called', () => {
+ // Arrange
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-update-crawl-provider'))
+
+ // Assert - Need to verify state through StepTwo props
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ expect(stepTwoProps.websiteCrawlProvider).toBe(DataSourceProvider.fireCrawl)
+ })
+
+ it('should update job id when onWebsiteCrawlJobIdChange is called', () => {
+ // Arrange
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-update-job-id'))
+
+ // Assert - Verify through StepTwo props
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ expect(stepTwoProps.websiteCrawlJobId).toBe('job-123')
+ })
+
+ it('should update file progress correctly using immer produce', () => {
+ // Arrange
+ render( )
+ fireEvent.click(screen.getByTestId('step-one-update-files'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-update-file-progress'))
+
+ // Assert - Progress should be updated
+ expect(stepOneProps.files[0].progress).toBe(50)
+ })
+
+ it('should update notion credential id', () => {
+ // Arrange
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-update-notion-credential'))
+
+ // Assert
+ expect(stepOneProps.notionCredentialId).toBe('credential-123')
+ })
+ })
+
+ // ==========================================
+ // Step Two Specific Tests
+ // ==========================================
+ describe('StepTwo Rendering and Props', () => {
+ it('should pass isAPIKeySet as true when embeddingsDefaultModel exists', () => {
+ // Arrange
+ mockEmbeddingsDefaultModel = { model: 'model-1', provider: 'openai' }
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Assert
+ expect(screen.getByTestId('step-two-is-api-key-set')).toHaveTextContent('true')
+ })
+
+ it('should pass isAPIKeySet as false when embeddingsDefaultModel is undefined', () => {
+ // Arrange
+ mockEmbeddingsDefaultModel = undefined
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Assert
+ expect(screen.getByTestId('step-two-is-api-key-set')).toHaveTextContent('false')
+ })
+
+ it('should pass correct dataSourceType to StepTwo', () => {
+ // Arrange
+ render( )
+ fireEvent.click(screen.getByTestId('step-one-change-type'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Assert
+ expect(screen.getByTestId('step-two-data-source-type')).toHaveTextContent(DataSourceType.NOTION)
+ })
+
+ it('should pass files mapped to file property to StepTwo', () => {
+ // Arrange
+ render( )
+ fireEvent.click(screen.getByTestId('step-one-update-files'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Assert
+ expect(screen.getByTestId('step-two-files-count')).toHaveTextContent('1')
+ })
+
+ it('should update indexing type cache from StepTwo', () => {
+ // Arrange
+ render( )
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-two-update-indexing-cache'))
+
+ // Assert - Go to step 3 and verify
+ fireEvent.click(screen.getByTestId('step-two-next'))
+ expect(screen.getByTestId('step-three-indexing-type')).toHaveTextContent('high_quality')
+ })
+
+ it('should update retrieval method cache from StepTwo', () => {
+ // Arrange
+ render( )
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-two-update-retrieval-cache'))
+
+ // Assert - Go to step 3 and verify
+ fireEvent.click(screen.getByTestId('step-two-next'))
+ expect(screen.getByTestId('step-three-retrieval-method')).toHaveTextContent('semantic_search')
+ })
+
+ it('should update result cache from StepTwo', () => {
+ // Arrange
+ render( )
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-two-update-result-cache'))
+
+ // Assert - Go to step 3 and verify creationCache is passed
+ fireEvent.click(screen.getByTestId('step-two-next'))
+ expect(stepThreeProps.creationCache).toBeDefined()
+ expect(stepThreeProps.creationCache?.batch).toBe('batch-1')
+ })
+ })
+
+ // ==========================================
+ // Step Two with datasetId and datasetDetail
+ // ==========================================
+ describe('StepTwo with existing dataset', () => {
+ it('should not render StepTwo when datasetId exists but datasetDetail is undefined', () => {
+ // Arrange
+ mockDatasetDetail = undefined
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Assert - StepTwo should not render due to condition
+ expect(screen.queryByTestId('step-two')).not.toBeInTheDocument()
+ })
+
+ it('should render StepTwo when datasetId exists and datasetDetail is defined', () => {
+ // Arrange
+ mockDatasetDetail = createMockDataset()
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Assert
+ expect(screen.getByTestId('step-two')).toBeInTheDocument()
+ })
+
+ it('should pass indexingType from datasetDetail to StepTwo', () => {
+ // Arrange
+ mockDatasetDetail = createMockDataset({ indexing_technique: IndexingTypeValues.ECONOMICAL as any })
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Assert
+ expect(stepTwoProps.indexingType).toBe('economy')
+ })
+ })
+
+ // ==========================================
+ // Step Three Tests
+ // ==========================================
+ describe('StepThree Rendering and Props', () => {
+ it('should pass datasetId to StepThree', () => {
+ // Arrange - Need datasetDetail for StepTwo to render when datasetId exists
+ mockDatasetDetail = createMockDataset()
+ render( )
+
+ // Act - Navigate to step 3
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ fireEvent.click(screen.getByTestId('step-two-next'))
+
+ // Assert
+ expect(screen.getByTestId('step-three-dataset-id')).toHaveTextContent('dataset-456')
+ })
+
+ it('should pass datasetName from datasetDetail to StepThree', () => {
+ // Arrange
+ mockDatasetDetail = createMockDataset({ name: 'My Special Dataset' })
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ fireEvent.click(screen.getByTestId('step-two-next'))
+
+ // Assert
+ expect(screen.getByTestId('step-three-dataset-name')).toHaveTextContent('My Special Dataset')
+ })
+
+ it('should use cached indexing type when datasetDetail indexing_technique is not available', () => {
+ // Arrange
+ render( )
+
+ // Navigate to step 2 and set cache
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ fireEvent.click(screen.getByTestId('step-two-update-indexing-cache'))
+
+ // Act - Navigate to step 3
+ fireEvent.click(screen.getByTestId('step-two-next'))
+
+ // Assert
+ expect(screen.getByTestId('step-three-indexing-type')).toHaveTextContent('high_quality')
+ })
+
+ it('should use datasetDetail indexing_technique over cached value', () => {
+ // Arrange
+ mockDatasetDetail = createMockDataset({ indexing_technique: IndexingTypeValues.ECONOMICAL as any })
+ render( )
+
+ // Navigate to step 2 and set different cache
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ fireEvent.click(screen.getByTestId('step-two-update-indexing-cache'))
+
+ // Act - Navigate to step 3
+ fireEvent.click(screen.getByTestId('step-two-next'))
+
+ // Assert - Should use datasetDetail value, not cache
+ expect(screen.getByTestId('step-three-indexing-type')).toHaveTextContent('economy')
+ })
+
+ it('should use retrieval method from datasetDetail when available', () => {
+ // Arrange
+ mockDatasetDetail = createMockDataset()
+ mockDatasetDetail.retrieval_model_dict = {
+ ...mockDatasetDetail.retrieval_model_dict,
+ search_method: RETRIEVE_METHOD.fullText,
+ }
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ fireEvent.click(screen.getByTestId('step-two-next'))
+
+ // Assert
+ expect(screen.getByTestId('step-three-retrieval-method')).toHaveTextContent('full_text_search')
+ })
+ })
+
+ // ==========================================
+ // StepOne Props Tests
+ // ==========================================
+ describe('StepOne Props', () => {
+ it('should pass authedDataSourceList from hook response', () => {
+ // Arrange
+ const mockAuth = createMockDataSourceAuth({ provider: 'google-drive' })
+ mockDataSourceList = { result: [mockAuth] }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(stepOneProps.authedDataSourceList).toEqual([mockAuth])
+ })
+
+ it('should pass empty array when dataSourceList is undefined', () => {
+ // Arrange
+ mockDataSourceList = undefined
+
+ // Act
+ render( )
+
+ // Assert
+ expect(stepOneProps.authedDataSourceList).toEqual([])
+ })
+
+ it('should pass dataSourceTypeDisable as true when datasetDetail has data_source_type', () => {
+ // Arrange
+ mockDatasetDetail = createMockDataset({ data_source_type: DataSourceType.FILE })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(stepOneProps.dataSourceTypeDisable).toBe(true)
+ })
+
+ it('should pass dataSourceTypeDisable as false when datasetDetail is undefined', () => {
+ // Arrange
+ mockDatasetDetail = undefined
+
+ // Act
+ render( )
+
+ // Assert
+ expect(stepOneProps.dataSourceTypeDisable).toBe(false)
+ })
+
+ it('should pass default crawl options', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ expect(stepOneProps.crawlOptions).toEqual({
+ crawl_sub_pages: true,
+ only_main_content: true,
+ includes: '',
+ excludes: '',
+ limit: 10,
+ max_depth: '',
+ use_sitemap: true,
+ })
+ })
+ })
+
+ // ==========================================
+ // Edge Cases - Test boundary conditions and error handling
+ // ==========================================
+ describe('Edge Cases', () => {
+ it('should handle empty data source list', () => {
+ // Arrange
+ mockDataSourceList = { result: [] }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(stepOneProps.authedDataSourceList).toEqual([])
+ })
+
+ it('should handle undefined datasetDetail retrieval_model_dict', () => {
+ // Arrange
+ mockDatasetDetail = createMockDataset()
+ // @ts-expect-error - Testing undefined case
+ mockDatasetDetail.retrieval_model_dict = undefined
+ render( )
+
+ // Act
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ fireEvent.click(screen.getByTestId('step-two-update-retrieval-cache'))
+ fireEvent.click(screen.getByTestId('step-two-next'))
+
+ // Assert - Should use cached value
+ expect(screen.getByTestId('step-three-retrieval-method')).toHaveTextContent('semantic_search')
+ })
+
+ it('should handle step state correctly after multiple navigations', () => {
+ // Arrange
+ render( )
+
+ // Act - Navigate forward and back multiple times
+ fireEvent.click(screen.getByTestId('step-one-next')) // to step 2
+ fireEvent.click(screen.getByTestId('step-two-prev')) // back to step 1
+ fireEvent.click(screen.getByTestId('step-one-next')) // to step 2
+ fireEvent.click(screen.getByTestId('step-two-next')) // to step 3
+
+ // Assert
+ expect(screen.getByTestId('step-three')).toBeInTheDocument()
+ expect(screen.getByTestId('top-bar-active-index')).toHaveTextContent('2')
+ })
+
+ it('should handle result cache being undefined', () => {
+ // Arrange
+ render( )
+
+ // Act - Navigate to step 3 without setting result cache
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ fireEvent.click(screen.getByTestId('step-two-next'))
+
+ // Assert
+ expect(stepThreeProps.creationCache).toBeUndefined()
+ })
+
+ it('should pass result cache to step three', async () => {
+ // Arrange
+ render( )
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Set result cache value
+ fireEvent.click(screen.getByTestId('step-two-update-result-cache'))
+
+ // Navigate to step 3
+ fireEvent.click(screen.getByTestId('step-two-next'))
+
+ // Assert - Result cache is correctly passed to step three
+ expect(stepThreeProps.creationCache).toBeDefined()
+ expect(stepThreeProps.creationCache?.batch).toBe('batch-1')
+ })
+
+ it('should preserve state when navigating between steps', () => {
+ // Arrange
+ render( )
+
+ // Set up various states
+ fireEvent.click(screen.getByTestId('step-one-change-type'))
+ fireEvent.click(screen.getByTestId('step-one-update-files'))
+ fireEvent.click(screen.getByTestId('step-one-update-notion-pages'))
+
+ // Navigate to step 2 and back
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ fireEvent.click(screen.getByTestId('step-two-prev'))
+
+ // Assert - All state should be preserved
+ expect(screen.getByTestId('step-one-data-source-type')).toHaveTextContent(DataSourceType.NOTION)
+ expect(screen.getByTestId('step-one-files-count')).toHaveTextContent('1')
+ expect(screen.getByTestId('step-one-notion-pages-count')).toHaveTextContent('1')
+ })
+ })
+
+ // ==========================================
+ // Integration Tests - Test complete flows
+ // ==========================================
+ describe('Integration', () => {
+ it('should complete full flow from step 1 to step 3 with all state updates', () => {
+ // Arrange
+ render( )
+
+ // Step 1: Set up data
+ fireEvent.click(screen.getByTestId('step-one-update-files'))
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Step 2: Set caches
+ fireEvent.click(screen.getByTestId('step-two-update-indexing-cache'))
+ fireEvent.click(screen.getByTestId('step-two-update-retrieval-cache'))
+ fireEvent.click(screen.getByTestId('step-two-update-result-cache'))
+ fireEvent.click(screen.getByTestId('step-two-next'))
+
+ // Assert - All data flows through to Step 3
+ expect(screen.getByTestId('step-three-indexing-type')).toHaveTextContent('high_quality')
+ expect(screen.getByTestId('step-three-retrieval-method')).toHaveTextContent('semantic_search')
+ expect(stepThreeProps.creationCache?.batch).toBe('batch-1')
+ })
+
+ it('should handle complete website crawl workflow', () => {
+ // Arrange
+ render( )
+
+ // Set website data source through button click
+ fireEvent.click(screen.getByTestId('step-one-update-website-pages'))
+ fireEvent.click(screen.getByTestId('step-one-update-crawl-options'))
+ fireEvent.click(screen.getByTestId('step-one-update-crawl-provider'))
+ fireEvent.click(screen.getByTestId('step-one-update-job-id'))
+
+ // Navigate to step 2
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Assert - All website data passed to StepTwo
+ expect(stepTwoProps.websitePages.length).toBe(1)
+ expect(stepTwoProps.websiteCrawlProvider).toBe(DataSourceProvider.fireCrawl)
+ expect(stepTwoProps.websiteCrawlJobId).toBe('job-123')
+ expect(stepTwoProps.crawlOptions.limit).toBe(20)
+ })
+
+ it('should handle complete notion workflow', () => {
+ // Arrange
+ render( )
+
+ // Set notion data source
+ fireEvent.click(screen.getByTestId('step-one-change-type'))
+ fireEvent.click(screen.getByTestId('step-one-update-notion-pages'))
+ fireEvent.click(screen.getByTestId('step-one-update-notion-credential'))
+
+ // Navigate to step 2
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Assert
+ expect(stepTwoProps.notionPages.length).toBe(1)
+ expect(stepTwoProps.notionCredentialId).toBe('credential-123')
+ })
+
+ it('should handle edit mode with existing dataset', () => {
+ // Arrange
+ mockDatasetDetail = createMockDataset({
+ name: 'Existing Dataset',
+ indexing_technique: IndexingTypeValues.QUALIFIED as any,
+ data_source_type: DataSourceType.NOTION,
+ })
+ render( )
+
+ // Assert - Step 1 should have disabled data source type
+ expect(stepOneProps.dataSourceTypeDisable).toBe(true)
+
+ // Navigate through
+ fireEvent.click(screen.getByTestId('step-one-next'))
+
+ // Assert - Step 2 should receive dataset info
+ expect(stepTwoProps.indexingType).toBe('high_quality')
+ expect(stepTwoProps.datasetId).toBe('dataset-123')
+
+ // Navigate to Step 3
+ fireEvent.click(screen.getByTestId('step-two-next'))
+
+ // Assert - Step 3 should show dataset details
+ expect(screen.getByTestId('step-three-dataset-name')).toHaveTextContent('Existing Dataset')
+ expect(screen.getByTestId('step-three-indexing-type')).toHaveTextContent('high_quality')
+ })
+ })
+
+ // ==========================================
+ // Default Crawl Options Tests
+ // ==========================================
+ describe('Default Crawl Options', () => {
+ it('should have correct default crawl options structure', () => {
+ // Arrange & Act
+ render( )
+
+ // Assert
+ const crawlOptions = stepOneProps.crawlOptions
+ expect(crawlOptions).toMatchObject({
+ crawl_sub_pages: true,
+ only_main_content: true,
+ includes: '',
+ excludes: '',
+ limit: 10,
+ max_depth: '',
+ use_sitemap: true,
+ })
+ })
+
+ it('should preserve crawl options when navigating steps', () => {
+ // Arrange
+ render( )
+
+ // Update crawl options
+ fireEvent.click(screen.getByTestId('step-one-update-crawl-options'))
+
+ // Navigate to step 2 and back
+ fireEvent.click(screen.getByTestId('step-one-next'))
+ fireEvent.click(screen.getByTestId('step-two-prev'))
+
+ // Assert
+ expect(stepOneProps.crawlOptions.limit).toBe(20)
+ })
+ })
+
+ // ==========================================
+ // Error State Tests
+ // ==========================================
+ describe('Error States', () => {
+ it('should display error message when fetching data source list fails', () => {
+ // Arrange
+ mockFetchingError = true
+
+ // Act
+ render( )
+
+ // Assert
+ const errorElement = screen.getByText('datasetCreation.error.unavailable')
+ expect(errorElement).toBeInTheDocument()
+ })
+
+ it('should not render steps when in error state', () => {
+ // Arrange
+ mockFetchingError = true
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.queryByTestId('step-one')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('step-two')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('step-three')).not.toBeInTheDocument()
+ })
+
+ it('should render error page with 500 code when in error state', () => {
+ // Arrange
+ mockFetchingError = true
+
+ // Act
+ render( )
+
+ // Assert - Error state renders AppUnavailable, not the normal layout
+ expect(screen.getByText('500')).toBeInTheDocument()
+ expect(screen.queryByTestId('top-bar')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==========================================
+ // Loading State Tests
+ // ==========================================
+ describe('Loading States', () => {
+ it('should not render steps while loading', () => {
+ // Arrange
+ mockIsLoadingDataSourceList = true
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.queryByTestId('step-one')).not.toBeInTheDocument()
+ })
+
+ it('should render TopBar while loading', () => {
+ // Arrange
+ mockIsLoadingDataSourceList = true
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByTestId('top-bar')).toBeInTheDocument()
+ })
+
+ it('should render StepOne after loading completes', async () => {
+ // Arrange
+ mockIsLoadingDataSourceList = true
+ const { rerender } = render( )
+
+ // Assert - Initially not rendered
+ expect(screen.queryByTestId('step-one')).not.toBeInTheDocument()
+
+ // Act - Loading completes
+ mockIsLoadingDataSourceList = false
+ rerender( )
+
+ // Assert - Now rendered
+ await waitFor(() => {
+ expect(screen.getByTestId('step-one')).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/notion-page-preview/index.spec.tsx b/web/app/components/datasets/create/notion-page-preview/index.spec.tsx
new file mode 100644
index 0000000000..daec7a8cdf
--- /dev/null
+++ b/web/app/components/datasets/create/notion-page-preview/index.spec.tsx
@@ -0,0 +1,1150 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import NotionPagePreview from './index'
+import type { NotionPage } from '@/models/common'
+import { fetchNotionPagePreview } from '@/service/datasets'
+
+// Mock the fetchNotionPagePreview service
+jest.mock('@/service/datasets', () => ({
+ fetchNotionPagePreview: jest.fn(),
+}))
+
+const mockFetchNotionPagePreview = fetchNotionPagePreview as jest.MockedFunction
+
+// Factory function to create mock NotionPage objects
+const createMockNotionPage = (overrides: Partial = {}): NotionPage => {
+ return {
+ page_id: 'page-123',
+ page_name: 'Test Page',
+ page_icon: null,
+ parent_id: 'parent-123',
+ type: 'page',
+ is_bound: false,
+ workspace_id: 'workspace-123',
+ ...overrides,
+ }
+}
+
+// Factory function to create NotionPage with emoji icon
+const createMockNotionPageWithEmojiIcon = (emoji: string, overrides: Partial = {}): NotionPage => {
+ return createMockNotionPage({
+ page_icon: {
+ type: 'emoji',
+ url: null,
+ emoji,
+ },
+ ...overrides,
+ })
+}
+
+// Factory function to create NotionPage with URL icon
+const createMockNotionPageWithUrlIcon = (url: string, overrides: Partial = {}): NotionPage => {
+ return createMockNotionPage({
+ page_icon: {
+ type: 'url',
+ url,
+ emoji: null,
+ },
+ ...overrides,
+ })
+}
+
+// Helper to render NotionPagePreview with default props and wait for async updates
+const renderNotionPagePreview = async (
+ props: Partial<{
+ currentPage?: NotionPage
+ notionCredentialId: string
+ hidePreview: () => void
+ }> = {},
+ waitForContent = true,
+) => {
+ const defaultProps = {
+ currentPage: createMockNotionPage(),
+ notionCredentialId: 'credential-123',
+ hidePreview: jest.fn(),
+ ...props,
+ }
+ const result = render( )
+
+ // Wait for async state updates to complete if needed
+ if (waitForContent && defaultProps.currentPage) {
+ await waitFor(() => {
+ // Wait for loading to finish
+ expect(result.container.querySelector('.spin-animation')).not.toBeInTheDocument()
+ })
+ }
+
+ return {
+ ...result,
+ props: defaultProps,
+ }
+}
+
+// Helper to find the loading spinner element
+const findLoadingSpinner = (container: HTMLElement) => {
+ return container.querySelector('.spin-animation')
+}
+
+// ============================================================================
+// NotionPagePreview Component Tests
+// ============================================================================
+// Note: Branch coverage is ~88% because line 29 (`if (!currentPage) return`)
+// is defensive code that cannot be reached - getPreviewContent is only called
+// from useEffect when currentPage is truthy.
+// ============================================================================
+describe('NotionPagePreview', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ // Default successful API response
+ mockFetchNotionPagePreview.mockResolvedValue({ content: 'Preview content here' })
+ })
+
+ afterEach(async () => {
+ // Wait for any pending state updates to complete
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0))
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Rendering Tests - Verify component renders properly
+ // --------------------------------------------------------------------------
+ describe('Rendering', () => {
+ it('should render without crashing', async () => {
+ // Arrange & Act
+ await renderNotionPagePreview()
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
+ })
+
+ it('should render page preview header', async () => {
+ // Arrange & Act
+ await renderNotionPagePreview()
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
+ })
+
+ it('should render close button with XMarkIcon', async () => {
+ // Arrange & Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert
+ const closeButton = container.querySelector('.cursor-pointer')
+ expect(closeButton).toBeInTheDocument()
+ const xMarkIcon = closeButton?.querySelector('svg')
+ expect(xMarkIcon).toBeInTheDocument()
+ })
+
+ it('should render page name', async () => {
+ // Arrange
+ const page = createMockNotionPage({ page_name: 'My Notion Page' })
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ expect(screen.getByText('My Notion Page')).toBeInTheDocument()
+ })
+
+ it('should apply correct CSS classes to container', async () => {
+ // Arrange & Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('h-full')
+ })
+
+ it('should render NotionIcon component', async () => {
+ // Arrange
+ const page = createMockNotionPage()
+
+ // Act
+ const { container } = await renderNotionPagePreview({ currentPage: page })
+
+ // Assert - NotionIcon should be rendered (either as img or div or svg)
+ const iconContainer = container.querySelector('.mr-1.shrink-0')
+ expect(iconContainer).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // NotionIcon Rendering Tests
+ // --------------------------------------------------------------------------
+ describe('NotionIcon Rendering', () => {
+ it('should render default icon when page_icon is null', async () => {
+ // Arrange
+ const page = createMockNotionPage({ page_icon: null })
+
+ // Act
+ const { container } = await renderNotionPagePreview({ currentPage: page })
+
+ // Assert - Should render RiFileTextLine icon (svg)
+ const svgIcon = container.querySelector('svg')
+ expect(svgIcon).toBeInTheDocument()
+ })
+
+ it('should render emoji icon when page_icon has emoji type', async () => {
+ // Arrange
+ const page = createMockNotionPageWithEmojiIcon('📝')
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ expect(screen.getByText('📝')).toBeInTheDocument()
+ })
+
+ it('should render image icon when page_icon has url type', async () => {
+ // Arrange
+ const page = createMockNotionPageWithUrlIcon('https://example.com/icon.png')
+
+ // Act
+ const { container } = await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ const img = container.querySelector('img[alt="page icon"]')
+ expect(img).toBeInTheDocument()
+ expect(img).toHaveAttribute('src', 'https://example.com/icon.png')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Loading State Tests
+ // --------------------------------------------------------------------------
+ describe('Loading State', () => {
+ it('should show loading indicator initially', async () => {
+ // Arrange - Delay API response to keep loading state
+ mockFetchNotionPagePreview.mockImplementation(
+ () => new Promise(resolve => setTimeout(() => resolve({ content: 'test' }), 100)),
+ )
+
+ // Act - Don't wait for content to load
+ const { container } = await renderNotionPagePreview({}, false)
+
+ // Assert - Loading should be visible initially
+ const loadingElement = findLoadingSpinner(container)
+ expect(loadingElement).toBeInTheDocument()
+ })
+
+ it('should hide loading indicator after content loads', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockResolvedValue({ content: 'Loaded content' })
+
+ // Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert
+ expect(screen.getByText('Loaded content')).toBeInTheDocument()
+ // Loading should be gone
+ const loadingElement = findLoadingSpinner(container)
+ expect(loadingElement).not.toBeInTheDocument()
+ })
+
+ it('should show loading when currentPage changes', async () => {
+ // Arrange
+ const page1 = createMockNotionPage({ page_id: 'page-1', page_name: 'Page 1' })
+ const page2 = createMockNotionPage({ page_id: 'page-2', page_name: 'Page 2' })
+
+ let resolveFirst: (value: { content: string }) => void
+ let resolveSecond: (value: { content: string }) => void
+
+ mockFetchNotionPagePreview
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
+
+ // Act - Initial render
+ const { rerender, container } = render(
+ ,
+ )
+
+ // First page loading - spinner should be visible
+ expect(findLoadingSpinner(container)).toBeInTheDocument()
+
+ // Resolve first page
+ await act(async () => {
+ resolveFirst({ content: 'Content 1' })
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Content 1')).toBeInTheDocument()
+ })
+
+ // Rerender with new page
+ rerender( )
+
+ // Should show loading again
+ await waitFor(() => {
+ expect(findLoadingSpinner(container)).toBeInTheDocument()
+ })
+
+ // Resolve second page
+ await act(async () => {
+ resolveSecond({ content: 'Content 2' })
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Content 2')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // API Call Tests
+ // --------------------------------------------------------------------------
+ describe('API Calls', () => {
+ it('should call fetchNotionPagePreview with correct parameters', async () => {
+ // Arrange
+ const page = createMockNotionPage({
+ page_id: 'test-page-id',
+ type: 'database',
+ })
+
+ // Act
+ await renderNotionPagePreview({
+ currentPage: page,
+ notionCredentialId: 'test-credential-id',
+ })
+
+ // Assert
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({
+ pageID: 'test-page-id',
+ pageType: 'database',
+ credentialID: 'test-credential-id',
+ })
+ })
+
+ it('should not call fetchNotionPagePreview when currentPage is undefined', async () => {
+ // Arrange & Act
+ await renderNotionPagePreview({ currentPage: undefined }, false)
+
+ // Assert
+ expect(mockFetchNotionPagePreview).not.toHaveBeenCalled()
+ })
+
+ it('should call fetchNotionPagePreview again when currentPage changes', async () => {
+ // Arrange
+ const page1 = createMockNotionPage({ page_id: 'page-1' })
+ const page2 = createMockNotionPage({ page_id: 'page-2' })
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({
+ pageID: 'page-1',
+ pageType: 'page',
+ credentialID: 'cred-123',
+ })
+ })
+
+ await act(async () => {
+ rerender( )
+ })
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({
+ pageID: 'page-2',
+ pageType: 'page',
+ credentialID: 'cred-123',
+ })
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ it('should handle API success and display content', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockResolvedValue({ content: 'Notion page preview content from API' })
+
+ // Act
+ await renderNotionPagePreview()
+
+ // Assert
+ expect(screen.getByText('Notion page preview content from API')).toBeInTheDocument()
+ })
+
+ it('should handle API error gracefully', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockRejectedValue(new Error('Network error'))
+
+ // Act
+ const { container } = await renderNotionPagePreview({}, false)
+
+ // Assert - Component should not crash
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ // Header should still render
+ expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
+ })
+
+ it('should handle empty content response', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockResolvedValue({ content: '' })
+
+ // Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert - Should still render without loading
+ const loadingElement = findLoadingSpinner(container)
+ expect(loadingElement).not.toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // User Interactions Tests
+ // --------------------------------------------------------------------------
+ describe('User Interactions', () => {
+ it('should call hidePreview when close button is clicked', async () => {
+ // Arrange
+ const hidePreview = jest.fn()
+ const { container } = await renderNotionPagePreview({ hidePreview })
+
+ // Act
+ const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
+ fireEvent.click(closeButton)
+
+ // Assert
+ expect(hidePreview).toHaveBeenCalledTimes(1)
+ })
+
+ it('should handle multiple clicks on close button', async () => {
+ // Arrange
+ const hidePreview = jest.fn()
+ const { container } = await renderNotionPagePreview({ hidePreview })
+
+ // Act
+ const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
+ fireEvent.click(closeButton)
+ fireEvent.click(closeButton)
+ fireEvent.click(closeButton)
+
+ // Assert
+ expect(hidePreview).toHaveBeenCalledTimes(3)
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // State Management Tests
+ // --------------------------------------------------------------------------
+ describe('State Management', () => {
+ it('should initialize with loading state true', async () => {
+ // Arrange - Keep loading indefinitely (never resolves)
+ mockFetchNotionPagePreview.mockImplementation(() => new Promise(() => { /* intentionally empty */ }))
+
+ // Act - Don't wait for content
+ const { container } = await renderNotionPagePreview({}, false)
+
+ // Assert
+ const loadingElement = findLoadingSpinner(container)
+ expect(loadingElement).toBeInTheDocument()
+ })
+
+ it('should update previewContent state after successful fetch', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockResolvedValue({ content: 'New preview content' })
+
+ // Act
+ await renderNotionPagePreview()
+
+ // Assert
+ expect(screen.getByText('New preview content')).toBeInTheDocument()
+ })
+
+ it('should reset loading to true when currentPage changes', async () => {
+ // Arrange
+ const page1 = createMockNotionPage({ page_id: 'page-1' })
+ const page2 = createMockNotionPage({ page_id: 'page-2' })
+
+ mockFetchNotionPagePreview
+ .mockResolvedValueOnce({ content: 'Content 1' })
+ .mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
+
+ // Act
+ const { rerender, container } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByText('Content 1')).toBeInTheDocument()
+ })
+
+ // Change page
+ await act(async () => {
+ rerender( )
+ })
+
+ // Assert - Loading should be shown again
+ await waitFor(() => {
+ const loadingElement = findLoadingSpinner(container)
+ expect(loadingElement).toBeInTheDocument()
+ })
+ })
+
+ it('should replace old content with new content when page changes', async () => {
+ // Arrange
+ const page1 = createMockNotionPage({ page_id: 'page-1' })
+ const page2 = createMockNotionPage({ page_id: 'page-2' })
+
+ let resolveSecond: (value: { content: string }) => void
+
+ mockFetchNotionPagePreview
+ .mockResolvedValueOnce({ content: 'Content 1' })
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByText('Content 1')).toBeInTheDocument()
+ })
+
+ // Change page
+ await act(async () => {
+ rerender( )
+ })
+
+ // Resolve second fetch
+ await act(async () => {
+ resolveSecond({ content: 'Content 2' })
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Content 2')).toBeInTheDocument()
+ expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Props Testing
+ // --------------------------------------------------------------------------
+ describe('Props', () => {
+ describe('currentPage prop', () => {
+ it('should render correctly with currentPage prop', async () => {
+ // Arrange
+ const page = createMockNotionPage({ page_name: 'My Test Page' })
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ expect(screen.getByText('My Test Page')).toBeInTheDocument()
+ })
+
+ it('should render correctly without currentPage prop (undefined)', async () => {
+ // Arrange & Act
+ await renderNotionPagePreview({ currentPage: undefined }, false)
+
+ // Assert - Header should still render
+ expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
+ })
+
+ it('should handle page with empty name', async () => {
+ // Arrange
+ const page = createMockNotionPage({ page_name: '' })
+
+ // Act
+ const { container } = await renderNotionPagePreview({ currentPage: page })
+
+ // Assert - Should not crash
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle page with very long name', async () => {
+ // Arrange
+ const longName = 'a'.repeat(200)
+ const page = createMockNotionPage({ page_name: longName })
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ expect(screen.getByText(longName)).toBeInTheDocument()
+ })
+
+ it('should handle page with special characters in name', async () => {
+ // Arrange
+ const page = createMockNotionPage({ page_name: 'Page with & "chars"' })
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ expect(screen.getByText('Page with & "chars"')).toBeInTheDocument()
+ })
+
+ it('should handle page with unicode characters in name', async () => {
+ // Arrange
+ const page = createMockNotionPage({ page_name: '中文页面名称 🚀 日本語' })
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ expect(screen.getByText('中文页面名称 🚀 日本語')).toBeInTheDocument()
+ })
+ })
+
+ describe('notionCredentialId prop', () => {
+ it('should pass notionCredentialId to API call', async () => {
+ // Arrange
+ const page = createMockNotionPage()
+
+ // Act
+ await renderNotionPagePreview({
+ currentPage: page,
+ notionCredentialId: 'my-credential-id',
+ })
+
+ // Assert
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ credentialID: 'my-credential-id' }),
+ )
+ })
+ })
+
+ describe('hidePreview prop', () => {
+ it('should accept hidePreview callback', async () => {
+ // Arrange
+ const hidePreview = jest.fn()
+
+ // Act
+ await renderNotionPagePreview({ hidePreview })
+
+ // Assert - No errors thrown
+ expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Edge Cases Tests
+ // --------------------------------------------------------------------------
+ describe('Edge Cases', () => {
+ it('should handle page with undefined page_id', async () => {
+ // Arrange
+ const page = createMockNotionPage({ page_id: undefined as unknown as string })
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert - API should still be called (with undefined pageID)
+ expect(mockFetchNotionPagePreview).toHaveBeenCalled()
+ })
+
+ it('should handle page with empty string page_id', async () => {
+ // Arrange
+ const page = createMockNotionPage({ page_id: '' })
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ pageID: '' }),
+ )
+ })
+
+ it('should handle very long preview content', async () => {
+ // Arrange
+ const longContent = 'x'.repeat(10000)
+ mockFetchNotionPagePreview.mockResolvedValue({ content: longContent })
+
+ // Act
+ await renderNotionPagePreview()
+
+ // Assert
+ expect(screen.getByText(longContent)).toBeInTheDocument()
+ })
+
+ it('should handle preview content with special characters safely', async () => {
+ // Arrange
+ const specialContent = '\n\t& < > "'
+ mockFetchNotionPagePreview.mockResolvedValue({ content: specialContent })
+
+ // Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert - Should render as text, not execute scripts
+ const contentDiv = container.querySelector('.fileContent')
+ expect(contentDiv).toBeInTheDocument()
+ expect(contentDiv?.textContent).toContain('alert')
+ })
+
+ it('should handle preview content with unicode', async () => {
+ // Arrange
+ const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs'
+ mockFetchNotionPagePreview.mockResolvedValue({ content: unicodeContent })
+
+ // Act
+ await renderNotionPagePreview()
+
+ // Assert
+ expect(screen.getByText(unicodeContent)).toBeInTheDocument()
+ })
+
+ it('should handle preview content with newlines', async () => {
+ // Arrange
+ const multilineContent = 'Line 1\nLine 2\nLine 3'
+ mockFetchNotionPagePreview.mockResolvedValue({ content: multilineContent })
+
+ // Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert
+ const contentDiv = container.querySelector('.fileContent')
+ expect(contentDiv).toBeInTheDocument()
+ expect(contentDiv?.textContent).toContain('Line 1')
+ expect(contentDiv?.textContent).toContain('Line 2')
+ expect(contentDiv?.textContent).toContain('Line 3')
+ })
+
+ it('should handle null content from API', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockResolvedValue({ content: null as unknown as string })
+
+ // Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert - Should not crash
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle different page types', async () => {
+ // Arrange
+ const databasePage = createMockNotionPage({ type: 'database' })
+
+ // Act
+ await renderNotionPagePreview({ currentPage: databasePage })
+
+ // Assert
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ pageType: 'database' }),
+ )
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Side Effects and Cleanup Tests
+ // --------------------------------------------------------------------------
+ describe('Side Effects and Cleanup', () => {
+ it('should trigger effect when currentPage prop changes', async () => {
+ // Arrange
+ const page1 = createMockNotionPage({ page_id: 'page-1' })
+ const page2 = createMockNotionPage({ page_id: 'page-2' })
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1)
+ })
+
+ await act(async () => {
+ rerender( )
+ })
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ it('should not trigger effect when hidePreview changes', async () => {
+ // Arrange
+ const page = createMockNotionPage()
+ const hidePreview1 = jest.fn()
+ const hidePreview2 = jest.fn()
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1)
+ })
+
+ await act(async () => {
+ rerender( )
+ })
+
+ // Assert - Should not call API again (currentPage didn't change by reference)
+ // Note: Since currentPage is the same object, effect should not re-run
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not trigger effect when notionCredentialId changes', async () => {
+ // Arrange
+ const page = createMockNotionPage()
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1)
+ })
+
+ await act(async () => {
+ rerender( )
+ })
+
+ // Assert - Should not call API again (only currentPage is in dependency array)
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1)
+ })
+
+ it('should handle rapid page changes', async () => {
+ // Arrange
+ const pages = Array.from({ length: 5 }, (_, i) =>
+ createMockNotionPage({ page_id: `page-${i}` }),
+ )
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ // Rapidly change pages
+ for (let i = 1; i < pages.length; i++) {
+ await act(async () => {
+ rerender( )
+ })
+ }
+
+ // Assert - Should have called API for each page
+ await waitFor(() => {
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(5)
+ })
+ })
+
+ it('should handle unmount during loading', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockImplementation(
+ () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)),
+ )
+
+ // Act - Don't wait for content
+ const { unmount } = await renderNotionPagePreview({}, false)
+
+ // Unmount before API resolves
+ unmount()
+
+ // Assert - No errors should be thrown
+ expect(true).toBe(true)
+ })
+
+ it('should handle page changing from defined to undefined', async () => {
+ // Arrange
+ const page = createMockNotionPage()
+
+ // Act
+ const { rerender, container } = render(
+ ,
+ )
+
+ await waitFor(() => {
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1)
+ })
+
+ await act(async () => {
+ rerender( )
+ })
+
+ // Assert - Should not crash, API should not be called again
+ expect(container.firstChild).toBeInTheDocument()
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Accessibility Tests
+ // --------------------------------------------------------------------------
+ describe('Accessibility', () => {
+ it('should have clickable close button with visual indicator', async () => {
+ // Arrange & Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert
+ const closeButton = container.querySelector('.cursor-pointer')
+ expect(closeButton).toBeInTheDocument()
+ expect(closeButton).toHaveClass('cursor-pointer')
+ })
+
+ it('should have proper heading structure', async () => {
+ // Arrange & Act
+ await renderNotionPagePreview()
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Error Handling Tests
+ // --------------------------------------------------------------------------
+ describe('Error Handling', () => {
+ it('should not crash on API network error', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockRejectedValue(new Error('Network Error'))
+
+ // Act
+ const { container } = await renderNotionPagePreview({}, false)
+
+ // Assert - Component should still render
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ it('should not crash on API timeout', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockRejectedValue(new Error('Timeout'))
+
+ // Act
+ const { container } = await renderNotionPagePreview({}, false)
+
+ // Assert
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ it('should not crash on malformed API response', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockResolvedValue({} as { content: string })
+
+ // Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle 404 error gracefully', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockRejectedValue(new Error('404 Not Found'))
+
+ // Act
+ const { container } = await renderNotionPagePreview({}, false)
+
+ // Assert
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ it('should handle 500 error gracefully', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockRejectedValue(new Error('500 Internal Server Error'))
+
+ // Act
+ const { container } = await renderNotionPagePreview({}, false)
+
+ // Assert
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ it('should handle authorization error gracefully', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockRejectedValue(new Error('401 Unauthorized'))
+
+ // Act
+ const { container } = await renderNotionPagePreview({}, false)
+
+ // Assert
+ await waitFor(() => {
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Page Type Variations Tests
+ // --------------------------------------------------------------------------
+ describe('Page Type Variations', () => {
+ it('should handle page type', async () => {
+ // Arrange
+ const page = createMockNotionPage({ type: 'page' })
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ pageType: 'page' }),
+ )
+ })
+
+ it('should handle database type', async () => {
+ // Arrange
+ const page = createMockNotionPage({ type: 'database' })
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ pageType: 'database' }),
+ )
+ })
+
+ it('should handle unknown type', async () => {
+ // Arrange
+ const page = createMockNotionPage({ type: 'unknown_type' })
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ expect(mockFetchNotionPagePreview).toHaveBeenCalledWith(
+ expect.objectContaining({ pageType: 'unknown_type' }),
+ )
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Icon Type Variations Tests
+ // --------------------------------------------------------------------------
+ describe('Icon Type Variations', () => {
+ it('should handle page with null icon', async () => {
+ // Arrange
+ const page = createMockNotionPage({ page_icon: null })
+
+ // Act
+ const { container } = await renderNotionPagePreview({ currentPage: page })
+
+ // Assert - Should render default icon
+ const svgIcon = container.querySelector('svg')
+ expect(svgIcon).toBeInTheDocument()
+ })
+
+ it('should handle page with emoji icon object', async () => {
+ // Arrange
+ const page = createMockNotionPageWithEmojiIcon('📄')
+
+ // Act
+ await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ expect(screen.getByText('📄')).toBeInTheDocument()
+ })
+
+ it('should handle page with url icon object', async () => {
+ // Arrange
+ const page = createMockNotionPageWithUrlIcon('https://example.com/custom-icon.png')
+
+ // Act
+ const { container } = await renderNotionPagePreview({ currentPage: page })
+
+ // Assert
+ const img = container.querySelector('img[alt="page icon"]')
+ expect(img).toBeInTheDocument()
+ expect(img).toHaveAttribute('src', 'https://example.com/custom-icon.png')
+ })
+
+ it('should handle page with icon object having null values', async () => {
+ // Arrange
+ const page = createMockNotionPage({
+ page_icon: {
+ type: null,
+ url: null,
+ emoji: null,
+ },
+ })
+
+ // Act
+ const { container } = await renderNotionPagePreview({ currentPage: page })
+
+ // Assert - Should render, likely with default/fallback
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle page with icon object having empty url', async () => {
+ // Arrange
+ // Suppress console.error for this test as we're intentionally testing empty src edge case
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
+
+ const page = createMockNotionPage({
+ page_icon: {
+ type: 'url',
+ url: '',
+ emoji: null,
+ },
+ })
+
+ // Act
+ const { container } = await renderNotionPagePreview({ currentPage: page })
+
+ // Assert - Component should not crash, may render img or fallback
+ expect(container.firstChild).toBeInTheDocument()
+ // NotionIcon renders img when type is 'url'
+ const img = container.querySelector('img[alt="page icon"]')
+ if (img)
+ expect(img).toBeInTheDocument()
+
+ // Restore console.error
+ consoleErrorSpy.mockRestore()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Content Display Tests
+ // --------------------------------------------------------------------------
+ describe('Content Display', () => {
+ it('should display content in fileContent div with correct class', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockResolvedValue({ content: 'Test content' })
+
+ // Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert
+ const contentDiv = container.querySelector('.fileContent')
+ expect(contentDiv).toBeInTheDocument()
+ expect(contentDiv).toHaveTextContent('Test content')
+ })
+
+ it('should preserve whitespace in content', async () => {
+ // Arrange
+ const contentWithWhitespace = ' indented content\n more indent'
+ mockFetchNotionPagePreview.mockResolvedValue({ content: contentWithWhitespace })
+
+ // Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert
+ const contentDiv = container.querySelector('.fileContent')
+ expect(contentDiv).toBeInTheDocument()
+ // The CSS class has white-space: pre-line
+ expect(contentDiv?.textContent).toContain('indented content')
+ })
+
+ it('should display empty string content without loading', async () => {
+ // Arrange
+ mockFetchNotionPagePreview.mockResolvedValue({ content: '' })
+
+ // Act
+ const { container } = await renderNotionPagePreview()
+
+ // Assert
+ const loadingElement = findLoadingSpinner(container)
+ expect(loadingElement).not.toBeInTheDocument()
+ const contentDiv = container.querySelector('.fileContent')
+ expect(contentDiv).toBeInTheDocument()
+ expect(contentDiv?.textContent).toBe('')
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/notion-page-preview/index.tsx b/web/app/components/datasets/create/notion-page-preview/index.tsx
index 000b84ac62..456b124324 100644
--- a/web/app/components/datasets/create/notion-page-preview/index.tsx
+++ b/web/app/components/datasets/create/notion-page-preview/index.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import { XMarkIcon } from '@heroicons/react/20/solid'
import Loading from '@/app/components/base/loading'
import s from './index.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { NotionPage } from '@/models/common'
import NotionIcon from '@/app/components/base/notion-icon'
import { fetchNotionPagePreview } from '@/service/datasets'
@@ -29,7 +29,6 @@ const NotionPagePreview = ({
return
try {
const res = await fetchNotionPagePreview({
- workspaceID: currentPage.workspace_id,
pageID: currentPage.page_id,
pageType: currentPage.type,
credentialID: notionCredentialId,
diff --git a/web/app/components/datasets/create/step-one/index.tsx b/web/app/components/datasets/create/step-one/index.tsx
index e70feb204c..e844fd1b8a 100644
--- a/web/app/components/datasets/create/step-one/index.tsx
+++ b/web/app/components/datasets/create/step-one/index.tsx
@@ -9,7 +9,6 @@ import EmptyDatasetCreationModal from '../empty-dataset-creation-modal'
import Website from '../website'
import WebsitePreview from '../website/preview'
import s from './index.module.css'
-import cn from '@/utils/classnames'
import type { CrawlOptions, CrawlResultItem, FileItem } from '@/models/datasets'
import type { DataSourceProvider, NotionPage } from '@/models/common'
import { DataSourceType } from '@/models/datasets'
@@ -18,7 +17,7 @@ import { NotionPageSelector } from '@/app/components/base/notion-page-selector'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContext } from '@/context/provider-context'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
import NotionConnector from '@/app/components/base/notion-connector'
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
@@ -165,10 +164,10 @@ const StepOne = ({
-
+
{
shouldShowDataSourceTypeList && (
-
+
{t('datasetCreation.steps.one')}
)
diff --git a/web/app/components/datasets/create/step-three/index.spec.tsx b/web/app/components/datasets/create/step-three/index.spec.tsx
new file mode 100644
index 0000000000..02746c8aee
--- /dev/null
+++ b/web/app/components/datasets/create/step-three/index.spec.tsx
@@ -0,0 +1,844 @@
+import { render, screen } from '@testing-library/react'
+import StepThree from './index'
+import type { FullDocumentDetail, IconInfo, createDocumentResponse } from '@/models/datasets'
+
+// Mock the EmbeddingProcess component since it has complex async logic
+jest.mock('../embedding-process', () => ({
+ __esModule: true,
+ default: jest.fn(({ datasetId, batchId, documents, indexingType, retrievalMethod }) => (
+
+ {datasetId}
+ {batchId}
+ {documents?.length ?? 0}
+ {indexingType}
+ {retrievalMethod}
+
+ )),
+}))
+
+// Mock useBreakpoints hook
+let mockMediaType = 'pc'
+jest.mock('@/hooks/use-breakpoints', () => ({
+ __esModule: true,
+ MediaType: {
+ mobile: 'mobile',
+ tablet: 'tablet',
+ pc: 'pc',
+ },
+ default: jest.fn(() => mockMediaType),
+}))
+
+// Mock useDocLink hook
+jest.mock('@/context/i18n', () => ({
+ useDocLink: () => (path?: string) => `https://docs.dify.ai/en-US${path || ''}`,
+}))
+
+// Factory function to create mock IconInfo
+const createMockIconInfo = (overrides: Partial
= {}): IconInfo => ({
+ icon: '📙',
+ icon_type: 'emoji',
+ icon_background: '#FFF4ED',
+ icon_url: '',
+ ...overrides,
+})
+
+// Factory function to create mock FullDocumentDetail
+const createMockDocument = (overrides: Partial = {}): FullDocumentDetail => ({
+ id: 'doc-123',
+ name: 'test-document.txt',
+ data_source_type: 'upload_file',
+ data_source_info: {
+ upload_file: {
+ id: 'file-123',
+ name: 'test-document.txt',
+ extension: 'txt',
+ mime_type: 'text/plain',
+ size: 1024,
+ created_by: 'user-1',
+ created_at: Date.now(),
+ },
+ },
+ batch: 'batch-123',
+ created_api_request_id: 'request-123',
+ processing_started_at: Date.now(),
+ parsing_completed_at: Date.now(),
+ cleaning_completed_at: Date.now(),
+ splitting_completed_at: Date.now(),
+ tokens: 100,
+ indexing_latency: 5000,
+ completed_at: Date.now(),
+ paused_by: '',
+ paused_at: 0,
+ stopped_at: 0,
+ indexing_status: 'completed',
+ disabled_at: 0,
+ ...overrides,
+} as FullDocumentDetail)
+
+// Factory function to create mock createDocumentResponse
+const createMockCreationCache = (overrides: Partial = {}): createDocumentResponse => ({
+ dataset: {
+ id: 'dataset-123',
+ name: 'Test Dataset',
+ icon_info: createMockIconInfo(),
+ indexing_technique: 'high_quality',
+ retrieval_model_dict: {
+ search_method: 'semantic_search',
+ },
+ } as createDocumentResponse['dataset'],
+ batch: 'batch-123',
+ documents: [createMockDocument()] as createDocumentResponse['documents'],
+ ...overrides,
+})
+
+// Helper to render StepThree with default props
+const renderStepThree = (props: Partial[0]> = {}) => {
+ const defaultProps = {
+ ...props,
+ }
+ return render( )
+}
+
+// ============================================================================
+// StepThree Component Tests
+// ============================================================================
+describe('StepThree', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockMediaType = 'pc'
+ })
+
+ // --------------------------------------------------------------------------
+ // Rendering Tests - Verify component renders properly
+ // --------------------------------------------------------------------------
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ renderStepThree()
+
+ // Assert
+ expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
+ })
+
+ it('should render with creation title when datasetId is not provided', () => {
+ // Arrange & Act
+ renderStepThree()
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.stepThree.creationContent')).toBeInTheDocument()
+ })
+
+ it('should render with addition title when datasetId is provided', () => {
+ // Arrange & Act
+ renderStepThree({
+ datasetId: 'existing-dataset-123',
+ datasetName: 'Existing Dataset',
+ })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument()
+ expect(screen.queryByText('datasetCreation.stepThree.creationTitle')).not.toBeInTheDocument()
+ })
+
+ it('should render label text in creation mode', () => {
+ // Arrange & Act
+ renderStepThree()
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.label')).toBeInTheDocument()
+ })
+
+ it('should render side tip panel on desktop', () => {
+ // Arrange
+ mockMediaType = 'pc'
+
+ // Act
+ renderStepThree()
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.stepThree.sideTipContent')).toBeInTheDocument()
+ expect(screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')).toBeInTheDocument()
+ })
+
+ it('should not render side tip panel on mobile', () => {
+ // Arrange
+ mockMediaType = 'mobile'
+
+ // Act
+ renderStepThree()
+
+ // Assert
+ expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument()
+ expect(screen.queryByText('datasetCreation.stepThree.sideTipContent')).not.toBeInTheDocument()
+ })
+
+ it('should render EmbeddingProcess component', () => {
+ // Arrange & Act
+ renderStepThree()
+
+ // Assert
+ expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
+ })
+
+ it('should render documentation link with correct href on desktop', () => {
+ // Arrange
+ mockMediaType = 'pc'
+
+ // Act
+ renderStepThree()
+
+ // Assert
+ const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')
+ expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/guides/knowledge-base/integrate-knowledge-within-application')
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('rel', 'noreferrer noopener')
+ })
+
+ it('should apply correct container classes', () => {
+ // Arrange & Act
+ const { container } = renderStepThree()
+
+ // Assert
+ const outerDiv = container.firstChild as HTMLElement
+ expect(outerDiv).toHaveClass('flex', 'h-full', 'max-h-full', 'w-full', 'justify-center', 'overflow-y-auto')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Props Testing - Test all prop variations
+ // --------------------------------------------------------------------------
+ describe('Props', () => {
+ describe('datasetId prop', () => {
+ it('should render creation mode when datasetId is undefined', () => {
+ // Arrange & Act
+ renderStepThree({ datasetId: undefined })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
+ })
+
+ it('should render addition mode when datasetId is provided', () => {
+ // Arrange & Act
+ renderStepThree({ datasetId: 'dataset-123' })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument()
+ })
+
+ it('should pass datasetId to EmbeddingProcess', () => {
+ // Arrange
+ const datasetId = 'my-dataset-id'
+
+ // Act
+ renderStepThree({ datasetId })
+
+ // Assert
+ expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent(datasetId)
+ })
+
+ it('should use creationCache dataset id when datasetId is not provided', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+
+ // Act
+ renderStepThree({ creationCache })
+
+ // Assert
+ expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('dataset-123')
+ })
+ })
+
+ describe('datasetName prop', () => {
+ it('should display datasetName in creation mode', () => {
+ // Arrange & Act
+ renderStepThree({ datasetName: 'My Custom Dataset' })
+
+ // Assert
+ expect(screen.getByText('My Custom Dataset')).toBeInTheDocument()
+ })
+
+ it('should display datasetName in addition mode description', () => {
+ // Arrange & Act
+ renderStepThree({
+ datasetId: 'dataset-123',
+ datasetName: 'Existing Dataset Name',
+ })
+
+ // Assert - Check the text contains the dataset name (in the description)
+ const description = screen.getByText(/datasetCreation.stepThree.additionP1.*Existing Dataset Name.*datasetCreation.stepThree.additionP2/i)
+ expect(description).toBeInTheDocument()
+ })
+
+ it('should fallback to creationCache dataset name when datasetName is not provided', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.dataset!.name = 'Cache Dataset Name'
+
+ // Act
+ renderStepThree({ creationCache })
+
+ // Assert
+ expect(screen.getByText('Cache Dataset Name')).toBeInTheDocument()
+ })
+ })
+
+ describe('indexingType prop', () => {
+ it('should pass indexingType to EmbeddingProcess', () => {
+ // Arrange & Act
+ renderStepThree({ indexingType: 'high_quality' })
+
+ // Assert
+ expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('high_quality')
+ })
+
+ it('should use creationCache indexing_technique when indexingType is not provided', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.dataset!.indexing_technique = 'economy' as any
+
+ // Act
+ renderStepThree({ creationCache })
+
+ // Assert
+ expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('economy')
+ })
+
+ it('should prefer creationCache indexing_technique over indexingType prop', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.dataset!.indexing_technique = 'cache_technique' as any
+
+ // Act
+ renderStepThree({ creationCache, indexingType: 'prop_technique' })
+
+ // Assert - creationCache takes precedence
+ expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('cache_technique')
+ })
+ })
+
+ describe('retrievalMethod prop', () => {
+ it('should pass retrievalMethod to EmbeddingProcess', () => {
+ // Arrange & Act
+ renderStepThree({ retrievalMethod: 'semantic_search' })
+
+ // Assert
+ expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('semantic_search')
+ })
+
+ it('should use creationCache retrieval method when retrievalMethod is not provided', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as any
+
+ // Act
+ renderStepThree({ creationCache })
+
+ // Assert
+ expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('full_text_search')
+ })
+ })
+
+ describe('creationCache prop', () => {
+ it('should pass batchId from creationCache to EmbeddingProcess', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.batch = 'custom-batch-123'
+
+ // Act
+ renderStepThree({ creationCache })
+
+ // Assert
+ expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('custom-batch-123')
+ })
+
+ it('should pass documents from creationCache to EmbeddingProcess', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as any
+
+ // Act
+ renderStepThree({ creationCache })
+
+ // Assert
+ expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('3')
+ })
+
+ it('should use icon_info from creationCache dataset', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.dataset!.icon_info = createMockIconInfo({
+ icon: '🚀',
+ icon_background: '#FF0000',
+ })
+
+ // Act
+ const { container } = renderStepThree({ creationCache })
+
+ // Assert - Check AppIcon component receives correct props
+ const appIcon = container.querySelector('span[style*="background"]')
+ expect(appIcon).toBeInTheDocument()
+ })
+
+ it('should handle undefined creationCache', () => {
+ // Arrange & Act
+ renderStepThree({ creationCache: undefined })
+
+ // Assert - Should not crash, use fallback values
+ expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('')
+ expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('')
+ })
+
+ it('should handle creationCache with undefined dataset', () => {
+ // Arrange
+ const creationCache: createDocumentResponse = {
+ dataset: undefined,
+ batch: 'batch-123',
+ documents: [],
+ }
+
+ // Act
+ renderStepThree({ creationCache })
+
+ // Assert - Should use default icon info
+ expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Edge Cases Tests - Test null, undefined, empty values and boundaries
+ // --------------------------------------------------------------------------
+ describe('Edge Cases', () => {
+ it('should handle all props being undefined', () => {
+ // Arrange & Act
+ renderStepThree({
+ datasetId: undefined,
+ datasetName: undefined,
+ indexingType: undefined,
+ retrievalMethod: undefined,
+ creationCache: undefined,
+ })
+
+ // Assert - Should render creation mode with fallbacks
+ expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
+ expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
+ })
+
+ it('should handle empty string datasetId', () => {
+ // Arrange & Act
+ renderStepThree({ datasetId: '' })
+
+ // Assert - Empty string is falsy, should show creation mode
+ expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
+ })
+
+ it('should handle empty string datasetName', () => {
+ // Arrange & Act
+ renderStepThree({ datasetName: '' })
+
+ // Assert - Should not crash
+ expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
+ })
+
+ it('should handle empty documents array in creationCache', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.documents = []
+
+ // Act
+ renderStepThree({ creationCache })
+
+ // Assert
+ expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('0')
+ })
+
+ it('should handle creationCache with missing icon_info', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.dataset!.icon_info = undefined as any
+
+ // Act
+ renderStepThree({ creationCache })
+
+ // Assert - Should use default icon info
+ expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
+ })
+
+ it('should handle very long datasetName', () => {
+ // Arrange
+ const longName = 'A'.repeat(500)
+
+ // Act
+ renderStepThree({ datasetName: longName })
+
+ // Assert - Should render without crashing
+ expect(screen.getByText(longName)).toBeInTheDocument()
+ })
+
+ it('should handle special characters in datasetName', () => {
+ // Arrange
+ const specialName = 'Dataset & "quotes" \'apostrophe\''
+
+ // Act
+ renderStepThree({ datasetName: specialName })
+
+ // Assert - Should render safely as text
+ expect(screen.getByText(specialName)).toBeInTheDocument()
+ })
+
+ it('should handle unicode characters in datasetName', () => {
+ // Arrange
+ const unicodeName = '数据集名称 🚀 émojis & spëcîal çhàrs'
+
+ // Act
+ renderStepThree({ datasetName: unicodeName })
+
+ // Assert
+ expect(screen.getByText(unicodeName)).toBeInTheDocument()
+ })
+
+ it('should handle creationCache with null dataset name', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.dataset!.name = null as any
+
+ // Act
+ const { container } = renderStepThree({ creationCache })
+
+ // Assert - Should not crash
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Conditional Rendering Tests - Test mode switching behavior
+ // --------------------------------------------------------------------------
+ describe('Conditional Rendering', () => {
+ describe('Creation Mode (no datasetId)', () => {
+ it('should show AppIcon component', () => {
+ // Arrange & Act
+ const { container } = renderStepThree()
+
+ // Assert - AppIcon should be rendered
+ const appIcon = container.querySelector('span')
+ expect(appIcon).toBeInTheDocument()
+ })
+
+ it('should show Divider component', () => {
+ // Arrange & Act
+ const { container } = renderStepThree()
+
+ // Assert - Divider should be rendered (it adds hr with specific classes)
+ const dividers = container.querySelectorAll('[class*="divider"]')
+ expect(dividers.length).toBeGreaterThan(0)
+ })
+
+ it('should show dataset name input area', () => {
+ // Arrange
+ const datasetName = 'Test Dataset Name'
+
+ // Act
+ renderStepThree({ datasetName })
+
+ // Assert
+ expect(screen.getByText(datasetName)).toBeInTheDocument()
+ })
+ })
+
+ describe('Addition Mode (with datasetId)', () => {
+ it('should not show AppIcon component', () => {
+ // Arrange & Act
+ renderStepThree({ datasetId: 'dataset-123' })
+
+ // Assert - Creation section should not be rendered
+ expect(screen.queryByText('datasetCreation.stepThree.label')).not.toBeInTheDocument()
+ })
+
+ it('should show addition description with dataset name', () => {
+ // Arrange & Act
+ renderStepThree({
+ datasetId: 'dataset-123',
+ datasetName: 'My Dataset',
+ })
+
+ // Assert - Description should include dataset name
+ expect(screen.getByText(/datasetCreation.stepThree.additionP1/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Mobile vs Desktop', () => {
+ it('should show side panel on tablet', () => {
+ // Arrange
+ mockMediaType = 'tablet'
+
+ // Act
+ renderStepThree()
+
+ // Assert - Tablet is not mobile, should show side panel
+ expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument()
+ })
+
+ it('should not show side panel on mobile', () => {
+ // Arrange
+ mockMediaType = 'mobile'
+
+ // Act
+ renderStepThree()
+
+ // Assert
+ expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument()
+ })
+
+ it('should render EmbeddingProcess on mobile', () => {
+ // Arrange
+ mockMediaType = 'mobile'
+
+ // Act
+ renderStepThree()
+
+ // Assert - Main content should still be rendered
+ expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // EmbeddingProcess Integration Tests - Verify correct props are passed
+ // --------------------------------------------------------------------------
+ describe('EmbeddingProcess Integration', () => {
+ it('should pass correct datasetId to EmbeddingProcess with datasetId prop', () => {
+ // Arrange & Act
+ renderStepThree({ datasetId: 'direct-dataset-id' })
+
+ // Assert
+ expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('direct-dataset-id')
+ })
+
+ it('should pass creationCache dataset id when datasetId prop is undefined', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.dataset!.id = 'cache-dataset-id'
+
+ // Act
+ renderStepThree({ creationCache })
+
+ // Assert
+ expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('cache-dataset-id')
+ })
+
+ it('should pass empty string for datasetId when both sources are undefined', () => {
+ // Arrange & Act
+ renderStepThree()
+
+ // Assert
+ expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('')
+ })
+
+ it('should pass batchId from creationCache', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.batch = 'test-batch-456'
+
+ // Act
+ renderStepThree({ creationCache })
+
+ // Assert
+ expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('test-batch-456')
+ })
+
+ it('should pass empty string for batchId when creationCache is undefined', () => {
+ // Arrange & Act
+ renderStepThree()
+
+ // Assert
+ expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('')
+ })
+
+ it('should prefer datasetId prop over creationCache dataset id', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.dataset!.id = 'cache-id'
+
+ // Act
+ renderStepThree({ datasetId: 'prop-id', creationCache })
+
+ // Assert - datasetId prop takes precedence
+ expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('prop-id')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Icon Rendering Tests - Verify AppIcon behavior
+ // --------------------------------------------------------------------------
+ describe('Icon Rendering', () => {
+ it('should use default icon info when creationCache is undefined', () => {
+ // Arrange & Act
+ const { container } = renderStepThree()
+
+ // Assert - Default background color should be applied
+ const appIcon = container.querySelector('span[style*="background"]')
+ if (appIcon)
+ expect(appIcon).toHaveStyle({ background: '#FFF4ED' })
+ })
+
+ it('should use icon_info from creationCache when available', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ creationCache.dataset!.icon_info = {
+ icon: '🎉',
+ icon_type: 'emoji',
+ icon_background: '#00FF00',
+ icon_url: '',
+ }
+
+ // Act
+ const { container } = renderStepThree({ creationCache })
+
+ // Assert - Custom background color should be applied
+ const appIcon = container.querySelector('span[style*="background"]')
+ if (appIcon)
+ expect(appIcon).toHaveStyle({ background: '#00FF00' })
+ })
+
+ it('should use default icon when creationCache dataset icon_info is undefined', () => {
+ // Arrange
+ const creationCache = createMockCreationCache()
+ delete (creationCache.dataset as any).icon_info
+
+ // Act
+ const { container } = renderStepThree({ creationCache })
+
+ // Assert - Component should still render with default icon
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Layout Tests - Verify correct CSS classes and structure
+ // --------------------------------------------------------------------------
+ describe('Layout', () => {
+ it('should have correct outer container classes', () => {
+ // Arrange & Act
+ const { container } = renderStepThree()
+
+ // Assert
+ const outerDiv = container.firstChild as HTMLElement
+ expect(outerDiv).toHaveClass('flex')
+ expect(outerDiv).toHaveClass('h-full')
+ expect(outerDiv).toHaveClass('justify-center')
+ })
+
+ it('should have correct inner container classes', () => {
+ // Arrange & Act
+ const { container } = renderStepThree()
+
+ // Assert
+ const innerDiv = container.querySelector('.max-w-\\[960px\\]')
+ expect(innerDiv).toBeInTheDocument()
+ expect(innerDiv).toHaveClass('shrink-0', 'grow')
+ })
+
+ it('should have content wrapper with correct max width', () => {
+ // Arrange & Act
+ const { container } = renderStepThree()
+
+ // Assert
+ const contentWrapper = container.querySelector('.max-w-\\[640px\\]')
+ expect(contentWrapper).toBeInTheDocument()
+ })
+
+ it('should have side tip panel with correct width on desktop', () => {
+ // Arrange
+ mockMediaType = 'pc'
+
+ // Act
+ const { container } = renderStepThree()
+
+ // Assert
+ const sidePanel = container.querySelector('.w-\\[328px\\]')
+ expect(sidePanel).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Accessibility Tests - Verify accessibility features
+ // --------------------------------------------------------------------------
+ describe('Accessibility', () => {
+ it('should have correct link attributes for external documentation link', () => {
+ // Arrange
+ mockMediaType = 'pc'
+
+ // Act
+ renderStepThree()
+
+ // Assert
+ const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')
+ expect(link.tagName).toBe('A')
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('rel', 'noreferrer noopener')
+ })
+
+ it('should have semantic heading structure in creation mode', () => {
+ // Arrange & Act
+ renderStepThree()
+
+ // Assert
+ const title = screen.getByText('datasetCreation.stepThree.creationTitle')
+ expect(title).toBeInTheDocument()
+ expect(title.className).toContain('title-2xl-semi-bold')
+ })
+
+ it('should have semantic heading structure in addition mode', () => {
+ // Arrange & Act
+ renderStepThree({ datasetId: 'dataset-123' })
+
+ // Assert
+ const title = screen.getByText('datasetCreation.stepThree.additionTitle')
+ expect(title).toBeInTheDocument()
+ expect(title.className).toContain('title-2xl-semi-bold')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Side Panel Tests - Verify side panel behavior
+ // --------------------------------------------------------------------------
+ describe('Side Panel', () => {
+ it('should render RiBookOpenLine icon in side panel', () => {
+ // Arrange
+ mockMediaType = 'pc'
+
+ // Act
+ const { container } = renderStepThree()
+
+ // Assert - Icon should be present in side panel
+ const iconContainer = container.querySelector('.size-10')
+ expect(iconContainer).toBeInTheDocument()
+ })
+
+ it('should have correct side panel section background', () => {
+ // Arrange
+ mockMediaType = 'pc'
+
+ // Act
+ const { container } = renderStepThree()
+
+ // Assert
+ const sidePanel = container.querySelector('.bg-background-section')
+ expect(sidePanel).toBeInTheDocument()
+ })
+
+ it('should have correct padding for side panel', () => {
+ // Arrange
+ mockMediaType = 'pc'
+
+ // Act
+ const { container } = renderStepThree()
+
+ // Assert
+ const sidePanelWrapper = container.querySelector('.pr-8')
+ expect(sidePanelWrapper).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/step-two/index.tsx b/web/app/components/datasets/create/step-two/index.tsx
index 4d568e9a6c..cb592aed5d 100644
--- a/web/app/components/datasets/create/step-two/index.tsx
+++ b/web/app/components/datasets/create/step-two/index.tsx
@@ -28,7 +28,7 @@ import escape from './escape'
import { OptionCard } from './option-card'
import LanguageSelect from './language-select'
import { DelimiterInput, MaxLengthInput, OverlapInput } from './inputs'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { CrawlOptions, CrawlResultItem, CreateDocumentReq, CustomFile, DocumentItem, FullDocumentDetail, ParentMode, PreProcessingRule, ProcessRule, Rules, createDocumentResponse } from '@/models/datasets'
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
diff --git a/web/app/components/datasets/create/step-two/language-select/index.spec.tsx b/web/app/components/datasets/create/step-two/language-select/index.spec.tsx
new file mode 100644
index 0000000000..ad9611668d
--- /dev/null
+++ b/web/app/components/datasets/create/step-two/language-select/index.spec.tsx
@@ -0,0 +1,596 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import React from 'react'
+import LanguageSelect from './index'
+import type { ILanguageSelectProps } from './index'
+import { languages } from '@/i18n-config/language'
+
+// Get supported languages for test assertions
+const supportedLanguages = languages.filter(lang => lang.supported)
+
+// Test data builder for props
+const createDefaultProps = (overrides?: Partial): ILanguageSelectProps => ({
+ currentLanguage: 'English',
+ onSelect: jest.fn(),
+ disabled: false,
+ ...overrides,
+})
+
+describe('LanguageSelect', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // ==========================================
+ // Rendering Tests - Verify component renders correctly
+ // ==========================================
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('English')).toBeInTheDocument()
+ })
+
+ it('should render current language text', () => {
+ // Arrange
+ const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Chinese Simplified')).toBeInTheDocument()
+ })
+
+ it('should render dropdown arrow icon', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render( )
+
+ // Assert - RiArrowDownSLine renders as SVG
+ const svgIcon = container.querySelector('svg')
+ expect(svgIcon).toBeInTheDocument()
+ })
+
+ it('should render all supported languages in dropdown when opened', () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+
+ // Act - Click button to open dropdown
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - All supported languages should be visible
+ // Use getAllByText because current language appears both in button and dropdown
+ supportedLanguages.forEach((lang) => {
+ expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1)
+ })
+ })
+
+ it('should render check icon for selected language', () => {
+ // Arrange
+ const selectedLanguage = 'Japanese'
+ const props = createDefaultProps({ currentLanguage: selectedLanguage })
+ render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - The selected language option should have a check icon
+ const languageOptions = screen.getAllByText(selectedLanguage)
+ // One in the button, one in the dropdown list
+ expect(languageOptions.length).toBeGreaterThanOrEqual(1)
+ })
+ })
+
+ // ==========================================
+ // Props Testing - Verify all prop variations work correctly
+ // ==========================================
+ describe('Props', () => {
+ describe('currentLanguage prop', () => {
+ it('should display English when currentLanguage is English', () => {
+ const props = createDefaultProps({ currentLanguage: 'English' })
+ render( )
+ expect(screen.getByText('English')).toBeInTheDocument()
+ })
+
+ it('should display Chinese Simplified when currentLanguage is Chinese Simplified', () => {
+ const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' })
+ render( )
+ expect(screen.getByText('Chinese Simplified')).toBeInTheDocument()
+ })
+
+ it('should display Japanese when currentLanguage is Japanese', () => {
+ const props = createDefaultProps({ currentLanguage: 'Japanese' })
+ render( )
+ expect(screen.getByText('Japanese')).toBeInTheDocument()
+ })
+
+ it.each(supportedLanguages.map(l => l.prompt_name))(
+ 'should display %s as current language',
+ (language) => {
+ const props = createDefaultProps({ currentLanguage: language })
+ render( )
+ expect(screen.getByText(language)).toBeInTheDocument()
+ },
+ )
+ })
+
+ describe('disabled prop', () => {
+ it('should have disabled button when disabled is true', () => {
+ // Arrange
+ const props = createDefaultProps({ disabled: true })
+
+ // Act
+ render( )
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toBeDisabled()
+ })
+
+ it('should have enabled button when disabled is false', () => {
+ // Arrange
+ const props = createDefaultProps({ disabled: false })
+
+ // Act
+ render( )
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).not.toBeDisabled()
+ })
+
+ it('should have enabled button when disabled is undefined', () => {
+ // Arrange
+ const props = createDefaultProps()
+ delete (props as Partial).disabled
+
+ // Act
+ render( )
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).not.toBeDisabled()
+ })
+
+ it('should apply disabled styling when disabled is true', () => {
+ // Arrange
+ const props = createDefaultProps({ disabled: true })
+
+ // Act
+ const { container } = render( )
+
+ // Assert - Check for disabled class on text elements
+ const disabledTextElement = container.querySelector('.text-components-button-tertiary-text-disabled')
+ expect(disabledTextElement).toBeInTheDocument()
+ })
+
+ it('should apply cursor-not-allowed styling when disabled', () => {
+ // Arrange
+ const props = createDefaultProps({ disabled: true })
+
+ // Act
+ const { container } = render( )
+
+ // Assert
+ const elementWithCursor = container.querySelector('.cursor-not-allowed')
+ expect(elementWithCursor).toBeInTheDocument()
+ })
+ })
+
+ describe('onSelect prop', () => {
+ it('should be callable as a function', () => {
+ const mockOnSelect = jest.fn()
+ const props = createDefaultProps({ onSelect: mockOnSelect })
+ render( )
+
+ // Open dropdown and click a language
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ const germanOption = screen.getByText('German')
+ fireEvent.click(germanOption)
+
+ expect(mockOnSelect).toHaveBeenCalledWith('German')
+ })
+ })
+ })
+
+ // ==========================================
+ // User Interactions - Test event handlers
+ // ==========================================
+ describe('User Interactions', () => {
+ it('should open dropdown when button is clicked', () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - Check if dropdown content is visible
+ expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('should call onSelect when a language option is clicked', () => {
+ // Arrange
+ const mockOnSelect = jest.fn()
+ const props = createDefaultProps({ onSelect: mockOnSelect })
+ render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+ const frenchOption = screen.getByText('French')
+ fireEvent.click(frenchOption)
+
+ // Assert
+ expect(mockOnSelect).toHaveBeenCalledTimes(1)
+ expect(mockOnSelect).toHaveBeenCalledWith('French')
+ })
+
+ it('should call onSelect with correct language when selecting different languages', () => {
+ // Arrange
+ const mockOnSelect = jest.fn()
+ const props = createDefaultProps({ onSelect: mockOnSelect })
+ render( )
+
+ // Act & Assert - Test multiple language selections
+ const testLanguages = ['Korean', 'Spanish', 'Italian']
+
+ testLanguages.forEach((lang) => {
+ mockOnSelect.mockClear()
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+ const languageOption = screen.getByText(lang)
+ fireEvent.click(languageOption)
+ expect(mockOnSelect).toHaveBeenCalledWith(lang)
+ })
+ })
+
+ it('should not open dropdown when disabled', () => {
+ // Arrange
+ const props = createDefaultProps({ disabled: true })
+ render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - Dropdown should not open, only one instance of the current language should exist
+ const englishElements = screen.getAllByText('English')
+ expect(englishElements.length).toBe(1) // Only the button text, not dropdown
+ })
+
+ it('should not call onSelect when component is disabled', () => {
+ // Arrange
+ const mockOnSelect = jest.fn()
+ const props = createDefaultProps({ onSelect: mockOnSelect, disabled: true })
+ render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert
+ expect(mockOnSelect).not.toHaveBeenCalled()
+ })
+
+ it('should handle rapid consecutive clicks', () => {
+ // Arrange
+ const mockOnSelect = jest.fn()
+ const props = createDefaultProps({ onSelect: mockOnSelect })
+ render( )
+
+ // Act - Rapid clicks
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+ fireEvent.click(button)
+ fireEvent.click(button)
+
+ // Assert - Component should not crash
+ expect(button).toBeInTheDocument()
+ })
+ })
+
+ // ==========================================
+ // Component Memoization - Test React.memo behavior
+ // ==========================================
+ describe('Memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ // Assert - Check component has memo wrapper
+ expect(LanguageSelect.$$typeof).toBe(Symbol.for('react.memo'))
+ })
+
+ it('should not re-render when props remain the same', () => {
+ // Arrange
+ const mockOnSelect = jest.fn()
+ const props = createDefaultProps({ onSelect: mockOnSelect })
+ const renderSpy = jest.fn()
+
+ // Create a wrapper component to track renders
+ const TrackedLanguageSelect: React.FC = (trackedProps) => {
+ renderSpy()
+ return
+ }
+ const MemoizedTracked = React.memo(TrackedLanguageSelect)
+
+ // Act
+ const { rerender } = render( )
+ rerender( )
+
+ // Assert - Should only render once due to same props
+ expect(renderSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('should re-render when currentLanguage changes', () => {
+ // Arrange
+ const props = createDefaultProps({ currentLanguage: 'English' })
+
+ // Act
+ const { rerender } = render( )
+ expect(screen.getByText('English')).toBeInTheDocument()
+
+ rerender( )
+
+ // Assert
+ expect(screen.getByText('French')).toBeInTheDocument()
+ })
+
+ it('should re-render when disabled changes', () => {
+ // Arrange
+ const props = createDefaultProps({ disabled: false })
+
+ // Act
+ const { rerender } = render( )
+ expect(screen.getByRole('button')).not.toBeDisabled()
+
+ rerender( )
+
+ // Assert
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+ })
+
+ // ==========================================
+ // Edge Cases - Test boundary conditions and error handling
+ // ==========================================
+ describe('Edge Cases', () => {
+ it('should handle empty string as currentLanguage', () => {
+ // Arrange
+ const props = createDefaultProps({ currentLanguage: '' })
+
+ // Act
+ render( )
+
+ // Assert - Component should still render
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ })
+
+ it('should handle non-existent language as currentLanguage', () => {
+ // Arrange
+ const props = createDefaultProps({ currentLanguage: 'NonExistentLanguage' })
+
+ // Act
+ render( )
+
+ // Assert - Should display the value even if not in list
+ expect(screen.getByText('NonExistentLanguage')).toBeInTheDocument()
+ })
+
+ it('should handle special characters in language names', () => {
+ // Arrange - Turkish has special character in prompt_name
+ const props = createDefaultProps({ currentLanguage: 'Türkçe' })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Türkçe')).toBeInTheDocument()
+ })
+
+ it('should handle very long language names', () => {
+ // Arrange
+ const longLanguageName = 'A'.repeat(100)
+ const props = createDefaultProps({ currentLanguage: longLanguageName })
+
+ // Act
+ render( )
+
+ // Assert - Should not crash and should display the text
+ expect(screen.getByText(longLanguageName)).toBeInTheDocument()
+ })
+
+ it('should render correct number of language options', () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - Should show all supported languages
+ const expectedCount = supportedLanguages.length
+ // Each language appears in the dropdown (use getAllByText because current language appears twice)
+ supportedLanguages.forEach((lang) => {
+ expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1)
+ })
+ expect(supportedLanguages.length).toBe(expectedCount)
+ })
+
+ it('should only show supported languages in dropdown', () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - All displayed languages should be supported
+ const allLanguages = languages
+ const unsupportedLanguages = allLanguages.filter(lang => !lang.supported)
+
+ unsupportedLanguages.forEach((lang) => {
+ expect(screen.queryByText(lang.prompt_name)).not.toBeInTheDocument()
+ })
+ })
+
+ it('should handle undefined onSelect gracefully when clicking', () => {
+ // Arrange - This tests TypeScript boundary, but runtime should not crash
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+ const option = screen.getByText('German')
+
+ // Assert - Should not throw
+ expect(() => fireEvent.click(option)).not.toThrow()
+ })
+
+ it('should maintain selection state visually with check icon', () => {
+ // Arrange
+ const props = createDefaultProps({ currentLanguage: 'Russian' })
+ const { container } = render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - Find the check icon (RiCheckLine) in the dropdown
+ // The selected option should have a check icon next to it
+ const checkIcons = container.querySelectorAll('svg.text-text-accent')
+ expect(checkIcons.length).toBeGreaterThanOrEqual(1)
+ })
+ })
+
+ // ==========================================
+ // Accessibility - Basic accessibility checks
+ // ==========================================
+ describe('Accessibility', () => {
+ it('should have accessible button element', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toBeInTheDocument()
+ })
+
+ it('should have clickable language options', () => {
+ // Arrange
+ const props = createDefaultProps()
+ render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - Options should be clickable (have cursor-pointer class)
+ const options = screen.getAllByText(/English|French|German|Japanese/i)
+ expect(options.length).toBeGreaterThan(0)
+ })
+ })
+
+ // ==========================================
+ // Integration with Popover - Test Popover behavior
+ // ==========================================
+ describe('Popover Integration', () => {
+ it('should use manualClose prop on Popover', () => {
+ // Arrange
+ const mockOnSelect = jest.fn()
+ const props = createDefaultProps({ onSelect: mockOnSelect })
+
+ // Act
+ render( )
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - Popover should be open
+ expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('should have correct popup z-index class', () => {
+ // Arrange
+ const props = createDefaultProps()
+ const { container } = render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - Check for z-20 class (popupClassName='z-20')
+ // This is applied to the Popover
+ expect(container.querySelector('.z-20')).toBeTruthy()
+ })
+ })
+
+ // ==========================================
+ // Styling Tests - Verify correct CSS classes applied
+ // ==========================================
+ describe('Styling', () => {
+ it('should apply tertiary button styling', () => {
+ // Arrange
+ const props = createDefaultProps()
+ const { container } = render( )
+
+ // Assert - Check for tertiary button classes (uses ! prefix for important)
+ expect(container.querySelector('.\\!bg-components-button-tertiary-bg')).toBeInTheDocument()
+ })
+
+ it('should apply hover styling class to options', () => {
+ // Arrange
+ const props = createDefaultProps()
+ const { container } = render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - Options should have hover class
+ const optionWithHover = container.querySelector('.hover\\:bg-state-base-hover')
+ expect(optionWithHover).toBeInTheDocument()
+ })
+
+ it('should apply correct text styling to language options', () => {
+ // Arrange
+ const props = createDefaultProps()
+ const { container } = render( )
+
+ // Act
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - Check for system-sm-medium class on options
+ const styledOption = container.querySelector('.system-sm-medium')
+ expect(styledOption).toBeInTheDocument()
+ })
+
+ it('should apply disabled styling to icon when disabled', () => {
+ // Arrange
+ const props = createDefaultProps({ disabled: true })
+ const { container } = render( )
+
+ // Assert - Check for disabled text color on icon
+ const disabledIcon = container.querySelector('.text-components-button-tertiary-text-disabled')
+ expect(disabledIcon).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/step-two/language-select/index.tsx b/web/app/components/datasets/create/step-two/language-select/index.tsx
index 2fd8c143f9..da3807b95f 100644
--- a/web/app/components/datasets/create/step-two/language-select/index.tsx
+++ b/web/app/components/datasets/create/step-two/language-select/index.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Popover from '@/app/components/base/popover'
import { languages } from '@/i18n-config/language'
diff --git a/web/app/components/datasets/create/step-two/option-card.tsx b/web/app/components/datasets/create/step-two/option-card.tsx
index 7e901c913a..9a43513df1 100644
--- a/web/app/components/datasets/create/step-two/option-card.tsx
+++ b/web/app/components/datasets/create/step-two/option-card.tsx
@@ -1,6 +1,6 @@
import type { ComponentProps, FC, ReactNode } from 'react'
import Image from 'next/image'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
const TriangleArrow: FC> = props => (
@@ -20,11 +20,9 @@ type OptionCardHeaderProps = {
export const OptionCardHeader: FC = (props) => {
const { icon, title, description, isActive, activeClassName, effectImg, disabled } = props
- return
+ !disabled && 'cursor-pointer')}>
{isActive && effectImg &&
}
@@ -34,7 +32,7 @@ export const OptionCardHeader: FC = (props) => {
{title}
@@ -66,14 +64,12 @@ export const OptionCard: FC
= (
const { icon, className, title, description, isActive, children, actions, activeHeaderClassName, style, effectImg, onSwitched, noHighlight, disabled, ...rest } = props
return (
): IPreviewItemProps => ({
+ type: PreviewType.TEXT,
+ index: 1,
+ content: 'Test content',
+ ...overrides,
+})
+
+const createQAProps = (overrides?: Partial
): IPreviewItemProps => ({
+ type: PreviewType.QA,
+ index: 1,
+ qa: {
+ question: 'Test question',
+ answer: 'Test answer',
+ },
+ ...overrides,
+})
+
+describe('PreviewItem', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // ==========================================
+ // Rendering Tests - Verify component renders correctly
+ // ==========================================
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Test content')).toBeInTheDocument()
+ })
+
+ it('should render with TEXT type', () => {
+ // Arrange
+ const props = createDefaultProps({ content: 'Sample text content' })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Sample text content')).toBeInTheDocument()
+ })
+
+ it('should render with QA type', () => {
+ // Arrange
+ const props = createQAProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ expect(screen.getByText('Test question')).toBeInTheDocument()
+ expect(screen.getByText('Test answer')).toBeInTheDocument()
+ })
+
+ it('should render sharp icon (#) with formatted index', () => {
+ // Arrange
+ const props = createDefaultProps({ index: 5 })
+
+ // Act
+ const { container } = render( )
+
+ // Assert - Index should be padded to 3 digits
+ expect(screen.getByText('005')).toBeInTheDocument()
+ // Sharp icon SVG should exist
+ const svgElements = container.querySelectorAll('svg')
+ expect(svgElements.length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('should render character count for TEXT type', () => {
+ // Arrange
+ const content = 'Hello World' // 11 characters
+ const props = createDefaultProps({ content })
+
+ // Act
+ render( )
+
+ // Assert - Shows character count with translation key
+ expect(screen.getByText(/11/)).toBeInTheDocument()
+ expect(screen.getByText(/datasetCreation.stepTwo.characters/)).toBeInTheDocument()
+ })
+
+ it('should render character count for QA type', () => {
+ // Arrange
+ const props = createQAProps({
+ qa: {
+ question: 'Hello', // 5 characters
+ answer: 'World', // 5 characters - total 10
+ },
+ })
+
+ // Act
+ render( )
+
+ // Assert - Shows combined character count
+ expect(screen.getByText(/10/)).toBeInTheDocument()
+ })
+
+ it('should render text icon SVG', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render( )
+
+ // Assert - Should have SVG icons
+ const svgElements = container.querySelectorAll('svg')
+ expect(svgElements.length).toBe(2) // Sharp icon and text icon
+ })
+ })
+
+ // ==========================================
+ // Props Testing - Verify all prop variations work correctly
+ // ==========================================
+ describe('Props', () => {
+ describe('type prop', () => {
+ it('should render TEXT content when type is TEXT', () => {
+ // Arrange
+ const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text mode content' })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Text mode content')).toBeInTheDocument()
+ expect(screen.queryByText('Q')).not.toBeInTheDocument()
+ expect(screen.queryByText('A')).not.toBeInTheDocument()
+ })
+
+ it('should render QA content when type is QA', () => {
+ // Arrange
+ const props = createQAProps({
+ type: PreviewType.QA,
+ qa: { question: 'My question', answer: 'My answer' },
+ })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ expect(screen.getByText('My question')).toBeInTheDocument()
+ expect(screen.getByText('My answer')).toBeInTheDocument()
+ })
+
+ it('should use TEXT as default type when type is "text"', () => {
+ // Arrange
+ const props = createDefaultProps({ type: 'text' as PreviewType, content: 'Default type content' })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Default type content')).toBeInTheDocument()
+ })
+
+ it('should use QA type when type is "QA"', () => {
+ // Arrange
+ const props = createQAProps({ type: 'QA' as PreviewType })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ })
+ })
+
+ describe('index prop', () => {
+ it.each([
+ [1, '001'],
+ [5, '005'],
+ [10, '010'],
+ [99, '099'],
+ [100, '100'],
+ [999, '999'],
+ [1000, '1000'],
+ ])('should format index %i as %s', (index, expected) => {
+ // Arrange
+ const props = createDefaultProps({ index })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(expected)).toBeInTheDocument()
+ })
+
+ it('should handle index 0', () => {
+ // Arrange
+ const props = createDefaultProps({ index: 0 })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('000')).toBeInTheDocument()
+ })
+
+ it('should handle large index numbers', () => {
+ // Arrange
+ const props = createDefaultProps({ index: 12345 })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('12345')).toBeInTheDocument()
+ })
+ })
+
+ describe('content prop', () => {
+ it('should render content when provided', () => {
+ // Arrange
+ const props = createDefaultProps({ content: 'Custom content here' })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Custom content here')).toBeInTheDocument()
+ })
+
+ it('should handle multiline content', () => {
+ // Arrange
+ const multilineContent = 'Line 1\nLine 2\nLine 3'
+ const props = createDefaultProps({ content: multilineContent })
+
+ // Act
+ const { container } = render( )
+
+ // Assert - Check content is rendered (multiline text is in pre-line div)
+ const contentDiv = container.querySelector('[style*="white-space: pre-line"]')
+ expect(contentDiv?.textContent).toContain('Line 1')
+ expect(contentDiv?.textContent).toContain('Line 2')
+ expect(contentDiv?.textContent).toContain('Line 3')
+ })
+
+ it('should preserve whitespace with pre-line style', () => {
+ // Arrange
+ const props = createDefaultProps({ content: 'Text with spaces' })
+
+ // Act
+ const { container } = render( )
+
+ // Assert - Check for whiteSpace: pre-line style
+ const contentDiv = container.querySelector('[style*="white-space: pre-line"]')
+ expect(contentDiv).toBeInTheDocument()
+ })
+ })
+
+ describe('qa prop', () => {
+ it('should render question and answer when qa is provided', () => {
+ // Arrange
+ const props = createQAProps({
+ qa: {
+ question: 'What is testing?',
+ answer: 'Testing is verification.',
+ },
+ })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('What is testing?')).toBeInTheDocument()
+ expect(screen.getByText('Testing is verification.')).toBeInTheDocument()
+ })
+
+ it('should render Q and A labels', () => {
+ // Arrange
+ const props = createQAProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ })
+
+ it('should handle multiline question', () => {
+ // Arrange
+ const props = createQAProps({
+ qa: {
+ question: 'Question line 1\nQuestion line 2',
+ answer: 'Answer',
+ },
+ })
+
+ // Act
+ const { container } = render( )
+
+ // Assert - Check content is in pre-line div
+ const preLineDivs = container.querySelectorAll('[style*="white-space: pre-line"]')
+ const questionDiv = Array.from(preLineDivs).find(div => div.textContent?.includes('Question line 1'))
+ expect(questionDiv).toBeTruthy()
+ expect(questionDiv?.textContent).toContain('Question line 2')
+ })
+
+ it('should handle multiline answer', () => {
+ // Arrange
+ const props = createQAProps({
+ qa: {
+ question: 'Question',
+ answer: 'Answer line 1\nAnswer line 2',
+ },
+ })
+
+ // Act
+ const { container } = render( )
+
+ // Assert - Check content is in pre-line div
+ const preLineDivs = container.querySelectorAll('[style*="white-space: pre-line"]')
+ const answerDiv = Array.from(preLineDivs).find(div => div.textContent?.includes('Answer line 1'))
+ expect(answerDiv).toBeTruthy()
+ expect(answerDiv?.textContent).toContain('Answer line 2')
+ })
+ })
+ })
+
+ // ==========================================
+ // Component Memoization - Test React.memo behavior
+ // ==========================================
+ describe('Memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ // Assert - Check component has memo wrapper
+ expect(PreviewItem.$$typeof).toBe(Symbol.for('react.memo'))
+ })
+
+ it('should not re-render when props remain the same', () => {
+ // Arrange
+ const props = createDefaultProps()
+ const renderSpy = jest.fn()
+
+ // Create a wrapper component to track renders
+ const TrackedPreviewItem: React.FC = (trackedProps) => {
+ renderSpy()
+ return
+ }
+ const MemoizedTracked = React.memo(TrackedPreviewItem)
+
+ // Act
+ const { rerender } = render( )
+ rerender( )
+
+ // Assert - Should only render once due to same props
+ expect(renderSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('should re-render when content changes', () => {
+ // Arrange
+ const props = createDefaultProps({ content: 'Initial content' })
+
+ // Act
+ const { rerender } = render( )
+ expect(screen.getByText('Initial content')).toBeInTheDocument()
+
+ rerender( )
+
+ // Assert
+ expect(screen.getByText('Updated content')).toBeInTheDocument()
+ })
+
+ it('should re-render when index changes', () => {
+ // Arrange
+ const props = createDefaultProps({ index: 1 })
+
+ // Act
+ const { rerender } = render( )
+ expect(screen.getByText('001')).toBeInTheDocument()
+
+ rerender( )
+
+ // Assert
+ expect(screen.getByText('099')).toBeInTheDocument()
+ })
+
+ it('should re-render when type changes', () => {
+ // Arrange
+ const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text content' })
+
+ // Act
+ const { rerender } = render( )
+ expect(screen.getByText('Text content')).toBeInTheDocument()
+ expect(screen.queryByText('Q')).not.toBeInTheDocument()
+
+ rerender( )
+
+ // Assert
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ })
+
+ it('should re-render when qa prop changes', () => {
+ // Arrange
+ const props = createQAProps({
+ qa: { question: 'Original question', answer: 'Original answer' },
+ })
+
+ // Act
+ const { rerender } = render( )
+ expect(screen.getByText('Original question')).toBeInTheDocument()
+
+ rerender( )
+
+ // Assert
+ expect(screen.getByText('New question')).toBeInTheDocument()
+ expect(screen.getByText('New answer')).toBeInTheDocument()
+ })
+ })
+
+ // ==========================================
+ // Edge Cases - Test boundary conditions and error handling
+ // ==========================================
+ describe('Edge Cases', () => {
+ describe('Empty/Undefined values', () => {
+ it('should handle undefined content gracefully', () => {
+ // Arrange
+ const props = createDefaultProps({ content: undefined })
+
+ // Act
+ render( )
+
+ // Assert - Should show 0 characters (use more specific text match)
+ expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
+ })
+
+ it('should handle empty string content', () => {
+ // Arrange
+ const props = createDefaultProps({ content: '' })
+
+ // Act
+ render( )
+
+ // Assert - Should show 0 characters (use more specific text match)
+ expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
+ })
+
+ it('should handle undefined qa gracefully', () => {
+ // Arrange
+ const props: IPreviewItemProps = {
+ type: PreviewType.QA,
+ index: 1,
+ qa: undefined,
+ }
+
+ // Act
+ render( )
+
+ // Assert - Should render Q and A labels but with empty content
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ // Character count should be 0 (use more specific text match)
+ expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
+ })
+
+ it('should handle undefined question in qa', () => {
+ // Arrange
+ const props: IPreviewItemProps = {
+ type: PreviewType.QA,
+ index: 1,
+ qa: {
+ question: undefined as unknown as string,
+ answer: 'Only answer',
+ },
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Only answer')).toBeInTheDocument()
+ })
+
+ it('should handle undefined answer in qa', () => {
+ // Arrange
+ const props: IPreviewItemProps = {
+ type: PreviewType.QA,
+ index: 1,
+ qa: {
+ question: 'Only question',
+ answer: undefined as unknown as string,
+ },
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Only question')).toBeInTheDocument()
+ })
+
+ it('should handle empty question and answer strings', () => {
+ // Arrange
+ const props = createQAProps({
+ qa: { question: '', answer: '' },
+ })
+
+ // Act
+ render( )
+
+ // Assert - Should show 0 characters (use more specific text match)
+ expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
+ expect(screen.getByText('Q')).toBeInTheDocument()
+ expect(screen.getByText('A')).toBeInTheDocument()
+ })
+ })
+
+ describe('Character count calculation', () => {
+ it('should calculate correct character count for TEXT type', () => {
+ // Arrange - 'Test' has 4 characters
+ const props = createDefaultProps({ content: 'Test' })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/4/)).toBeInTheDocument()
+ })
+
+ it('should calculate correct character count for QA type (question + answer)', () => {
+ // Arrange - 'ABC' (3) + 'DEFGH' (5) = 8 characters
+ const props = createQAProps({
+ qa: { question: 'ABC', answer: 'DEFGH' },
+ })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/8/)).toBeInTheDocument()
+ })
+
+ it('should count special characters correctly', () => {
+ // Arrange - Content with special characters
+ const props = createDefaultProps({ content: '你好世界' }) // 4 Chinese characters
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/4/)).toBeInTheDocument()
+ })
+
+ it('should count newlines in character count', () => {
+ // Arrange - 'a\nb' has 3 characters
+ const props = createDefaultProps({ content: 'a\nb' })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/3/)).toBeInTheDocument()
+ })
+
+ it('should count spaces in character count', () => {
+ // Arrange - 'a b' has 3 characters
+ const props = createDefaultProps({ content: 'a b' })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/3/)).toBeInTheDocument()
+ })
+ })
+
+ describe('Boundary conditions', () => {
+ it('should handle very long content', () => {
+ // Arrange
+ const longContent = 'A'.repeat(10000)
+ const props = createDefaultProps({ content: longContent })
+
+ // Act
+ render( )
+
+ // Assert - Should show correct character count
+ expect(screen.getByText(/10000/)).toBeInTheDocument()
+ })
+
+ it('should handle very long index', () => {
+ // Arrange
+ const props = createDefaultProps({ index: 999999999 })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('999999999')).toBeInTheDocument()
+ })
+
+ it('should handle negative index', () => {
+ // Arrange
+ const props = createDefaultProps({ index: -1 })
+
+ // Act
+ render( )
+
+ // Assert - padStart pads from the start, so -1 becomes 0-1
+ expect(screen.getByText('0-1')).toBeInTheDocument()
+ })
+
+ it('should handle content with only whitespace', () => {
+ // Arrange
+ const props = createDefaultProps({ content: ' ' }) // 3 spaces
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText(/3/)).toBeInTheDocument()
+ })
+
+ it('should handle content with HTML-like characters', () => {
+ // Arrange
+ const props = createDefaultProps({ content: 'Test
' })
+
+ // Act
+ render( )
+
+ // Assert - Should render as text, not HTML
+ expect(screen.getByText('Test
')).toBeInTheDocument()
+ })
+
+ it('should handle content with emojis', () => {
+ // Arrange - Emojis can have complex character lengths
+ const props = createDefaultProps({ content: '😀👍' })
+
+ // Act
+ render( )
+
+ // Assert - Emoji length depends on JS string length
+ expect(screen.getByText('😀👍')).toBeInTheDocument()
+ })
+ })
+
+ describe('Type edge cases', () => {
+ it('should ignore qa prop when type is TEXT', () => {
+ // Arrange - Both content and qa provided, but type is TEXT
+ const props: IPreviewItemProps = {
+ type: PreviewType.TEXT,
+ index: 1,
+ content: 'Text content',
+ qa: { question: 'Should not show', answer: 'Also should not show' },
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('Text content')).toBeInTheDocument()
+ expect(screen.queryByText('Should not show')).not.toBeInTheDocument()
+ expect(screen.queryByText('Also should not show')).not.toBeInTheDocument()
+ })
+
+ it('should use content length for TEXT type even when qa is provided', () => {
+ // Arrange
+ const props: IPreviewItemProps = {
+ type: PreviewType.TEXT,
+ index: 1,
+ content: 'Hi', // 2 characters
+ qa: { question: 'Question', answer: 'Answer' }, // Would be 14 characters if used
+ }
+
+ // Act
+ render( )
+
+ // Assert - Should show 2, not 14
+ expect(screen.getByText(/2/)).toBeInTheDocument()
+ })
+
+ it('should ignore content prop when type is QA', () => {
+ // Arrange
+ const props: IPreviewItemProps = {
+ type: PreviewType.QA,
+ index: 1,
+ content: 'Should not display',
+ qa: { question: 'Q text', answer: 'A text' },
+ }
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.queryByText('Should not display')).not.toBeInTheDocument()
+ expect(screen.getByText('Q text')).toBeInTheDocument()
+ expect(screen.getByText('A text')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // ==========================================
+ // PreviewType Enum - Test exported enum values
+ // ==========================================
+ describe('PreviewType Enum', () => {
+ it('should have TEXT value as "text"', () => {
+ expect(PreviewType.TEXT).toBe('text')
+ })
+
+ it('should have QA value as "QA"', () => {
+ expect(PreviewType.QA).toBe('QA')
+ })
+ })
+
+ // ==========================================
+ // Styling Tests - Verify correct CSS classes applied
+ // ==========================================
+ describe('Styling', () => {
+ it('should have rounded container with gray background', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render( )
+
+ // Assert
+ const rootDiv = container.firstChild as HTMLElement
+ expect(rootDiv).toHaveClass('rounded-xl', 'bg-gray-50', 'p-4')
+ })
+
+ it('should have proper header styling', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render( )
+
+ // Assert - Check header div styling
+ const headerDiv = container.querySelector('.flex.h-5.items-center.justify-between')
+ expect(headerDiv).toBeInTheDocument()
+ })
+
+ it('should have index badge styling', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render( )
+
+ // Assert
+ const indexBadge = container.querySelector('.border.border-gray-200')
+ expect(indexBadge).toBeInTheDocument()
+ expect(indexBadge).toHaveClass('rounded-md', 'italic', 'font-medium')
+ })
+
+ it('should have content area with line-clamp', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render( )
+
+ // Assert
+ const contentArea = container.querySelector('.line-clamp-6')
+ expect(contentArea).toBeInTheDocument()
+ expect(contentArea).toHaveClass('max-h-[120px]', 'overflow-hidden')
+ })
+
+ it('should have Q/A labels with gray color', () => {
+ // Arrange
+ const props = createQAProps()
+
+ // Act
+ const { container } = render( )
+
+ // Assert
+ const labels = container.querySelectorAll('.text-gray-400')
+ expect(labels.length).toBeGreaterThanOrEqual(2) // Q and A labels
+ })
+ })
+
+ // ==========================================
+ // i18n Translation - Test translation integration
+ // ==========================================
+ describe('i18n Translation', () => {
+ it('should use translation key for characters label', () => {
+ // Arrange
+ const props = createDefaultProps({ content: 'Test' })
+
+ // Act
+ render( )
+
+ // Assert - The mock returns the key as-is
+ expect(screen.getByText(/datasetCreation.stepTwo.characters/)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/stepper/index.spec.tsx b/web/app/components/datasets/create/stepper/index.spec.tsx
new file mode 100644
index 0000000000..174c2d3472
--- /dev/null
+++ b/web/app/components/datasets/create/stepper/index.spec.tsx
@@ -0,0 +1,735 @@
+import { render, screen } from '@testing-library/react'
+import { Stepper, type StepperProps } from './index'
+import { type Step, StepperStep, type StepperStepProps } from './step'
+
+// Test data factory for creating steps
+const createStep = (overrides: Partial = {}): Step => ({
+ name: 'Test Step',
+ ...overrides,
+})
+
+const createSteps = (count: number, namePrefix = 'Step'): Step[] =>
+ Array.from({ length: count }, (_, i) => createStep({ name: `${namePrefix} ${i + 1}` }))
+
+// Helper to render Stepper with default props
+const renderStepper = (props: Partial = {}) => {
+ const defaultProps: StepperProps = {
+ steps: createSteps(3),
+ activeIndex: 0,
+ ...props,
+ }
+ return render( )
+}
+
+// Helper to render StepperStep with default props
+const renderStepperStep = (props: Partial = {}) => {
+ const defaultProps: StepperStepProps = {
+ name: 'Test Step',
+ index: 0,
+ activeIndex: 0,
+ ...props,
+ }
+ return render( )
+}
+
+// ============================================================================
+// Stepper Component Tests
+// ============================================================================
+describe('Stepper', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // --------------------------------------------------------------------------
+ // Rendering Tests - Verify component renders properly with various inputs
+ // --------------------------------------------------------------------------
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ renderStepper()
+
+ // Assert
+ expect(screen.getByText('Step 1')).toBeInTheDocument()
+ })
+
+ it('should render all step names', () => {
+ // Arrange
+ const steps = createSteps(3, 'Custom Step')
+
+ // Act
+ renderStepper({ steps })
+
+ // Assert
+ expect(screen.getByText('Custom Step 1')).toBeInTheDocument()
+ expect(screen.getByText('Custom Step 2')).toBeInTheDocument()
+ expect(screen.getByText('Custom Step 3')).toBeInTheDocument()
+ })
+
+ it('should render dividers between steps', () => {
+ // Arrange
+ const steps = createSteps(3)
+
+ // Act
+ const { container } = renderStepper({ steps })
+
+ // Assert - Should have 2 dividers for 3 steps
+ const dividers = container.querySelectorAll('.bg-divider-deep')
+ expect(dividers.length).toBe(2)
+ })
+
+ it('should not render divider after last step', () => {
+ // Arrange
+ const steps = createSteps(2)
+
+ // Act
+ const { container } = renderStepper({ steps })
+
+ // Assert - Should have 1 divider for 2 steps
+ const dividers = container.querySelectorAll('.bg-divider-deep')
+ expect(dividers.length).toBe(1)
+ })
+
+ it('should render with flex container layout', () => {
+ // Arrange & Act
+ const { container } = renderStepper()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex', 'items-center', 'gap-3')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Props Testing - Test all prop variations and combinations
+ // --------------------------------------------------------------------------
+ describe('Props', () => {
+ describe('steps prop', () => {
+ it('should render correct number of steps', () => {
+ // Arrange
+ const steps = createSteps(5)
+
+ // Act
+ renderStepper({ steps })
+
+ // Assert
+ expect(screen.getByText('Step 1')).toBeInTheDocument()
+ expect(screen.getByText('Step 2')).toBeInTheDocument()
+ expect(screen.getByText('Step 3')).toBeInTheDocument()
+ expect(screen.getByText('Step 4')).toBeInTheDocument()
+ expect(screen.getByText('Step 5')).toBeInTheDocument()
+ })
+
+ it('should handle single step correctly', () => {
+ // Arrange
+ const steps = [createStep({ name: 'Only Step' })]
+
+ // Act
+ const { container } = renderStepper({ steps, activeIndex: 0 })
+
+ // Assert
+ expect(screen.getByText('Only Step')).toBeInTheDocument()
+ // No dividers for single step
+ const dividers = container.querySelectorAll('.bg-divider-deep')
+ expect(dividers.length).toBe(0)
+ })
+
+ it('should handle steps with long names', () => {
+ // Arrange
+ const longName = 'This is a very long step name that might overflow'
+ const steps = [createStep({ name: longName })]
+
+ // Act
+ renderStepper({ steps, activeIndex: 0 })
+
+ // Assert
+ expect(screen.getByText(longName)).toBeInTheDocument()
+ })
+
+ it('should handle steps with special characters', () => {
+ // Arrange
+ const steps = [
+ createStep({ name: 'Step & Configuration' }),
+ createStep({ name: 'Step ' }),
+ createStep({ name: 'Step "Complete"' }),
+ ]
+
+ // Act
+ renderStepper({ steps, activeIndex: 0 })
+
+ // Assert
+ expect(screen.getByText('Step & Configuration')).toBeInTheDocument()
+ expect(screen.getByText('Step ')).toBeInTheDocument()
+ expect(screen.getByText('Step "Complete"')).toBeInTheDocument()
+ })
+ })
+
+ describe('activeIndex prop', () => {
+ it('should highlight first step when activeIndex is 0', () => {
+ // Arrange & Act
+ renderStepper({ activeIndex: 0 })
+
+ // Assert - First step should show "STEP 1" label
+ expect(screen.getByText('STEP 1')).toBeInTheDocument()
+ })
+
+ it('should highlight second step when activeIndex is 1', () => {
+ // Arrange & Act
+ renderStepper({ activeIndex: 1 })
+
+ // Assert - Second step should show "STEP 2" label
+ expect(screen.getByText('STEP 2')).toBeInTheDocument()
+ })
+
+ it('should highlight last step when activeIndex equals steps length - 1', () => {
+ // Arrange
+ const steps = createSteps(3)
+
+ // Act
+ renderStepper({ steps, activeIndex: 2 })
+
+ // Assert - Third step should show "STEP 3" label
+ expect(screen.getByText('STEP 3')).toBeInTheDocument()
+ })
+
+ it('should show completed steps with number only (no STEP prefix)', () => {
+ // Arrange
+ const steps = createSteps(3)
+
+ // Act
+ renderStepper({ steps, activeIndex: 2 })
+
+ // Assert - Completed steps show just the number
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ expect(screen.getByText('STEP 3')).toBeInTheDocument()
+ })
+
+ it('should show disabled steps with number only (no STEP prefix)', () => {
+ // Arrange
+ const steps = createSteps(3)
+
+ // Act
+ renderStepper({ steps, activeIndex: 0 })
+
+ // Assert - Disabled steps show just the number
+ expect(screen.getByText('STEP 1')).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ expect(screen.getByText('3')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Edge Cases - Test boundary conditions and unexpected inputs
+ // --------------------------------------------------------------------------
+ describe('Edge Cases', () => {
+ it('should handle empty steps array', () => {
+ // Arrange & Act
+ const { container } = renderStepper({ steps: [] })
+
+ // Assert - Container should render but be empty
+ expect(container.firstChild).toBeInTheDocument()
+ expect(container.firstChild?.childNodes.length).toBe(0)
+ })
+
+ it('should handle activeIndex greater than steps length', () => {
+ // Arrange
+ const steps = createSteps(2)
+
+ // Act - activeIndex 5 is beyond array bounds
+ renderStepper({ steps, activeIndex: 5 })
+
+ // Assert - All steps should render as completed (since activeIndex > all indices)
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ })
+
+ it('should handle negative activeIndex', () => {
+ // Arrange
+ const steps = createSteps(2)
+
+ // Act - negative activeIndex
+ renderStepper({ steps, activeIndex: -1 })
+
+ // Assert - All steps should render as disabled (since activeIndex < all indices)
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ })
+
+ it('should handle large number of steps', () => {
+ // Arrange
+ const steps = createSteps(10)
+
+ // Act
+ const { container } = renderStepper({ steps, activeIndex: 5 })
+
+ // Assert
+ expect(screen.getByText('STEP 6')).toBeInTheDocument()
+ // Should have 9 dividers for 10 steps
+ const dividers = container.querySelectorAll('.bg-divider-deep')
+ expect(dividers.length).toBe(9)
+ })
+
+ it('should handle steps with empty name', () => {
+ // Arrange
+ const steps = [createStep({ name: '' })]
+
+ // Act
+ const { container } = renderStepper({ steps, activeIndex: 0 })
+
+ // Assert - Should still render the step structure
+ expect(screen.getByText('STEP 1')).toBeInTheDocument()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Integration - Test step state combinations
+ // --------------------------------------------------------------------------
+ describe('Step States', () => {
+ it('should render mixed states: completed, active, disabled', () => {
+ // Arrange
+ const steps = createSteps(5)
+
+ // Act
+ renderStepper({ steps, activeIndex: 2 })
+
+ // Assert
+ // Steps 1-2 are completed (show number only)
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ // Step 3 is active (shows STEP prefix)
+ expect(screen.getByText('STEP 3')).toBeInTheDocument()
+ // Steps 4-5 are disabled (show number only)
+ expect(screen.getByText('4')).toBeInTheDocument()
+ expect(screen.getByText('5')).toBeInTheDocument()
+ })
+
+ it('should transition through all states correctly', () => {
+ // Arrange
+ const steps = createSteps(3)
+
+ // Act & Assert - Step 1 active
+ const { rerender } = render( )
+ expect(screen.getByText('STEP 1')).toBeInTheDocument()
+
+ // Step 2 active
+ rerender( )
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('STEP 2')).toBeInTheDocument()
+
+ // Step 3 active
+ rerender( )
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ expect(screen.getByText('STEP 3')).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// StepperStep Component Tests
+// ============================================================================
+describe('StepperStep', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // --------------------------------------------------------------------------
+ // Rendering Tests
+ // --------------------------------------------------------------------------
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ renderStepperStep()
+
+ // Assert
+ expect(screen.getByText('Test Step')).toBeInTheDocument()
+ })
+
+ it('should render step name', () => {
+ // Arrange & Act
+ renderStepperStep({ name: 'Configure Dataset' })
+
+ // Assert
+ expect(screen.getByText('Configure Dataset')).toBeInTheDocument()
+ })
+
+ it('should render with flex container layout', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('flex', 'items-center', 'gap-2')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Active State Tests
+ // --------------------------------------------------------------------------
+ describe('Active State', () => {
+ it('should show STEP prefix when active', () => {
+ // Arrange & Act
+ renderStepperStep({ index: 0, activeIndex: 0 })
+
+ // Assert
+ expect(screen.getByText('STEP 1')).toBeInTheDocument()
+ })
+
+ it('should apply active styles to label container', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep({ index: 0, activeIndex: 0 })
+
+ // Assert
+ const labelContainer = container.querySelector('.bg-state-accent-solid')
+ expect(labelContainer).toBeInTheDocument()
+ expect(labelContainer).toHaveClass('px-2')
+ })
+
+ it('should apply active text color to label', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep({ index: 0, activeIndex: 0 })
+
+ // Assert
+ const label = container.querySelector('.text-text-primary-on-surface')
+ expect(label).toBeInTheDocument()
+ })
+
+ it('should apply accent text color to name when active', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep({ index: 0, activeIndex: 0 })
+
+ // Assert
+ const nameElement = container.querySelector('.text-text-accent')
+ expect(nameElement).toBeInTheDocument()
+ expect(nameElement).toHaveClass('system-xs-semibold-uppercase')
+ })
+
+ it('should calculate active correctly for different indices', () => {
+ // Test index 1 with activeIndex 1
+ const { rerender } = render(
+ ,
+ )
+ expect(screen.getByText('STEP 2')).toBeInTheDocument()
+
+ // Test index 5 with activeIndex 5
+ rerender( )
+ expect(screen.getByText('STEP 6')).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Completed State Tests (index < activeIndex)
+ // --------------------------------------------------------------------------
+ describe('Completed State', () => {
+ it('should show number only when completed (not active)', () => {
+ // Arrange & Act
+ renderStepperStep({ index: 0, activeIndex: 1 })
+
+ // Assert
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.queryByText('STEP 1')).not.toBeInTheDocument()
+ })
+
+ it('should apply completed styles to label container', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep({ index: 0, activeIndex: 1 })
+
+ // Assert
+ const labelContainer = container.querySelector('.border-text-quaternary')
+ expect(labelContainer).toBeInTheDocument()
+ expect(labelContainer).toHaveClass('w-5')
+ })
+
+ it('should apply tertiary text color to label when completed', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep({ index: 0, activeIndex: 1 })
+
+ // Assert
+ const label = container.querySelector('.text-text-tertiary')
+ expect(label).toBeInTheDocument()
+ })
+
+ it('should apply tertiary text color to name when completed', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep({ index: 0, activeIndex: 2 })
+
+ // Assert
+ const nameElements = container.querySelectorAll('.text-text-tertiary')
+ expect(nameElements.length).toBeGreaterThan(0)
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Disabled State Tests (index > activeIndex)
+ // --------------------------------------------------------------------------
+ describe('Disabled State', () => {
+ it('should show number only when disabled', () => {
+ // Arrange & Act
+ renderStepperStep({ index: 2, activeIndex: 0 })
+
+ // Assert
+ expect(screen.getByText('3')).toBeInTheDocument()
+ expect(screen.queryByText('STEP 3')).not.toBeInTheDocument()
+ })
+
+ it('should apply disabled styles to label container', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep({ index: 2, activeIndex: 0 })
+
+ // Assert
+ const labelContainer = container.querySelector('.border-divider-deep')
+ expect(labelContainer).toBeInTheDocument()
+ expect(labelContainer).toHaveClass('w-5')
+ })
+
+ it('should apply quaternary text color to label when disabled', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep({ index: 2, activeIndex: 0 })
+
+ // Assert
+ const label = container.querySelector('.text-text-quaternary')
+ expect(label).toBeInTheDocument()
+ })
+
+ it('should apply quaternary text color to name when disabled', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep({ index: 2, activeIndex: 0 })
+
+ // Assert
+ const nameElements = container.querySelectorAll('.text-text-quaternary')
+ expect(nameElements.length).toBeGreaterThan(0)
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Props Testing
+ // --------------------------------------------------------------------------
+ describe('Props', () => {
+ describe('name prop', () => {
+ it('should render provided name', () => {
+ // Arrange & Act
+ renderStepperStep({ name: 'Custom Name' })
+
+ // Assert
+ expect(screen.getByText('Custom Name')).toBeInTheDocument()
+ })
+
+ it('should handle empty name', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep({ name: '' })
+
+ // Assert - Label should still render
+ expect(screen.getByText('STEP 1')).toBeInTheDocument()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle name with whitespace', () => {
+ // Arrange & Act
+ renderStepperStep({ name: ' Padded Name ' })
+
+ // Assert
+ expect(screen.getByText('Padded Name')).toBeInTheDocument()
+ })
+ })
+
+ describe('index prop', () => {
+ it('should display correct 1-based number for index 0', () => {
+ // Arrange & Act
+ renderStepperStep({ index: 0, activeIndex: 0 })
+
+ // Assert
+ expect(screen.getByText('STEP 1')).toBeInTheDocument()
+ })
+
+ it('should display correct 1-based number for index 9', () => {
+ // Arrange & Act
+ renderStepperStep({ index: 9, activeIndex: 9 })
+
+ // Assert
+ expect(screen.getByText('STEP 10')).toBeInTheDocument()
+ })
+
+ it('should handle large index values', () => {
+ // Arrange & Act
+ renderStepperStep({ index: 99, activeIndex: 99 })
+
+ // Assert
+ expect(screen.getByText('STEP 100')).toBeInTheDocument()
+ })
+ })
+
+ describe('activeIndex prop', () => {
+ it('should determine state based on activeIndex comparison', () => {
+ // Active: index === activeIndex
+ const { rerender } = render(
+ ,
+ )
+ expect(screen.getByText('STEP 2')).toBeInTheDocument()
+
+ // Completed: index < activeIndex
+ rerender( )
+ expect(screen.getByText('2')).toBeInTheDocument()
+
+ // Disabled: index > activeIndex
+ rerender( )
+ expect(screen.getByText('2')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Edge Cases
+ // --------------------------------------------------------------------------
+ describe('Edge Cases', () => {
+ it('should handle zero index correctly', () => {
+ // Arrange & Act
+ renderStepperStep({ index: 0, activeIndex: 0 })
+
+ // Assert
+ expect(screen.getByText('STEP 1')).toBeInTheDocument()
+ })
+
+ it('should handle negative activeIndex', () => {
+ // Arrange & Act
+ renderStepperStep({ index: 0, activeIndex: -1 })
+
+ // Assert - Step should be disabled (index > activeIndex)
+ expect(screen.getByText('1')).toBeInTheDocument()
+ })
+
+ it('should handle equal boundary (index equals activeIndex)', () => {
+ // Arrange & Act
+ renderStepperStep({ index: 5, activeIndex: 5 })
+
+ // Assert - Should be active
+ expect(screen.getByText('STEP 6')).toBeInTheDocument()
+ })
+
+ it('should handle name with HTML-like content safely', () => {
+ // Arrange & Act
+ renderStepperStep({ name: '' })
+
+ // Assert - Should render as text, not execute
+ expect(screen.getByText('')).toBeInTheDocument()
+ })
+
+ it('should handle name with unicode characters', () => {
+ // Arrange & Act
+ renderStepperStep({ name: 'Step 数据 🚀' })
+
+ // Assert
+ expect(screen.getByText('Step 数据 🚀')).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Style Classes Verification
+ // --------------------------------------------------------------------------
+ describe('Style Classes', () => {
+ it('should apply correct typography classes to label', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep()
+
+ // Assert
+ const label = container.querySelector('.system-2xs-semibold-uppercase')
+ expect(label).toBeInTheDocument()
+ })
+
+ it('should apply correct typography classes to name', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep()
+
+ // Assert
+ const name = container.querySelector('.system-xs-medium-uppercase')
+ expect(name).toBeInTheDocument()
+ })
+
+ it('should have rounded pill shape for label container', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep()
+
+ // Assert
+ const labelContainer = container.querySelector('.rounded-3xl')
+ expect(labelContainer).toBeInTheDocument()
+ })
+
+ it('should apply h-5 height to label container', () => {
+ // Arrange & Act
+ const { container } = renderStepperStep()
+
+ // Assert
+ const labelContainer = container.querySelector('.h-5')
+ expect(labelContainer).toBeInTheDocument()
+ })
+ })
+})
+
+// ============================================================================
+// Integration Tests - Stepper and StepperStep working together
+// ============================================================================
+describe('Stepper Integration', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should pass correct props to each StepperStep', () => {
+ // Arrange
+ const steps = [
+ createStep({ name: 'First' }),
+ createStep({ name: 'Second' }),
+ createStep({ name: 'Third' }),
+ ]
+
+ // Act
+ renderStepper({ steps, activeIndex: 1 })
+
+ // Assert - Each step receives correct index and displays correctly
+ expect(screen.getByText('1')).toBeInTheDocument() // Completed
+ expect(screen.getByText('First')).toBeInTheDocument()
+ expect(screen.getByText('STEP 2')).toBeInTheDocument() // Active
+ expect(screen.getByText('Second')).toBeInTheDocument()
+ expect(screen.getByText('3')).toBeInTheDocument() // Disabled
+ expect(screen.getByText('Third')).toBeInTheDocument()
+ })
+
+ it('should maintain correct visual hierarchy across steps', () => {
+ // Arrange
+ const steps = createSteps(4)
+
+ // Act
+ const { container } = renderStepper({ steps, activeIndex: 2 })
+
+ // Assert - Check visual hierarchy
+ // Completed steps (0, 1) have border-text-quaternary
+ const completedLabels = container.querySelectorAll('.border-text-quaternary')
+ expect(completedLabels.length).toBe(2)
+
+ // Active step has bg-state-accent-solid
+ const activeLabel = container.querySelector('.bg-state-accent-solid')
+ expect(activeLabel).toBeInTheDocument()
+
+ // Disabled step (3) has border-divider-deep
+ const disabledLabels = container.querySelectorAll('.border-divider-deep')
+ expect(disabledLabels.length).toBe(1)
+ })
+
+ it('should render correctly with dynamic step updates', () => {
+ // Arrange
+ const initialSteps = createSteps(2)
+
+ // Act
+ const { rerender } = render( )
+ expect(screen.getByText('Step 1')).toBeInTheDocument()
+ expect(screen.getByText('Step 2')).toBeInTheDocument()
+
+ // Update with more steps
+ const updatedSteps = createSteps(4)
+ rerender( )
+
+ // Assert
+ expect(screen.getByText('STEP 3')).toBeInTheDocument()
+ expect(screen.getByText('Step 4')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/datasets/create/stepper/step.tsx b/web/app/components/datasets/create/stepper/step.tsx
index f135e4f007..8169103348 100644
--- a/web/app/components/datasets/create/stepper/step.tsx
+++ b/web/app/components/datasets/create/stepper/step.tsx
@@ -1,5 +1,5 @@
import type { FC } from 'react'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type Step = {
name: string
@@ -16,31 +16,26 @@ export const StepperStep: FC = (props) => {
const isDisabled = activeIndex < index
const label = isActive ? `STEP ${index + 1}` : `${index + 1}`
return
-
-
+
+ : 'text-text-quaternary')}>
{label}
-
{name}
+ : 'text-text-quaternary')}>{name}
}
diff --git a/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx b/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx
new file mode 100644
index 0000000000..244f65ffb0
--- /dev/null
+++ b/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx
@@ -0,0 +1,738 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import StopEmbeddingModal from './index'
+
+// Helper type for component props
+type StopEmbeddingModalProps = {
+ show: boolean
+ onConfirm: () => void
+ onHide: () => void
+}
+
+// Helper to render StopEmbeddingModal with default props
+const renderStopEmbeddingModal = (props: Partial = {}) => {
+ const defaultProps: StopEmbeddingModalProps = {
+ show: true,
+ onConfirm: jest.fn(),
+ onHide: jest.fn(),
+ ...props,
+ }
+ return {
+ ...render( ),
+ props: defaultProps,
+ }
+}
+
+// ============================================================================
+// StopEmbeddingModal Component Tests
+// ============================================================================
+describe('StopEmbeddingModal', () => {
+ // Suppress Headless UI warnings in tests
+ // These warnings are from the library's internal behavior, not our code
+ let consoleWarnSpy: jest.SpyInstance
+ let consoleErrorSpy: jest.SpyInstance
+
+ beforeAll(() => {
+ consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(jest.fn())
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
+ })
+
+ afterAll(() => {
+ consoleWarnSpy.mockRestore()
+ consoleErrorSpy.mockRestore()
+ })
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // --------------------------------------------------------------------------
+ // Rendering Tests - Verify component renders properly
+ // --------------------------------------------------------------------------
+ describe('Rendering', () => {
+ it('should render without crashing when show is true', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+ })
+
+ it('should render modal title', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+ })
+
+ it('should render modal content', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
+ })
+
+ it('should render confirm button with correct text', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument()
+ })
+
+ it('should render cancel button with correct text', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument()
+ })
+
+ it('should not render modal content when show is false', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: false })
+
+ // Assert
+ expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
+ })
+
+ it('should render buttons in correct order (cancel first, then confirm)', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert - Due to flex-row-reverse, confirm appears first visually but cancel is first in DOM
+ const buttons = screen.getAllByRole('button')
+ expect(buttons).toHaveLength(2)
+ })
+
+ it('should render confirm button with primary variant styling', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ expect(confirmButton).toHaveClass('ml-2', 'w-24')
+ })
+
+ it('should render cancel button with default styling', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
+ expect(cancelButton).toHaveClass('w-24')
+ })
+
+ it('should render all modal elements', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert - Modal should contain title, content, and buttons
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Props Testing - Test all prop variations
+ // --------------------------------------------------------------------------
+ describe('Props', () => {
+ describe('show prop', () => {
+ it('should show modal when show is true', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+ })
+
+ it('should hide modal when show is false', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: false })
+
+ // Assert
+ expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
+ })
+
+ it('should use default value false when show is not provided', () => {
+ // Arrange & Act
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ render( )
+
+ // Assert
+ expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
+ })
+
+ it('should toggle visibility when show prop changes to true', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+
+ // Act - Initially hidden
+ const { rerender } = render(
+ ,
+ )
+ expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
+
+ // Act - Show modal
+ await act(async () => {
+ rerender( )
+ })
+
+ // Assert - Modal should be visible
+ await waitFor(() => {
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('onConfirm prop', () => {
+ it('should accept onConfirm callback function', () => {
+ // Arrange
+ const onConfirm = jest.fn()
+
+ // Act
+ renderStopEmbeddingModal({ onConfirm })
+
+ // Assert - No errors thrown
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+ })
+ })
+
+ describe('onHide prop', () => {
+ it('should accept onHide callback function', () => {
+ // Arrange
+ const onHide = jest.fn()
+
+ // Act
+ renderStopEmbeddingModal({ onHide })
+
+ // Assert - No errors thrown
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // User Interactions Tests - Test click events and event handlers
+ // --------------------------------------------------------------------------
+ describe('User Interactions', () => {
+ describe('Confirm Button', () => {
+ it('should call onConfirm when confirm button is clicked', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ await act(async () => {
+ fireEvent.click(confirmButton)
+ })
+
+ // Assert
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onHide when confirm button is clicked', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ await act(async () => {
+ fireEvent.click(confirmButton)
+ })
+
+ // Assert
+ expect(onHide).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call both onConfirm and onHide in correct order when confirm button is clicked', async () => {
+ // Arrange
+ const callOrder: string[] = []
+ const onConfirm = jest.fn(() => callOrder.push('confirm'))
+ const onHide = jest.fn(() => callOrder.push('hide'))
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ await act(async () => {
+ fireEvent.click(confirmButton)
+ })
+
+ // Assert - onConfirm should be called before onHide
+ expect(callOrder).toEqual(['confirm', 'hide'])
+ })
+
+ it('should handle multiple clicks on confirm button', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ await act(async () => {
+ fireEvent.click(confirmButton)
+ fireEvent.click(confirmButton)
+ fireEvent.click(confirmButton)
+ })
+
+ // Assert
+ expect(onConfirm).toHaveBeenCalledTimes(3)
+ expect(onHide).toHaveBeenCalledTimes(3)
+ })
+ })
+
+ describe('Cancel Button', () => {
+ it('should call onHide when cancel button is clicked', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
+ await act(async () => {
+ fireEvent.click(cancelButton)
+ })
+
+ // Assert
+ expect(onHide).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not call onConfirm when cancel button is clicked', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
+ await act(async () => {
+ fireEvent.click(cancelButton)
+ })
+
+ // Assert
+ expect(onConfirm).not.toHaveBeenCalled()
+ })
+
+ it('should handle multiple clicks on cancel button', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
+ await act(async () => {
+ fireEvent.click(cancelButton)
+ fireEvent.click(cancelButton)
+ })
+
+ // Assert
+ expect(onHide).toHaveBeenCalledTimes(2)
+ expect(onConfirm).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Close Icon', () => {
+ it('should call onHide when close span is clicked', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ const { container } = renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act - Find the close span (it should be the span with onClick handler)
+ const spans = container.querySelectorAll('span')
+ const closeSpan = Array.from(spans).find(span =>
+ span.className && span.getAttribute('class')?.includes('close'),
+ )
+
+ if (closeSpan) {
+ await act(async () => {
+ fireEvent.click(closeSpan)
+ })
+
+ // Assert
+ expect(onHide).toHaveBeenCalledTimes(1)
+ }
+ else {
+ // If no close span found with class, just verify the modal renders
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+ }
+ })
+
+ it('should not call onConfirm when close span is clicked', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ const { container } = renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const spans = container.querySelectorAll('span')
+ const closeSpan = Array.from(spans).find(span =>
+ span.className && span.getAttribute('class')?.includes('close'),
+ )
+
+ if (closeSpan) {
+ await act(async () => {
+ fireEvent.click(closeSpan)
+ })
+
+ // Assert
+ expect(onConfirm).not.toHaveBeenCalled()
+ }
+ })
+ })
+
+ describe('Different Close Methods', () => {
+ it('should distinguish between confirm and cancel actions', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act - Click cancel
+ const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
+ await act(async () => {
+ fireEvent.click(cancelButton)
+ })
+
+ // Assert
+ expect(onConfirm).not.toHaveBeenCalled()
+ expect(onHide).toHaveBeenCalledTimes(1)
+
+ // Reset
+ jest.clearAllMocks()
+
+ // Act - Click confirm
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ await act(async () => {
+ fireEvent.click(confirmButton)
+ })
+
+ // Assert
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ expect(onHide).toHaveBeenCalledTimes(1)
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Edge Cases Tests - Test null, undefined, empty values and boundaries
+ // --------------------------------------------------------------------------
+ describe('Edge Cases', () => {
+ it('should handle rapid confirm button clicks', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act - Rapid clicks
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ await act(async () => {
+ for (let i = 0; i < 10; i++)
+ fireEvent.click(confirmButton)
+ })
+
+ // Assert
+ expect(onConfirm).toHaveBeenCalledTimes(10)
+ expect(onHide).toHaveBeenCalledTimes(10)
+ })
+
+ it('should handle rapid cancel button clicks', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act - Rapid clicks
+ const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
+ await act(async () => {
+ for (let i = 0; i < 10; i++)
+ fireEvent.click(cancelButton)
+ })
+
+ // Assert
+ expect(onHide).toHaveBeenCalledTimes(10)
+ expect(onConfirm).not.toHaveBeenCalled()
+ })
+
+ it('should handle callbacks being replaced', async () => {
+ // Arrange
+ const onConfirm1 = jest.fn()
+ const onHide1 = jest.fn()
+ const onConfirm2 = jest.fn()
+ const onHide2 = jest.fn()
+
+ // Act
+ const { rerender } = render(
+ ,
+ )
+
+ // Replace callbacks
+ await act(async () => {
+ rerender( )
+ })
+
+ // Click confirm with new callbacks
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ await act(async () => {
+ fireEvent.click(confirmButton)
+ })
+
+ // Assert - New callbacks should be called
+ expect(onConfirm1).not.toHaveBeenCalled()
+ expect(onHide1).not.toHaveBeenCalled()
+ expect(onConfirm2).toHaveBeenCalledTimes(1)
+ expect(onHide2).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render with all required props', () => {
+ // Arrange & Act
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Layout and Styling Tests - Verify correct structure
+ // --------------------------------------------------------------------------
+ describe('Layout and Styling', () => {
+ it('should have buttons container with flex-row-reverse', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ const buttons = screen.getAllByRole('button')
+ expect(buttons[0].closest('div')).toHaveClass('flex', 'flex-row-reverse')
+ })
+
+ it('should render title and content elements', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
+ })
+
+ it('should render two buttons', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ const buttons = screen.getAllByRole('button')
+ expect(buttons).toHaveLength(2)
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // submit Function Tests - Test the internal submit function behavior
+ // --------------------------------------------------------------------------
+ describe('submit Function', () => {
+ it('should execute onConfirm first then onHide', async () => {
+ // Arrange
+ let confirmTime = 0
+ let hideTime = 0
+ let counter = 0
+ const onConfirm = jest.fn(() => {
+ confirmTime = ++counter
+ })
+ const onHide = jest.fn(() => {
+ hideTime = ++counter
+ })
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ await act(async () => {
+ fireEvent.click(confirmButton)
+ })
+
+ // Assert
+ expect(confirmTime).toBe(1)
+ expect(hideTime).toBe(2)
+ })
+
+ it('should call both callbacks exactly once per click', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ await act(async () => {
+ fireEvent.click(confirmButton)
+ })
+
+ // Assert
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ expect(onHide).toHaveBeenCalledTimes(1)
+ })
+
+ it('should pass no arguments to onConfirm', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ await act(async () => {
+ fireEvent.click(confirmButton)
+ })
+
+ // Assert
+ expect(onConfirm).toHaveBeenCalledWith()
+ })
+
+ it('should pass no arguments to onHide when called from submit', async () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
+ await act(async () => {
+ fireEvent.click(confirmButton)
+ })
+
+ // Assert
+ expect(onHide).toHaveBeenCalledWith()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Modal Integration Tests - Verify Modal component integration
+ // --------------------------------------------------------------------------
+ describe('Modal Integration', () => {
+ it('should pass show prop to Modal as isShow', async () => {
+ // Arrange & Act
+ const { rerender } = render(
+ ,
+ )
+
+ // Assert - Modal should be visible
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+
+ // Act - Hide modal
+ await act(async () => {
+ rerender( )
+ })
+
+ // Assert - Modal should transition to hidden (wait for transition)
+ await waitFor(() => {
+ expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
+ }, { timeout: 3000 })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Accessibility Tests
+ // --------------------------------------------------------------------------
+ describe('Accessibility', () => {
+ it('should have buttons that are focusable', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ const buttons = screen.getAllByRole('button')
+ buttons.forEach((button) => {
+ expect(button).not.toHaveAttribute('tabindex', '-1')
+ })
+ })
+
+ it('should have semantic button elements', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ const buttons = screen.getAllByRole('button')
+ expect(buttons).toHaveLength(2)
+ })
+
+ it('should have accessible text content', () => {
+ // Arrange & Act
+ renderStopEmbeddingModal({ show: true })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeVisible()
+ expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeVisible()
+ expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeVisible()
+ expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeVisible()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Component Lifecycle Tests
+ // --------------------------------------------------------------------------
+ describe('Component Lifecycle', () => {
+ it('should unmount cleanly', () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act & Assert - Should not throw
+ expect(() => unmount()).not.toThrow()
+ })
+
+ it('should not call callbacks after unmount', () => {
+ // Arrange
+ const onConfirm = jest.fn()
+ const onHide = jest.fn()
+ const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide })
+
+ // Act
+ unmount()
+
+ // Assert - No callbacks should be called after unmount
+ expect(onConfirm).not.toHaveBeenCalled()
+ expect(onHide).not.toHaveBeenCalled()
+ })
+
+ it('should re-render correctly when props update', async () => {
+ // Arrange
+ const onConfirm1 = jest.fn()
+ const onHide1 = jest.fn()
+ const onConfirm2 = jest.fn()
+ const onHide2 = jest.fn()
+
+ // Act - Initial render
+ const { rerender } = render(
+ ,
+ )
+
+ // Verify initial render
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+
+ // Update props
+ await act(async () => {
+ rerender( )
+ })
+
+ // Assert - Still renders correctly
+ expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/stop-embedding-modal/index.tsx b/web/app/components/datasets/create/stop-embedding-modal/index.tsx
index 9d29187dc2..8fadf4d135 100644
--- a/web/app/components/datasets/create/stop-embedding-modal/index.tsx
+++ b/web/app/components/datasets/create/stop-embedding-modal/index.tsx
@@ -2,7 +2,7 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import s from './index.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
diff --git a/web/app/components/datasets/create/top-bar/index.spec.tsx b/web/app/components/datasets/create/top-bar/index.spec.tsx
new file mode 100644
index 0000000000..92fb97c839
--- /dev/null
+++ b/web/app/components/datasets/create/top-bar/index.spec.tsx
@@ -0,0 +1,539 @@
+import { render, screen } from '@testing-library/react'
+import { TopBar, type TopBarProps } from './index'
+
+// Mock next/link to capture href values
+jest.mock('next/link', () => {
+ return ({ children, href, replace, className }: { children: React.ReactNode; href: string; replace?: boolean; className?: string }) => (
+
+ {children}
+
+ )
+})
+
+// Helper to render TopBar with default props
+const renderTopBar = (props: Partial = {}) => {
+ const defaultProps: TopBarProps = {
+ activeIndex: 0,
+ ...props,
+ }
+ return {
+ ...render( ),
+ props: defaultProps,
+ }
+}
+
+// ============================================================================
+// TopBar Component Tests
+// ============================================================================
+describe('TopBar', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // --------------------------------------------------------------------------
+ // Rendering Tests - Verify component renders properly
+ // --------------------------------------------------------------------------
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange & Act
+ renderTopBar()
+
+ // Assert
+ expect(screen.getByTestId('back-link')).toBeInTheDocument()
+ })
+
+ it('should render back link with arrow icon', () => {
+ // Arrange & Act
+ const { container } = renderTopBar()
+
+ // Assert
+ const backLink = screen.getByTestId('back-link')
+ expect(backLink).toBeInTheDocument()
+ // Check for the arrow icon (svg element)
+ const arrowIcon = container.querySelector('svg')
+ expect(arrowIcon).toBeInTheDocument()
+ })
+
+ it('should render fallback route text', () => {
+ // Arrange & Act
+ renderTopBar()
+
+ // Assert
+ expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument()
+ })
+
+ it('should render Stepper component with 3 steps', () => {
+ // Arrange & Act
+ renderTopBar({ activeIndex: 0 })
+
+ // Assert - Check for step translations
+ expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
+ })
+
+ it('should apply default container classes', () => {
+ // Arrange & Act
+ const { container } = renderTopBar()
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('relative')
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('h-[52px]')
+ expect(wrapper).toHaveClass('shrink-0')
+ expect(wrapper).toHaveClass('items-center')
+ expect(wrapper).toHaveClass('justify-between')
+ expect(wrapper).toHaveClass('border-b')
+ expect(wrapper).toHaveClass('border-b-divider-subtle')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Props Testing - Test all prop variations
+ // --------------------------------------------------------------------------
+ describe('Props', () => {
+ describe('className prop', () => {
+ it('should apply custom className when provided', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({ className: 'custom-class' })
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-class')
+ })
+
+ it('should merge custom className with default classes', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({ className: 'my-custom-class another-class' })
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('relative')
+ expect(wrapper).toHaveClass('flex')
+ expect(wrapper).toHaveClass('my-custom-class')
+ expect(wrapper).toHaveClass('another-class')
+ })
+
+ it('should render correctly without className', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({ className: undefined })
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('relative')
+ expect(wrapper).toHaveClass('flex')
+ })
+
+ it('should handle empty string className', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({ className: '' })
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('relative')
+ })
+ })
+
+ describe('datasetId prop', () => {
+ it('should set fallback route to /datasets when datasetId is undefined', () => {
+ // Arrange & Act
+ renderTopBar({ datasetId: undefined })
+
+ // Assert
+ const backLink = screen.getByTestId('back-link')
+ expect(backLink).toHaveAttribute('href', '/datasets')
+ })
+
+ it('should set fallback route to /datasets/:id/documents when datasetId is provided', () => {
+ // Arrange & Act
+ renderTopBar({ datasetId: 'dataset-123' })
+
+ // Assert
+ const backLink = screen.getByTestId('back-link')
+ expect(backLink).toHaveAttribute('href', '/datasets/dataset-123/documents')
+ })
+
+ it('should handle various datasetId formats', () => {
+ // Arrange & Act
+ renderTopBar({ datasetId: 'abc-def-ghi-123' })
+
+ // Assert
+ const backLink = screen.getByTestId('back-link')
+ expect(backLink).toHaveAttribute('href', '/datasets/abc-def-ghi-123/documents')
+ })
+
+ it('should handle empty string datasetId', () => {
+ // Arrange & Act
+ renderTopBar({ datasetId: '' })
+
+ // Assert - Empty string is falsy, so fallback to /datasets
+ const backLink = screen.getByTestId('back-link')
+ expect(backLink).toHaveAttribute('href', '/datasets')
+ })
+ })
+
+ describe('activeIndex prop', () => {
+ it('should pass activeIndex to Stepper component (index 0)', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({ activeIndex: 0 })
+
+ // Assert - First step should be active (has specific styling)
+ const steps = container.querySelectorAll('[class*="system-2xs-semibold-uppercase"]')
+ expect(steps.length).toBeGreaterThan(0)
+ })
+
+ it('should pass activeIndex to Stepper component (index 1)', () => {
+ // Arrange & Act
+ renderTopBar({ activeIndex: 1 })
+
+ // Assert - Stepper is rendered with correct props
+ expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
+ })
+
+ it('should pass activeIndex to Stepper component (index 2)', () => {
+ // Arrange & Act
+ renderTopBar({ activeIndex: 2 })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
+ })
+
+ it('should handle edge case activeIndex of -1', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({ activeIndex: -1 })
+
+ // Assert - Component should render without crashing
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should handle edge case activeIndex beyond steps length', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({ activeIndex: 10 })
+
+ // Assert - Component should render without crashing
+ expect(container.firstChild).toBeInTheDocument()
+ })
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Memoization Tests - Test useMemo logic and dependencies
+ // --------------------------------------------------------------------------
+ describe('Memoization Logic', () => {
+ it('should compute fallbackRoute based on datasetId', () => {
+ // Arrange & Act - With datasetId
+ const { rerender } = render( )
+
+ // Assert
+ expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/test-id/documents')
+
+ // Act - Rerender with different datasetId
+ rerender( )
+
+ // Assert - Route should update
+ expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-id/documents')
+ })
+
+ it('should update fallbackRoute when datasetId changes from undefined to defined', () => {
+ // Arrange
+ const { rerender } = render( )
+ expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets')
+
+ // Act
+ rerender( )
+
+ // Assert
+ expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-dataset/documents')
+ })
+
+ it('should update fallbackRoute when datasetId changes from defined to undefined', () => {
+ // Arrange
+ const { rerender } = render( )
+ expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/existing-id/documents')
+
+ // Act
+ rerender( )
+
+ // Assert
+ expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets')
+ })
+
+ it('should not change fallbackRoute when activeIndex changes but datasetId stays same', () => {
+ // Arrange
+ const { rerender } = render( )
+ const initialHref = screen.getByTestId('back-link').getAttribute('href')
+
+ // Act
+ rerender( )
+
+ // Assert - href should remain the same
+ expect(screen.getByTestId('back-link')).toHaveAttribute('href', initialHref)
+ })
+
+ it('should not change fallbackRoute when className changes but datasetId stays same', () => {
+ // Arrange
+ const { rerender } = render( )
+ const initialHref = screen.getByTestId('back-link').getAttribute('href')
+
+ // Act
+ rerender( )
+
+ // Assert - href should remain the same
+ expect(screen.getByTestId('back-link')).toHaveAttribute('href', initialHref)
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Link Component Tests
+ // --------------------------------------------------------------------------
+ describe('Link Component', () => {
+ it('should render Link with replace prop', () => {
+ // Arrange & Act
+ renderTopBar()
+
+ // Assert
+ const backLink = screen.getByTestId('back-link')
+ expect(backLink).toHaveAttribute('data-replace', 'true')
+ })
+
+ it('should render Link with correct classes', () => {
+ // Arrange & Act
+ renderTopBar()
+
+ // Assert
+ const backLink = screen.getByTestId('back-link')
+ expect(backLink).toHaveClass('inline-flex')
+ expect(backLink).toHaveClass('h-12')
+ expect(backLink).toHaveClass('items-center')
+ expect(backLink).toHaveClass('justify-start')
+ expect(backLink).toHaveClass('gap-1')
+ expect(backLink).toHaveClass('py-2')
+ expect(backLink).toHaveClass('pl-2')
+ expect(backLink).toHaveClass('pr-6')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // STEP_T_MAP Tests - Verify step translations
+ // --------------------------------------------------------------------------
+ describe('STEP_T_MAP Translations', () => {
+ it('should render step one translation', () => {
+ // Arrange & Act
+ renderTopBar({ activeIndex: 0 })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
+ })
+
+ it('should render step two translation', () => {
+ // Arrange & Act
+ renderTopBar({ activeIndex: 1 })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
+ })
+
+ it('should render step three translation', () => {
+ // Arrange & Act
+ renderTopBar({ activeIndex: 2 })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
+ })
+
+ it('should render all three step translations', () => {
+ // Arrange & Act
+ renderTopBar({ activeIndex: 0 })
+
+ // Assert
+ expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
+ expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Edge Cases and Error Handling Tests
+ // --------------------------------------------------------------------------
+ describe('Edge Cases', () => {
+ it('should handle special characters in datasetId', () => {
+ // Arrange & Act
+ renderTopBar({ datasetId: 'dataset-with-special_chars.123' })
+
+ // Assert
+ const backLink = screen.getByTestId('back-link')
+ expect(backLink).toHaveAttribute('href', '/datasets/dataset-with-special_chars.123/documents')
+ })
+
+ it('should handle very long datasetId', () => {
+ // Arrange
+ const longId = 'a'.repeat(100)
+
+ // Act
+ renderTopBar({ datasetId: longId })
+
+ // Assert
+ const backLink = screen.getByTestId('back-link')
+ expect(backLink).toHaveAttribute('href', `/datasets/${longId}/documents`)
+ })
+
+ it('should handle UUID format datasetId', () => {
+ // Arrange
+ const uuid = '550e8400-e29b-41d4-a716-446655440000'
+
+ // Act
+ renderTopBar({ datasetId: uuid })
+
+ // Assert
+ const backLink = screen.getByTestId('back-link')
+ expect(backLink).toHaveAttribute('href', `/datasets/${uuid}/documents`)
+ })
+
+ it('should handle whitespace in className', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({ className: ' spaced-class ' })
+
+ // Assert - classNames utility handles whitespace
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toBeInTheDocument()
+ })
+
+ it('should render correctly with all props provided', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({
+ className: 'custom-class',
+ datasetId: 'full-props-id',
+ activeIndex: 2,
+ })
+
+ // Assert
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('custom-class')
+ expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/full-props-id/documents')
+ })
+
+ it('should render correctly with minimal props (only activeIndex)', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({ activeIndex: 0 })
+
+ // Assert
+ expect(container.firstChild).toBeInTheDocument()
+ expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Stepper Integration Tests
+ // --------------------------------------------------------------------------
+ describe('Stepper Integration', () => {
+ it('should pass steps array with correct structure to Stepper', () => {
+ // Arrange & Act
+ renderTopBar({ activeIndex: 0 })
+
+ // Assert - All step names should be rendered
+ const stepOne = screen.getByText('datasetCreation.steps.one')
+ const stepTwo = screen.getByText('datasetCreation.steps.two')
+ const stepThree = screen.getByText('datasetCreation.steps.three')
+
+ expect(stepOne).toBeInTheDocument()
+ expect(stepTwo).toBeInTheDocument()
+ expect(stepThree).toBeInTheDocument()
+ })
+
+ it('should render Stepper in centered position', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({ activeIndex: 0 })
+
+ // Assert - Check for centered positioning classes
+ const centeredContainer = container.querySelector('.absolute.left-1\\/2.top-1\\/2.-translate-x-1\\/2.-translate-y-1\\/2')
+ expect(centeredContainer).toBeInTheDocument()
+ })
+
+ it('should render step dividers between steps', () => {
+ // Arrange & Act
+ const { container } = renderTopBar({ activeIndex: 0 })
+
+ // Assert - Check for dividers (h-px w-4 bg-divider-deep)
+ const dividers = container.querySelectorAll('.h-px.w-4.bg-divider-deep')
+ expect(dividers.length).toBe(2) // 2 dividers between 3 steps
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Accessibility Tests
+ // --------------------------------------------------------------------------
+ describe('Accessibility', () => {
+ it('should have accessible back link', () => {
+ // Arrange & Act
+ renderTopBar()
+
+ // Assert
+ const backLink = screen.getByTestId('back-link')
+ expect(backLink).toBeInTheDocument()
+ // Link should have visible text
+ expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument()
+ })
+
+ it('should have visible arrow icon in back link', () => {
+ // Arrange & Act
+ const { container } = renderTopBar()
+
+ // Assert - Arrow icon should be visible
+ const arrowIcon = container.querySelector('svg')
+ expect(arrowIcon).toBeInTheDocument()
+ expect(arrowIcon).toHaveClass('text-text-primary')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Re-render Tests
+ // --------------------------------------------------------------------------
+ describe('Re-render Behavior', () => {
+ it('should update activeIndex on re-render', () => {
+ // Arrange
+ const { rerender, container } = render( )
+
+ // Initial check
+ expect(container.firstChild).toBeInTheDocument()
+
+ // Act - Update activeIndex
+ rerender( )
+
+ // Assert - Component should still render
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should update className on re-render', () => {
+ // Arrange
+ const { rerender, container } = render( )
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('initial-class')
+
+ // Act
+ rerender( )
+
+ // Assert
+ expect(wrapper).toHaveClass('updated-class')
+ expect(wrapper).not.toHaveClass('initial-class')
+ })
+
+ it('should handle multiple rapid re-renders', () => {
+ // Arrange
+ const { rerender, container } = render( )
+
+ // Act - Multiple rapid re-renders
+ rerender( )
+ rerender( )
+ rerender( )
+ rerender( )
+
+ // Assert - Component should be stable
+ expect(container.firstChild).toBeInTheDocument()
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper).toHaveClass('new-class')
+ expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/another-id/documents')
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/top-bar/index.tsx b/web/app/components/datasets/create/top-bar/index.tsx
index e54577f46b..6d3064c350 100644
--- a/web/app/components/datasets/create/top-bar/index.tsx
+++ b/web/app/components/datasets/create/top-bar/index.tsx
@@ -3,7 +3,7 @@ import { RiArrowLeftLine } from '@remixicon/react'
import Link from 'next/link'
import { useTranslation } from 'react-i18next'
import { Stepper, type StepperProps } from '../stepper'
-import classNames from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
export type TopBarProps = Pick & {
className?: string
@@ -24,7 +24,7 @@ export const TopBar: FC = (props) => {
return datasetId ? `/datasets/${datasetId}/documents` : '/datasets'
}, [datasetId])
- return
+ return
diff --git a/web/app/components/datasets/create/website/base.spec.tsx b/web/app/components/datasets/create/website/base.spec.tsx
new file mode 100644
index 0000000000..426fc259ea
--- /dev/null
+++ b/web/app/components/datasets/create/website/base.spec.tsx
@@ -0,0 +1,555 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import Input from './base/input'
+import Header from './base/header'
+import CrawledResult from './base/crawled-result'
+import CrawledResultItem from './base/crawled-result-item'
+import type { CrawlResultItem } from '@/models/datasets'
+
+// ============================================================================
+// Test Data Factories
+// ============================================================================
+
+const createCrawlResultItem = (overrides: Partial
= {}): CrawlResultItem => ({
+ title: 'Test Page Title',
+ markdown: '# Test Content',
+ description: 'Test description',
+ source_url: 'https://example.com/page',
+ ...overrides,
+})
+
+// ============================================================================
+// Input Component Tests
+// ============================================================================
+
+describe('Input', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ const createInputProps = (overrides: Partial[0]> = {}) => ({
+ value: '',
+ onChange: jest.fn(),
+ ...overrides,
+ })
+
+ describe('Rendering', () => {
+ it('should render text input by default', () => {
+ const props = createInputProps()
+ render( )
+
+ const input = screen.getByRole('textbox')
+ expect(input).toBeInTheDocument()
+ expect(input).toHaveAttribute('type', 'text')
+ })
+
+ it('should render number input when isNumber is true', () => {
+ const props = createInputProps({ isNumber: true, value: 0 })
+ render( )
+
+ const input = screen.getByRole('spinbutton')
+ expect(input).toBeInTheDocument()
+ expect(input).toHaveAttribute('type', 'number')
+ expect(input).toHaveAttribute('min', '0')
+ })
+
+ it('should render with placeholder', () => {
+ const props = createInputProps({ placeholder: 'Enter URL' })
+ render( )
+
+ expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument()
+ })
+
+ it('should render with initial value', () => {
+ const props = createInputProps({ value: 'test value' })
+ render( )
+
+ expect(screen.getByDisplayValue('test value')).toBeInTheDocument()
+ })
+ })
+
+ describe('Text Input Behavior', () => {
+ it('should call onChange with string value for text input', async () => {
+ const onChange = jest.fn()
+ const props = createInputProps({ onChange })
+
+ render( )
+ const input = screen.getByRole('textbox')
+
+ await userEvent.type(input, 'hello')
+
+ expect(onChange).toHaveBeenCalledWith('h')
+ expect(onChange).toHaveBeenCalledWith('e')
+ expect(onChange).toHaveBeenCalledWith('l')
+ expect(onChange).toHaveBeenCalledWith('l')
+ expect(onChange).toHaveBeenCalledWith('o')
+ })
+ })
+
+ describe('Number Input Behavior', () => {
+ it('should call onChange with parsed integer for number input', () => {
+ const onChange = jest.fn()
+ const props = createInputProps({ isNumber: true, onChange, value: 0 })
+
+ render( )
+ const input = screen.getByRole('spinbutton')
+
+ fireEvent.change(input, { target: { value: '42' } })
+
+ expect(onChange).toHaveBeenCalledWith(42)
+ })
+
+ it('should call onChange with empty string when input is NaN', () => {
+ const onChange = jest.fn()
+ const props = createInputProps({ isNumber: true, onChange, value: 0 })
+
+ render( )
+ const input = screen.getByRole('spinbutton')
+
+ fireEvent.change(input, { target: { value: 'abc' } })
+
+ expect(onChange).toHaveBeenCalledWith('')
+ })
+
+ it('should call onChange with empty string when input is empty', () => {
+ const onChange = jest.fn()
+ const props = createInputProps({ isNumber: true, onChange, value: 5 })
+
+ render( )
+ const input = screen.getByRole('spinbutton')
+
+ fireEvent.change(input, { target: { value: '' } })
+
+ expect(onChange).toHaveBeenCalledWith('')
+ })
+
+ it('should clamp negative values to MIN_VALUE (0)', () => {
+ const onChange = jest.fn()
+ const props = createInputProps({ isNumber: true, onChange, value: 0 })
+
+ render( )
+ const input = screen.getByRole('spinbutton')
+
+ fireEvent.change(input, { target: { value: '-5' } })
+
+ expect(onChange).toHaveBeenCalledWith(0)
+ })
+
+ it('should handle decimal input by parsing as integer', () => {
+ const onChange = jest.fn()
+ const props = createInputProps({ isNumber: true, onChange, value: 0 })
+
+ render( )
+ const input = screen.getByRole('spinbutton')
+
+ fireEvent.change(input, { target: { value: '3.7' } })
+
+ expect(onChange).toHaveBeenCalledWith(3)
+ })
+ })
+
+ describe('Component Memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ expect(Input.$$typeof).toBeDefined()
+ })
+ })
+})
+
+// ============================================================================
+// Header Component Tests
+// ============================================================================
+
+describe('Header', () => {
+ const createHeaderProps = (overrides: Partial[0]> = {}) => ({
+ title: 'Test Title',
+ docTitle: 'Documentation',
+ docLink: 'https://docs.example.com',
+ ...overrides,
+ })
+
+ describe('Rendering', () => {
+ it('should render title', () => {
+ const props = createHeaderProps()
+ render()
+
+ expect(screen.getByText('Test Title')).toBeInTheDocument()
+ })
+
+ it('should render doc link', () => {
+ const props = createHeaderProps()
+ render()
+
+ const link = screen.getByRole('link')
+ expect(link).toHaveAttribute('href', 'https://docs.example.com')
+ expect(link).toHaveAttribute('target', '_blank')
+ })
+
+ it('should render button text when not in pipeline', () => {
+ const props = createHeaderProps({ buttonText: 'Configure' })
+ render()
+
+ expect(screen.getByText('Configure')).toBeInTheDocument()
+ })
+
+ it('should not render button text when in pipeline', () => {
+ const props = createHeaderProps({ isInPipeline: true, buttonText: 'Configure' })
+ render()
+
+ expect(screen.queryByText('Configure')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('isInPipeline Prop', () => {
+ it('should apply pipeline styles when isInPipeline is true', () => {
+ const props = createHeaderProps({ isInPipeline: true })
+ render()
+
+ const titleElement = screen.getByText('Test Title')
+ expect(titleElement).toHaveClass('system-sm-semibold')
+ })
+
+ it('should apply default styles when isInPipeline is false', () => {
+ const props = createHeaderProps({ isInPipeline: false })
+ render()
+
+ const titleElement = screen.getByText('Test Title')
+ expect(titleElement).toHaveClass('system-md-semibold')
+ })
+
+ it('should apply compact button styles when isInPipeline is true', () => {
+ const props = createHeaderProps({ isInPipeline: true })
+ render()
+
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('size-6')
+ expect(button).toHaveClass('px-1')
+ })
+
+ it('should apply default button styles when isInPipeline is false', () => {
+ const props = createHeaderProps({ isInPipeline: false })
+ render()
+
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('gap-x-0.5')
+ expect(button).toHaveClass('px-1.5')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClickConfiguration when button is clicked', async () => {
+ const onClickConfiguration = jest.fn()
+ const props = createHeaderProps({ onClickConfiguration })
+
+ render()
+ await userEvent.click(screen.getByRole('button'))
+
+ expect(onClickConfiguration).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Component Memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ expect(Header.$$typeof).toBeDefined()
+ })
+ })
+})
+
+// ============================================================================
+// CrawledResultItem Component Tests
+// ============================================================================
+
+describe('CrawledResultItem', () => {
+ const createItemProps = (overrides: Partial[0]> = {}) => ({
+ payload: createCrawlResultItem(),
+ isChecked: false,
+ isPreview: false,
+ onCheckChange: jest.fn(),
+ onPreview: jest.fn(),
+ testId: 'test-item',
+ ...overrides,
+ })
+
+ describe('Rendering', () => {
+ it('should render title and source URL', () => {
+ const props = createItemProps({
+ payload: createCrawlResultItem({
+ title: 'My Page',
+ source_url: 'https://mysite.com',
+ }),
+ })
+ render( )
+
+ expect(screen.getByText('My Page')).toBeInTheDocument()
+ expect(screen.getByText('https://mysite.com')).toBeInTheDocument()
+ })
+
+ it('should render checkbox (custom Checkbox component)', () => {
+ const props = createItemProps()
+ render( )
+
+ // Find checkbox by data-testid
+ const checkbox = screen.getByTestId('checkbox-test-item')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should render preview button', () => {
+ const props = createItemProps()
+ render( )
+
+ expect(screen.getByText('datasetCreation.stepOne.website.preview')).toBeInTheDocument()
+ })
+ })
+
+ describe('Checkbox Behavior', () => {
+ it('should call onCheckChange with true when unchecked item is clicked', async () => {
+ const onCheckChange = jest.fn()
+ const props = createItemProps({ isChecked: false, onCheckChange })
+
+ render( )
+ const checkbox = screen.getByTestId('checkbox-test-item')
+ await userEvent.click(checkbox)
+
+ expect(onCheckChange).toHaveBeenCalledWith(true)
+ })
+
+ it('should call onCheckChange with false when checked item is clicked', async () => {
+ const onCheckChange = jest.fn()
+ const props = createItemProps({ isChecked: true, onCheckChange })
+
+ render( )
+ const checkbox = screen.getByTestId('checkbox-test-item')
+ await userEvent.click(checkbox)
+
+ expect(onCheckChange).toHaveBeenCalledWith(false)
+ })
+ })
+
+ describe('Preview Behavior', () => {
+ it('should call onPreview when preview button is clicked', async () => {
+ const onPreview = jest.fn()
+ const props = createItemProps({ onPreview })
+
+ render( )
+ await userEvent.click(screen.getByText('datasetCreation.stepOne.website.preview'))
+
+ expect(onPreview).toHaveBeenCalledTimes(1)
+ })
+
+ it('should apply active style when isPreview is true', () => {
+ const props = createItemProps({ isPreview: true })
+ const { container } = render( )
+
+ const wrapper = container.firstChild
+ expect(wrapper).toHaveClass('bg-state-base-active')
+ })
+
+ it('should not apply active style when isPreview is false', () => {
+ const props = createItemProps({ isPreview: false })
+ const { container } = render( )
+
+ const wrapper = container.firstChild
+ expect(wrapper).not.toHaveClass('bg-state-base-active')
+ })
+ })
+
+ describe('Component Memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ expect(CrawledResultItem.$$typeof).toBeDefined()
+ })
+ })
+})
+
+// ============================================================================
+// CrawledResult Component Tests
+// ============================================================================
+
+describe('CrawledResult', () => {
+ const createResultProps = (overrides: Partial[0]> = {}) => ({
+ list: [
+ createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
+ createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
+ createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }),
+ ],
+ checkedList: [],
+ onSelectedChange: jest.fn(),
+ onPreview: jest.fn(),
+ usedTime: 2.5,
+ ...overrides,
+ })
+
+ // Helper functions to get checkboxes by data-testid
+ const getSelectAllCheckbox = () => screen.getByTestId('checkbox-select-all')
+ const getItemCheckbox = (index: number) => screen.getByTestId(`checkbox-item-${index}`)
+
+ describe('Rendering', () => {
+ it('should render all items in list', () => {
+ const props = createResultProps()
+ render( )
+
+ expect(screen.getByText('Page 1')).toBeInTheDocument()
+ expect(screen.getByText('Page 2')).toBeInTheDocument()
+ expect(screen.getByText('Page 3')).toBeInTheDocument()
+ })
+
+ it('should render time info', () => {
+ const props = createResultProps({ usedTime: 3.456 })
+ render( )
+
+ // The component uses i18n, so we check for the key pattern
+ expect(screen.getByText(/scrapTimeInfo/)).toBeInTheDocument()
+ })
+
+ it('should render select all checkbox', () => {
+ const props = createResultProps()
+ render( )
+
+ expect(screen.getByText('datasetCreation.stepOne.website.selectAll')).toBeInTheDocument()
+ })
+
+ it('should render reset all when all items are checked', () => {
+ const list = [
+ createCrawlResultItem({ source_url: 'https://page1.com' }),
+ createCrawlResultItem({ source_url: 'https://page2.com' }),
+ ]
+ const props = createResultProps({ list, checkedList: list })
+ render( )
+
+ expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument()
+ })
+ })
+
+ describe('Select All / Deselect All', () => {
+ it('should call onSelectedChange with all items when select all is clicked', async () => {
+ const onSelectedChange = jest.fn()
+ const list = [
+ createCrawlResultItem({ source_url: 'https://page1.com' }),
+ createCrawlResultItem({ source_url: 'https://page2.com' }),
+ ]
+ const props = createResultProps({ list, checkedList: [], onSelectedChange })
+
+ render( )
+ await userEvent.click(getSelectAllCheckbox())
+
+ expect(onSelectedChange).toHaveBeenCalledWith(list)
+ })
+
+ it('should call onSelectedChange with empty array when reset all is clicked', async () => {
+ const onSelectedChange = jest.fn()
+ const list = [
+ createCrawlResultItem({ source_url: 'https://page1.com' }),
+ createCrawlResultItem({ source_url: 'https://page2.com' }),
+ ]
+ const props = createResultProps({ list, checkedList: list, onSelectedChange })
+
+ render( )
+ await userEvent.click(getSelectAllCheckbox())
+
+ expect(onSelectedChange).toHaveBeenCalledWith([])
+ })
+ })
+
+ describe('Individual Item Selection', () => {
+ it('should add item to checkedList when unchecked item is checked', async () => {
+ const onSelectedChange = jest.fn()
+ const list = [
+ createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
+ createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
+ ]
+ const props = createResultProps({ list, checkedList: [], onSelectedChange })
+
+ render( )
+ await userEvent.click(getItemCheckbox(0))
+
+ expect(onSelectedChange).toHaveBeenCalledWith([list[0]])
+ })
+
+ it('should remove item from checkedList when checked item is unchecked', async () => {
+ const onSelectedChange = jest.fn()
+ const list = [
+ createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
+ createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
+ ]
+ const props = createResultProps({ list, checkedList: [list[0]], onSelectedChange })
+
+ render( )
+ await userEvent.click(getItemCheckbox(0))
+
+ expect(onSelectedChange).toHaveBeenCalledWith([])
+ })
+
+ it('should preserve other checked items when unchecking one item', async () => {
+ const onSelectedChange = jest.fn()
+ const list = [
+ createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
+ createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
+ createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }),
+ ]
+ const props = createResultProps({ list, checkedList: [list[0], list[1]], onSelectedChange })
+
+ render( )
+ // Click the first item's checkbox to uncheck it
+ await userEvent.click(getItemCheckbox(0))
+
+ expect(onSelectedChange).toHaveBeenCalledWith([list[1]])
+ })
+ })
+
+ describe('Preview Behavior', () => {
+ it('should call onPreview with correct item when preview is clicked', async () => {
+ const onPreview = jest.fn()
+ const list = [
+ createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
+ createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
+ ]
+ const props = createResultProps({ list, onPreview })
+
+ render( )
+
+ // Click preview on second item
+ const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview')
+ await userEvent.click(previewButtons[1])
+
+ expect(onPreview).toHaveBeenCalledWith(list[1])
+ })
+
+ it('should track preview index correctly', async () => {
+ const onPreview = jest.fn()
+ const list = [
+ createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
+ createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
+ ]
+ const props = createResultProps({ list, onPreview })
+
+ render( )
+
+ // Click preview on first item
+ const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview')
+ await userEvent.click(previewButtons[0])
+
+ expect(onPreview).toHaveBeenCalledWith(list[0])
+ })
+ })
+
+ describe('Component Memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ expect(CrawledResult.$$typeof).toBeDefined()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle empty list', () => {
+ const props = createResultProps({ list: [], checkedList: [] })
+ render( )
+
+ // Should still render the header with resetAll (empty list = all checked)
+ expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument()
+ })
+
+ it('should handle className prop', () => {
+ const props = createResultProps({ className: 'custom-class' })
+ const { container } = render( )
+
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx
index f5451af074..54031a87d4 100644
--- a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx
+++ b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Checkbox from '@/app/components/base/checkbox'
import Tooltip from '@/app/components/base/tooltip'
@@ -12,6 +12,7 @@ type Props = {
label: string
labelClassName?: string
tooltip?: string
+ testId?: string
}
const CheckboxWithLabel: FC = ({
@@ -21,10 +22,11 @@ const CheckboxWithLabel: FC = ({
label,
labelClassName,
tooltip,
+ testId,
}) => {
return (
- onChange(!isChecked)} />
+ onChange(!isChecked)} id={testId} />
{label}
{tooltip && (
void
onPreview: () => void
+ testId?: string
}
const CrawledResultItem: FC = ({
@@ -21,6 +22,7 @@ const CrawledResultItem: FC = ({
isChecked,
onCheckChange,
onPreview,
+ testId,
}) => {
const { t } = useTranslation()
@@ -31,7 +33,7 @@ const CrawledResultItem: FC = ({
-
+
= ({
{t(`${I18N_PREFIX}.scrapTimeInfo`, {
@@ -80,6 +82,7 @@ const CrawledResult: FC
= ({
payload={item}
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
onCheckChange={handleItemCheckChange(item)}
+ testId={`item-${index}`}
/>
))}
diff --git a/web/app/components/datasets/create/website/base/error-message.tsx b/web/app/components/datasets/create/website/base/error-message.tsx
index 2788eb9013..4a88dfe79e 100644
--- a/web/app/components/datasets/create/website/base/error-message.tsx
+++ b/web/app/components/datasets/create/website/base/error-message.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
type Props = {
diff --git a/web/app/components/datasets/create/website/base/field.tsx b/web/app/components/datasets/create/website/base/field.tsx
index 43d76464e6..5db048c6b9 100644
--- a/web/app/components/datasets/create/website/base/field.tsx
+++ b/web/app/components/datasets/create/website/base/field.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import Input from './input'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
type Props = {
diff --git a/web/app/components/datasets/create/website/base/header.tsx b/web/app/components/datasets/create/website/base/header.tsx
index 5a99835522..c5aa95189f 100644
--- a/web/app/components/datasets/create/website/base/header.tsx
+++ b/web/app/components/datasets/create/website/base/header.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react'
type HeaderProps = {
diff --git a/web/app/components/datasets/create/website/base/options-wrap.tsx b/web/app/components/datasets/create/website/base/options-wrap.tsx
index f17a546203..9428782fdb 100644
--- a/web/app/components/datasets/create/website/base/options-wrap.tsx
+++ b/web/app/components/datasets/create/website/base/options-wrap.tsx
@@ -4,7 +4,7 @@ import type { FC } from 'react'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { RiEqualizer2Line } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
diff --git a/web/app/components/datasets/create/website/firecrawl/options.tsx b/web/app/components/datasets/create/website/firecrawl/options.tsx
index dea6c0e5b9..f31ae93e9d 100644
--- a/web/app/components/datasets/create/website/firecrawl/options.tsx
+++ b/web/app/components/datasets/create/website/firecrawl/options.tsx
@@ -4,7 +4,7 @@ import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import CheckboxWithLabel from '../base/checkbox-with-label'
import Field from '../base/field'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { CrawlOptions } from '@/models/datasets'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
diff --git a/web/app/components/datasets/create/website/index.tsx b/web/app/components/datasets/create/website/index.tsx
index ee7ace6815..180f6c6bcd 100644
--- a/web/app/components/datasets/create/website/index.tsx
+++ b/web/app/components/datasets/create/website/index.tsx
@@ -7,7 +7,7 @@ import NoData from './no-data'
import Firecrawl from './firecrawl'
import Watercrawl from './watercrawl'
import JinaReader from './jina-reader'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useModalContext } from '@/context/modal-context'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { DataSourceProvider } from '@/models/common'
diff --git a/web/app/components/datasets/create/website/jina-reader/base.spec.tsx b/web/app/components/datasets/create/website/jina-reader/base.spec.tsx
new file mode 100644
index 0000000000..44120f8f54
--- /dev/null
+++ b/web/app/components/datasets/create/website/jina-reader/base.spec.tsx
@@ -0,0 +1,396 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import UrlInput from './base/url-input'
+
+// Mock doc link context
+jest.mock('@/context/i18n', () => ({
+ useDocLink: () => () => 'https://docs.example.com',
+}))
+
+// ============================================================================
+// UrlInput Component Tests
+// ============================================================================
+
+describe('UrlInput', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // Helper to create default props for UrlInput
+ const createUrlInputProps = (overrides: Partial
[0]> = {}) => ({
+ isRunning: false,
+ onRun: jest.fn(),
+ ...overrides,
+ })
+
+ // --------------------------------------------------------------------------
+ // Rendering Tests
+ // --------------------------------------------------------------------------
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange
+ const props = createUrlInputProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
+ })
+
+ it('should render input with placeholder from docLink', () => {
+ // Arrange
+ const props = createUrlInputProps()
+
+ // Act
+ render( )
+
+ // Assert
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveAttribute('placeholder', 'https://docs.example.com')
+ })
+
+ it('should render run button with correct text when not running', () => {
+ // Arrange
+ const props = createUrlInputProps({ isRunning: false })
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
+ })
+
+ it('should render button without text when running', () => {
+ // Arrange
+ const props = createUrlInputProps({ isRunning: true })
+
+ // Act
+ render( )
+
+ // Assert - find button by data-testid when in loading state
+ const runButton = screen.getByTestId('url-input-run-button')
+ expect(runButton).toBeInTheDocument()
+ // Button text should be empty when running
+ expect(runButton).not.toHaveTextContent(/run/i)
+ })
+
+ it('should show loading state on button when running', () => {
+ // Arrange
+ const onRun = jest.fn()
+ const props = createUrlInputProps({ isRunning: true, onRun })
+
+ // Act
+ render( )
+
+ // Assert - find button by data-testid when in loading state
+ const runButton = screen.getByTestId('url-input-run-button')
+ expect(runButton).toBeInTheDocument()
+
+ // Verify button is empty (loading state removes text)
+ expect(runButton).not.toHaveTextContent(/run/i)
+
+ // Verify clicking doesn't trigger onRun when loading
+ fireEvent.click(runButton)
+ expect(onRun).not.toHaveBeenCalled()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // User Input Tests
+ // --------------------------------------------------------------------------
+ describe('User Input', () => {
+ it('should update URL value when user types', async () => {
+ // Arrange
+ const props = createUrlInputProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://test.com')
+
+ // Assert
+ expect(input).toHaveValue('https://test.com')
+ })
+
+ it('should handle URL input clearing', async () => {
+ // Arrange
+ const props = createUrlInputProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://test.com')
+ await userEvent.clear(input)
+
+ // Assert
+ expect(input).toHaveValue('')
+ })
+
+ it('should handle special characters in URL', async () => {
+ // Arrange
+ const props = createUrlInputProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://example.com/path?query=value&foo=bar')
+
+ // Assert
+ expect(input).toHaveValue('https://example.com/path?query=value&foo=bar')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Button Click Tests
+ // --------------------------------------------------------------------------
+ describe('Button Click', () => {
+ it('should call onRun with URL when button is clicked', async () => {
+ // Arrange
+ const onRun = jest.fn()
+ const props = createUrlInputProps({ onRun })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://run-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ expect(onRun).toHaveBeenCalledWith('https://run-test.com')
+ expect(onRun).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onRun with empty string if no URL entered', async () => {
+ // Arrange
+ const onRun = jest.fn()
+ const props = createUrlInputProps({ onRun })
+
+ // Act
+ render( )
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ expect(onRun).toHaveBeenCalledWith('')
+ })
+
+ it('should not call onRun when isRunning is true', async () => {
+ // Arrange
+ const onRun = jest.fn()
+ const props = createUrlInputProps({ onRun, isRunning: true })
+
+ // Act
+ render( )
+ const runButton = screen.getByTestId('url-input-run-button')
+ fireEvent.click(runButton)
+
+ // Assert
+ expect(onRun).not.toHaveBeenCalled()
+ })
+
+ it('should not call onRun when already running', async () => {
+ // Arrange
+ const onRun = jest.fn()
+
+ // First render with isRunning=false, type URL, then rerender with isRunning=true
+ const { rerender } = render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://test.com')
+
+ // Rerender with isRunning=true to simulate a running state
+ rerender( )
+
+ // Find and click the button by data-testid (loading state has no text)
+ const runButton = screen.getByTestId('url-input-run-button')
+ fireEvent.click(runButton)
+
+ // Assert - onRun should not be called due to early return at line 28
+ expect(onRun).not.toHaveBeenCalled()
+ })
+
+ it('should prevent multiple clicks when already running', async () => {
+ // Arrange
+ const onRun = jest.fn()
+ const props = createUrlInputProps({ onRun, isRunning: true })
+
+ // Act
+ render( )
+ const runButton = screen.getByTestId('url-input-run-button')
+ fireEvent.click(runButton)
+ fireEvent.click(runButton)
+ fireEvent.click(runButton)
+
+ // Assert
+ expect(onRun).not.toHaveBeenCalled()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Props Tests
+ // --------------------------------------------------------------------------
+ describe('Props', () => {
+ it('should respond to isRunning prop change', () => {
+ // Arrange
+ const props = createUrlInputProps({ isRunning: false })
+
+ // Act
+ const { rerender } = render( )
+ expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
+
+ // Change isRunning to true
+ rerender( )
+
+ // Assert - find button by data-testid and verify it's now in loading state
+ const runButton = screen.getByTestId('url-input-run-button')
+ expect(runButton).toBeInTheDocument()
+ // When loading, the button text should be empty
+ expect(runButton).not.toHaveTextContent(/run/i)
+ })
+
+ it('should call updated onRun callback after prop change', async () => {
+ // Arrange
+ const onRun1 = jest.fn()
+ const onRun2 = jest.fn()
+
+ // Act
+ const { rerender } = render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://first.com')
+
+ // Change onRun callback
+ rerender( )
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - new callback should be called
+ expect(onRun1).not.toHaveBeenCalled()
+ expect(onRun2).toHaveBeenCalledWith('https://first.com')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Callback Stability Tests
+ // --------------------------------------------------------------------------
+ describe('Callback Stability', () => {
+ it('should use memoized handleUrlChange callback', async () => {
+ // Arrange
+ const props = createUrlInputProps()
+
+ // Act
+ const { rerender } = render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'a')
+
+ // Rerender with same props
+ rerender( )
+ await userEvent.type(input, 'b')
+
+ // Assert - input should work correctly across rerenders
+ expect(input).toHaveValue('ab')
+ })
+
+ it('should maintain URL state across rerenders', async () => {
+ // Arrange
+ const props = createUrlInputProps()
+
+ // Act
+ const { rerender } = render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://stable.com')
+
+ // Rerender
+ rerender( )
+
+ // Assert - URL should be maintained
+ expect(input).toHaveValue('https://stable.com')
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Component Memoization Tests
+ // --------------------------------------------------------------------------
+ describe('Component Memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ // Assert
+ expect(UrlInput.$$typeof).toBeDefined()
+ })
+ })
+
+ // --------------------------------------------------------------------------
+ // Edge Cases Tests
+ // --------------------------------------------------------------------------
+ describe('Edge Cases', () => {
+ it('should handle very long URLs', async () => {
+ // Arrange
+ const props = createUrlInputProps()
+ const longUrl = `https://example.com/${'a'.repeat(1000)}`
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, longUrl)
+
+ // Assert
+ expect(input).toHaveValue(longUrl)
+ })
+
+ it('should handle URLs with unicode characters', async () => {
+ // Arrange
+ const props = createUrlInputProps()
+ const unicodeUrl = 'https://example.com/路径/测试'
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, unicodeUrl)
+
+ // Assert
+ expect(input).toHaveValue(unicodeUrl)
+ })
+
+ it('should handle rapid typing', async () => {
+ // Arrange
+ const props = createUrlInputProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://rapid.com', { delay: 1 })
+
+ // Assert
+ expect(input).toHaveValue('https://rapid.com')
+ })
+
+ it('should handle keyboard enter to trigger run', async () => {
+ // Arrange - Note: This tests if the button can be activated via keyboard
+ const onRun = jest.fn()
+ const props = createUrlInputProps({ onRun })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://enter.com')
+
+ // Focus button and press enter
+ const button = screen.getByRole('button', { name: /run/i })
+ button.focus()
+ await userEvent.keyboard('{Enter}')
+
+ // Assert
+ expect(onRun).toHaveBeenCalledWith('https://enter.com')
+ })
+
+ it('should handle empty URL submission', async () => {
+ // Arrange
+ const onRun = jest.fn()
+ const props = createUrlInputProps({ onRun })
+
+ // Act
+ render( )
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - should call with empty string
+ expect(onRun).toHaveBeenCalledWith('')
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/website/jina-reader/base/url-input.tsx b/web/app/components/datasets/create/website/jina-reader/base/url-input.tsx
index c4c2c15b28..1b30c44a88 100644
--- a/web/app/components/datasets/create/website/jina-reader/base/url-input.tsx
+++ b/web/app/components/datasets/create/website/jina-reader/base/url-input.tsx
@@ -41,6 +41,7 @@ const UrlInput: FC = ({
onClick={handleOnRun}
className='ml-2'
loading={isRunning}
+ data-testid='url-input-run-button'
>
{!isRunning ? t(`${I18N_PREFIX}.run`) : ''}
diff --git a/web/app/components/datasets/create/website/jina-reader/index.spec.tsx b/web/app/components/datasets/create/website/jina-reader/index.spec.tsx
new file mode 100644
index 0000000000..16b302bbd2
--- /dev/null
+++ b/web/app/components/datasets/create/website/jina-reader/index.spec.tsx
@@ -0,0 +1,1631 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import JinaReader from './index'
+import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
+import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datasets'
+import { sleep } from '@/utils'
+
+// Mock external dependencies
+jest.mock('@/service/datasets', () => ({
+ createJinaReaderTask: jest.fn(),
+ checkJinaReaderTaskStatus: jest.fn(),
+}))
+
+jest.mock('@/utils', () => ({
+ sleep: jest.fn(() => Promise.resolve()),
+}))
+
+// Mock modal context
+const mockSetShowAccountSettingModal = jest.fn()
+jest.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowAccountSettingModal: mockSetShowAccountSettingModal,
+ }),
+}))
+
+// Mock doc link context
+jest.mock('@/context/i18n', () => ({
+ useDocLink: () => () => 'https://docs.example.com',
+}))
+
+// ============================================================================
+// Test Data Factories
+// ============================================================================
+
+// Note: limit and max_depth are typed as `number | string` in CrawlOptions
+// Tests may use number, string, or empty string values to cover all valid cases
+const createDefaultCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({
+ crawl_sub_pages: true,
+ only_main_content: true,
+ includes: '',
+ excludes: '',
+ limit: 10,
+ max_depth: 2,
+ use_sitemap: false,
+ ...overrides,
+})
+
+const createCrawlResultItem = (overrides: Partial = {}): CrawlResultItem => ({
+ title: 'Test Page Title',
+ markdown: '# Test Content\n\nThis is test markdown content.',
+ description: 'Test description',
+ source_url: 'https://example.com/page',
+ ...overrides,
+})
+
+const createDefaultProps = (overrides: Partial[0]> = {}) => ({
+ onPreview: jest.fn(),
+ checkedCrawlResult: [] as CrawlResultItem[],
+ onCheckedCrawlResultChange: jest.fn(),
+ onJobIdChange: jest.fn(),
+ crawlOptions: createDefaultCrawlOptions(),
+ onCrawlOptionsChange: jest.fn(),
+ ...overrides,
+})
+
+// ============================================================================
+// Rendering Tests
+// ============================================================================
+describe('JinaReader', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepOne.website.jinaReaderTitle')).toBeInTheDocument()
+ })
+
+ it('should render header with configuration button', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepOne.website.configureJinaReader')).toBeInTheDocument()
+ })
+
+ it('should render URL input field', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+
+ it('should render run button', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
+ })
+
+ it('should render options section', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument()
+ })
+
+ it('should render doc link to Jina Reader', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ const docLink = screen.getByRole('link')
+ expect(docLink).toHaveAttribute('href', 'https://jina.ai/reader')
+ })
+
+ it('should not render crawling or result components initially', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Assert
+ expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
+ })
+ })
+
+ // ============================================================================
+ // Props Testing
+ // ============================================================================
+ describe('Props', () => {
+ it('should call onCrawlOptionsChange when options change', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const onCrawlOptionsChange = jest.fn()
+ const props = createDefaultProps({ onCrawlOptionsChange })
+
+ // Act
+ render( )
+
+ // Find the limit input by its associated label text
+ const limitLabel = screen.queryByText('datasetCreation.stepOne.website.limit')
+
+ if (limitLabel) {
+ // The limit input is a number input (spinbutton role) within the same container
+ const limitInput = limitLabel.closest('div')?.parentElement?.querySelector('input[type="number"]')
+
+ if (limitInput) {
+ await user.clear(limitInput)
+ await user.type(limitInput, '20')
+
+ // Assert
+ expect(onCrawlOptionsChange).toHaveBeenCalled()
+ }
+ }
+ else {
+ // Options might not be visible, just verify component renders
+ expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument()
+ }
+ })
+
+ it('should execute crawl task when checkedCrawlResult is provided', async () => {
+ // Arrange
+ const checkedItem = createCrawlResultItem({ source_url: 'https://checked.com' })
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: {
+ title: 'Test',
+ content: 'Test content',
+ description: 'Test desc',
+ url: 'https://example.com',
+ },
+ })
+
+ const props = createDefaultProps({
+ checkedCrawlResult: [checkedItem],
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - crawl task should be created even with pre-checked results
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalled()
+ })
+ })
+
+ it('should use default crawlOptions limit in validation', () => {
+ // Arrange
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ limit: '' }),
+ })
+
+ // Act
+ render( )
+
+ // Assert - component renders with empty limit
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+ })
+
+ // ============================================================================
+ // State Management Tests
+ // ============================================================================
+ describe('State Management', () => {
+ it('should transition from init to running state when run is clicked', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ let resolvePromise: () => void
+ mockCreateTask.mockImplementation(() => new Promise((resolve) => {
+ resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } })
+ }))
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const urlInput = screen.getAllByRole('textbox')[0]
+ await userEvent.type(urlInput, 'https://example.com')
+
+ // Click run and immediately check for crawling state
+ const runButton = screen.getByRole('button', { name: /run/i })
+ fireEvent.click(runButton)
+
+ // Assert - crawling indicator should appear
+ await waitFor(() => {
+ expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
+ })
+
+ // Cleanup - resolve the promise
+ resolvePromise!()
+ })
+
+ it('should transition to finished state after successful crawl', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: {
+ title: 'Test Page',
+ content: 'Test content',
+ description: 'Test description',
+ url: 'https://example.com',
+ },
+ })
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(/selectAll|resetAll/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should update crawl result state during polling', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'test-job-123' })
+ mockCheckStatus
+ .mockResolvedValueOnce({
+ status: 'running',
+ current: 1,
+ total: 3,
+ data: [createCrawlResultItem()],
+ })
+ .mockResolvedValueOnce({
+ status: 'completed',
+ current: 3,
+ total: 3,
+ data: [
+ createCrawlResultItem({ source_url: 'https://example.com/1' }),
+ createCrawlResultItem({ source_url: 'https://example.com/2' }),
+ createCrawlResultItem({ source_url: 'https://example.com/3' }),
+ ],
+ })
+
+ const onCheckedCrawlResultChange = jest.fn()
+ const onJobIdChange = jest.fn()
+ const props = createDefaultProps({ onCheckedCrawlResultChange, onJobIdChange })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(onJobIdChange).toHaveBeenCalledWith('test-job-123')
+ })
+
+ await waitFor(() => {
+ expect(onCheckedCrawlResultChange).toHaveBeenCalled()
+ })
+ })
+
+ it('should fold options when step changes from init', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: {
+ title: 'Test',
+ content: 'Content',
+ description: 'Desc',
+ url: 'https://example.com',
+ },
+ })
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Options should be visible initially
+ expect(screen.getByText('datasetCreation.stepOne.website.crawlSubPage')).toBeInTheDocument()
+
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - options should be folded after crawl starts
+ await waitFor(() => {
+ expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ // ============================================================================
+ // Side Effects and Cleanup Tests
+ // ============================================================================
+ describe('Side Effects and Cleanup', () => {
+ it('should call sleep during polling', async () => {
+ // Arrange
+ const mockSleep = sleep as jest.Mock
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'test-job' })
+ mockCheckStatus
+ .mockResolvedValueOnce({ status: 'running', current: 1, total: 2, data: [] })
+ .mockResolvedValueOnce({ status: 'completed', current: 2, total: 2, data: [] })
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockSleep).toHaveBeenCalledWith(2500)
+ })
+ })
+
+ it('should update controlFoldOptions when step changes', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockImplementation(() => new Promise((_resolve) => { /* pending */ }))
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Initially options should be visible
+ expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument()
+
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - the crawling indicator should appear
+ await waitFor(() => {
+ expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
+ })
+ })
+ })
+
+ // ============================================================================
+ // Callback Stability and Memoization Tests
+ // ============================================================================
+ describe('Callback Stability', () => {
+ it('should maintain stable handleSetting callback', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ const { rerender } = render( )
+ const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader')
+ fireEvent.click(configButton)
+
+ // Assert
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1)
+
+ // Rerender and click again
+ rerender( )
+ fireEvent.click(configButton)
+
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(2)
+ })
+
+ it('should memoize checkValid callback based on crawlOptions', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValue({ data: { title: 'T', content: 'C', description: 'D', url: 'https://a.com' } })
+
+ const props = createDefaultProps()
+
+ // Act
+ const { rerender } = render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalledTimes(1)
+ })
+
+ // Rerender with same options
+ rerender( )
+
+ // Assert - component should still work correctly
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+ })
+
+ // ============================================================================
+ // User Interactions and Event Handlers Tests
+ // ============================================================================
+ describe('User Interactions', () => {
+ it('should open account settings when configuration button is clicked', async () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader')
+ await userEvent.click(configButton)
+
+ // Assert
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
+ payload: 'data-source',
+ })
+ })
+
+ it('should handle URL input and run button click', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: {
+ title: 'Test',
+ content: 'Content',
+ description: 'Desc',
+ url: 'https://test.com',
+ },
+ })
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalledWith({
+ url: 'https://test.com',
+ options: props.crawlOptions,
+ })
+ })
+ })
+
+ it('should handle preview action on crawled result', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const onPreview = jest.fn()
+ const crawlResultData = {
+ title: 'Preview Test',
+ content: '# Content',
+ description: 'Preview desc',
+ url: 'https://preview.com',
+ }
+
+ mockCreateTask.mockResolvedValueOnce({ data: crawlResultData })
+
+ const props = createDefaultProps({ onPreview })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://preview.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - result should be displayed
+ await waitFor(() => {
+ expect(screen.getByText('Preview Test')).toBeInTheDocument()
+ })
+
+ // Click on preview button
+ const previewButton = screen.getByText('datasetCreation.stepOne.website.preview')
+ await userEvent.click(previewButton)
+
+ expect(onPreview).toHaveBeenCalled()
+ })
+
+ it('should handle checkbox changes in options', async () => {
+ // Arrange
+ const onCrawlOptionsChange = jest.fn()
+ const props = createDefaultProps({
+ onCrawlOptionsChange,
+ crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }),
+ })
+
+ // Act
+ render( )
+
+ // Find and click the checkbox by data-testid
+ const checkbox = screen.getByTestId('checkbox-crawl-sub-pages')
+ fireEvent.click(checkbox)
+
+ // Assert - onCrawlOptionsChange should be called
+ expect(onCrawlOptionsChange).toHaveBeenCalled()
+ })
+
+ it('should toggle options visibility when clicking options header', async () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+
+ // Options content should be visible initially
+ expect(screen.getByText('datasetCreation.stepOne.website.crawlSubPage')).toBeInTheDocument()
+
+ // Click to collapse
+ const optionsHeader = screen.getByText('datasetCreation.stepOne.website.options')
+ await userEvent.click(optionsHeader)
+
+ // Assert - options should be hidden
+ expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument()
+
+ // Click to expand again
+ await userEvent.click(optionsHeader)
+
+ // Options should be visible again
+ expect(screen.getByText('datasetCreation.stepOne.website.crawlSubPage')).toBeInTheDocument()
+ })
+ })
+
+ // ============================================================================
+ // API Calls Tests
+ // ============================================================================
+ describe('API Calls', () => {
+ it('should call createJinaReaderTask with correct parameters', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: { title: 'T', content: 'C', description: 'D', url: 'https://api-test.com' },
+ })
+
+ const crawlOptions = createDefaultCrawlOptions({ limit: 5, max_depth: 3 })
+ const props = createDefaultProps({ crawlOptions })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://api-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalledWith({
+ url: 'https://api-test.com',
+ options: crawlOptions,
+ })
+ })
+ })
+
+ it('should handle direct data response from API', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const onCheckedCrawlResultChange = jest.fn()
+
+ mockCreateTask.mockResolvedValueOnce({
+ data: {
+ title: 'Direct Result',
+ content: '# Direct Content',
+ description: 'Direct desc',
+ url: 'https://direct.com',
+ },
+ })
+
+ const props = createDefaultProps({ onCheckedCrawlResultChange })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://direct.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([
+ expect.objectContaining({
+ title: 'Direct Result',
+ source_url: 'https://direct.com',
+ }),
+ ])
+ })
+ })
+
+ it('should handle job_id response and poll for status', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+ const onJobIdChange = jest.fn()
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'poll-job-123' })
+ mockCheckStatus.mockResolvedValueOnce({
+ status: 'completed',
+ current: 2,
+ total: 2,
+ data: [
+ createCrawlResultItem({ source_url: 'https://p1.com' }),
+ createCrawlResultItem({ source_url: 'https://p2.com' }),
+ ],
+ })
+
+ const props = createDefaultProps({ onJobIdChange })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://poll-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(onJobIdChange).toHaveBeenCalledWith('poll-job-123')
+ })
+
+ await waitFor(() => {
+ expect(mockCheckStatus).toHaveBeenCalledWith('poll-job-123')
+ })
+ })
+
+ it('should handle failed status from polling', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'fail-job' })
+ mockCheckStatus.mockResolvedValueOnce({
+ status: 'failed',
+ message: 'Crawl failed due to network error',
+ })
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://fail-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('Crawl failed due to network error')).toBeInTheDocument()
+ })
+
+ it('should handle API error during status check', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'error-job' })
+ mockCheckStatus.mockRejectedValueOnce({
+ json: () => Promise.resolve({ message: 'API Error occurred' }),
+ })
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://error-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument()
+ })
+ })
+
+ it('should limit total to crawlOptions.limit', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+ const onCheckedCrawlResultChange = jest.fn()
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'limit-job' })
+ mockCheckStatus.mockResolvedValueOnce({
+ status: 'completed',
+ current: 100,
+ total: 100,
+ data: Array.from({ length: 100 }, (_, i) =>
+ createCrawlResultItem({ source_url: `https://example.com/${i}` }),
+ ),
+ })
+
+ const props = createDefaultProps({
+ onCheckedCrawlResultChange,
+ crawlOptions: createDefaultCrawlOptions({ limit: 5 }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://limit-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(onCheckedCrawlResultChange).toHaveBeenCalled()
+ })
+ })
+ })
+
+ // ============================================================================
+ // Component Memoization Tests
+ // ============================================================================
+ describe('Component Memoization', () => {
+ it('should be wrapped with React.memo', () => {
+ // Assert - React.memo components have $$typeof Symbol(react.memo)
+ expect(JinaReader.$$typeof?.toString()).toBe('Symbol(react.memo)')
+ expect((JinaReader as unknown as { type: unknown }).type).toBeDefined()
+ })
+ })
+
+ // ============================================================================
+ // Edge Cases and Error Handling Tests
+ // ============================================================================
+ describe('Edge Cases and Error Handling', () => {
+ it('should show error for empty URL', async () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - Toast should be shown (mocked via Toast component)
+ await waitFor(() => {
+ expect(createJinaReaderTask).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should show error for invalid URL format', async () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'invalid-url')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(createJinaReaderTask).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should show error for URL without protocol', async () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(createJinaReaderTask).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should accept URL with http:// protocol', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: { title: 'T', content: 'C', description: 'D', url: 'http://example.com' },
+ })
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'http://example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalled()
+ })
+ })
+
+ it('should show error when limit is empty', async () => {
+ // Arrange
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ limit: '' }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(createJinaReaderTask).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should show error when limit is null', async () => {
+ // Arrange
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ limit: null as unknown as number }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(createJinaReaderTask).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should show error when limit is undefined', async () => {
+ // Arrange
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ limit: undefined as unknown as number }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://example.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(createJinaReaderTask).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should handle API throwing an exception', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockRejectedValueOnce(new Error('Network error'))
+ // Suppress console output during test to avoid noisy logs
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation(jest.fn())
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://exception-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument()
+ })
+
+ consoleSpy.mockRestore()
+ })
+
+ it('should handle status response without status field', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'no-status-job' })
+ mockCheckStatus.mockResolvedValueOnce({
+ // No status field
+ message: 'Unknown error',
+ })
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://no-status-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument()
+ })
+ })
+
+ it('should show unknown error when error message is empty', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'empty-error-job' })
+ mockCheckStatus.mockResolvedValueOnce({
+ status: 'failed',
+ // No message
+ })
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://empty-error-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('datasetCreation.stepOne.website.unknownError')).toBeInTheDocument()
+ })
+ })
+
+ it('should handle empty data array from API', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+ const onCheckedCrawlResultChange = jest.fn()
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'empty-data-job' })
+ mockCheckStatus.mockResolvedValueOnce({
+ status: 'completed',
+ current: 0,
+ total: 0,
+ data: [],
+ })
+
+ const props = createDefaultProps({ onCheckedCrawlResultChange })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://empty-data-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([])
+ })
+ })
+
+ it('should handle null data from running status', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+ const onCheckedCrawlResultChange = jest.fn()
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'null-data-job' })
+ mockCheckStatus
+ .mockResolvedValueOnce({
+ status: 'running',
+ current: 0,
+ total: 5,
+ data: null,
+ })
+ .mockResolvedValueOnce({
+ status: 'completed',
+ current: 5,
+ total: 5,
+ data: [createCrawlResultItem()],
+ })
+
+ const props = createDefaultProps({ onCheckedCrawlResultChange })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://null-data-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([])
+ })
+ })
+
+ it('should return empty array when completed job has undefined data', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+ const onCheckedCrawlResultChange = jest.fn()
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'undefined-data-job' })
+ mockCheckStatus.mockResolvedValueOnce({
+ status: 'completed',
+ current: 0,
+ total: 0,
+ // data is undefined - should fallback to empty array
+ })
+
+ const props = createDefaultProps({ onCheckedCrawlResultChange })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://undefined-data-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([])
+ })
+ })
+
+ it('should show zero current progress when crawlResult is not yet available', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-current-job' })
+ mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
+
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://zero-current-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - should show 0/10 when crawlResult is undefined
+ await waitFor(() => {
+ expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
+ })
+ })
+
+ it('should show 0/0 progress when limit is zero string', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-total-job' })
+ mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
+
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ limit: '0' }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://zero-total-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - should show 0/0 when limit parses to 0
+ await waitFor(() => {
+ expect(screen.getByText(/totalPageScraped.*0\/0/)).toBeInTheDocument()
+ })
+ })
+
+ it('should complete successfully when result data is undefined', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+ const onCheckedCrawlResultChange = jest.fn()
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'undefined-result-data-job' })
+ mockCheckStatus.mockResolvedValueOnce({
+ status: 'completed',
+ current: 0,
+ total: 0,
+ time_consuming: 1.5,
+ // data is undefined - should fallback to empty array
+ })
+
+ const props = createDefaultProps({ onCheckedCrawlResultChange })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://undefined-result-data-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - should complete and show results even if empty
+ await waitFor(() => {
+ expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should use limit as total when crawlResult total is not available', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'no-total-job' })
+ mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
+
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ limit: 15 }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://no-total-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - should use limit (15) as total
+ await waitFor(() => {
+ expect(screen.getByText(/totalPageScraped.*0\/15/)).toBeInTheDocument()
+ })
+ })
+
+ it('should fallback to limit when crawlResult has zero total', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'both-zero-job' })
+ mockCheckStatus
+ .mockResolvedValueOnce({
+ status: 'running',
+ current: 0,
+ total: 0,
+ data: [],
+ })
+ .mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
+
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ limit: 5 }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://both-zero-test.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - should show progress indicator
+ await waitFor(() => {
+ expect(screen.getByText(/totalPageScraped/)).toBeInTheDocument()
+ })
+ })
+
+ it('should construct result item from direct data response', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const onCheckedCrawlResultChange = jest.fn()
+
+ mockCreateTask.mockResolvedValueOnce({
+ data: {
+ title: 'Direct Title',
+ content: '# Direct Content',
+ description: 'Direct desc',
+ url: 'https://direct-array.com',
+ },
+ })
+
+ const props = createDefaultProps({ onCheckedCrawlResultChange })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://direct-array.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - should construct result item from direct response
+ await waitFor(() => {
+ expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([
+ expect.objectContaining({
+ title: 'Direct Title',
+ source_url: 'https://direct-array.com',
+ }),
+ ])
+ })
+ })
+ })
+
+ // ============================================================================
+ // All Prop Variations Tests
+ // ============================================================================
+ describe('Prop Variations', () => {
+ it('should handle different limit values in crawlOptions', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: { title: 'T', content: 'C', description: 'D', url: 'https://limit.com' },
+ })
+
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ limit: 100 }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://limit.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ options: expect.objectContaining({ limit: 100 }),
+ }),
+ )
+ })
+ })
+
+ it('should handle different max_depth values', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: { title: 'T', content: 'C', description: 'D', url: 'https://depth.com' },
+ })
+
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ max_depth: 5 }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://depth.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ options: expect.objectContaining({ max_depth: 5 }),
+ }),
+ )
+ })
+ })
+
+ it('should handle crawl_sub_pages disabled', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: { title: 'T', content: 'C', description: 'D', url: 'https://nosub.com' },
+ })
+
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://nosub.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ options: expect.objectContaining({ crawl_sub_pages: false }),
+ }),
+ )
+ })
+ })
+
+ it('should handle use_sitemap enabled', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: { title: 'T', content: 'C', description: 'D', url: 'https://sitemap.com' },
+ })
+
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ use_sitemap: true }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://sitemap.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ options: expect.objectContaining({ use_sitemap: true }),
+ }),
+ )
+ })
+ })
+
+ it('should handle includes and excludes patterns', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: { title: 'T', content: 'C', description: 'D', url: 'https://patterns.com' },
+ })
+
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({
+ includes: '/docs/*',
+ excludes: '/api/*',
+ }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://patterns.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ options: expect.objectContaining({
+ includes: '/docs/*',
+ excludes: '/api/*',
+ }),
+ }),
+ )
+ })
+ })
+
+ it('should handle pre-selected crawl results', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const existingResult = createCrawlResultItem({ source_url: 'https://existing.com' })
+
+ mockCreateTask.mockResolvedValueOnce({
+ data: { title: 'New', content: 'C', description: 'D', url: 'https://new.com' },
+ })
+
+ const props = createDefaultProps({
+ checkedCrawlResult: [existingResult],
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://new.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalled()
+ })
+ })
+
+ it('should handle string type limit value', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ mockCreateTask.mockResolvedValueOnce({
+ data: { title: 'T', content: 'C', description: 'D', url: 'https://string-limit.com' },
+ })
+
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ limit: '25' }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://string-limit.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockCreateTask).toHaveBeenCalled()
+ })
+ })
+ })
+
+ // ============================================================================
+ // Display and UI State Tests
+ // ============================================================================
+ describe('Display and UI States', () => {
+ it('should show crawling progress during running state', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'progress-job' })
+ mockCheckStatus.mockImplementation(() => new Promise((_resolve) => { /* pending */ })) // Never resolves
+
+ const props = createDefaultProps({
+ crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://progress.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
+ })
+ })
+
+ it('should display time consumed after crawl completion', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({
+ data: { title: 'T', content: 'C', description: 'D', url: 'https://time.com' },
+ })
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://time.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument()
+ })
+ })
+
+ it('should display crawled results list after completion', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+
+ mockCreateTask.mockResolvedValueOnce({
+ data: {
+ title: 'Result Page',
+ content: '# Content',
+ description: 'Description',
+ url: 'https://result.com',
+ },
+ })
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://result.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('Result Page')).toBeInTheDocument()
+ })
+ })
+
+ it('should show error message component when crawl fails', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+
+ mockCreateTask.mockRejectedValueOnce(new Error('Failed'))
+ // Suppress console output during test to avoid noisy logs
+ jest.spyOn(console, 'log').mockImplementation(jest.fn())
+
+ const props = createDefaultProps()
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://fail.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // ============================================================================
+ // Integration Tests
+ // ============================================================================
+ describe('Integration', () => {
+ it('should complete full crawl workflow with job polling', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const mockCheckStatus = checkJinaReaderTaskStatus as jest.Mock
+ const onCheckedCrawlResultChange = jest.fn()
+ const onJobIdChange = jest.fn()
+ const onPreview = jest.fn()
+
+ mockCreateTask.mockResolvedValueOnce({ job_id: 'full-workflow-job' })
+ mockCheckStatus
+ .mockResolvedValueOnce({
+ status: 'running',
+ current: 2,
+ total: 5,
+ data: [
+ createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
+ createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
+ ],
+ })
+ .mockResolvedValueOnce({
+ status: 'completed',
+ current: 5,
+ total: 5,
+ time_consuming: 3.5,
+ data: [
+ createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
+ createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
+ createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }),
+ createCrawlResultItem({ source_url: 'https://page4.com', title: 'Page 4' }),
+ createCrawlResultItem({ source_url: 'https://page5.com', title: 'Page 5' }),
+ ],
+ })
+
+ const props = createDefaultProps({
+ onCheckedCrawlResultChange,
+ onJobIdChange,
+ onPreview,
+ })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://full-workflow.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Assert - job id should be set
+ await waitFor(() => {
+ expect(onJobIdChange).toHaveBeenCalledWith('full-workflow-job')
+ })
+
+ // Assert - final results should be displayed
+ await waitFor(() => {
+ expect(screen.getByText('Page 1')).toBeInTheDocument()
+ expect(screen.getByText('Page 5')).toBeInTheDocument()
+ })
+
+ // Assert - checked results should be updated
+ expect(onCheckedCrawlResultChange).toHaveBeenLastCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({ source_url: 'https://page1.com' }),
+ expect.objectContaining({ source_url: 'https://page5.com' }),
+ ]),
+ )
+ })
+
+ it('should handle select all and deselect all in results', async () => {
+ // Arrange
+ const mockCreateTask = createJinaReaderTask as jest.Mock
+ const onCheckedCrawlResultChange = jest.fn()
+
+ mockCreateTask.mockResolvedValueOnce({
+ data: { title: 'Single', content: 'C', description: 'D', url: 'https://single.com' },
+ })
+
+ const props = createDefaultProps({ onCheckedCrawlResultChange })
+
+ // Act
+ render( )
+ const input = screen.getByRole('textbox')
+ await userEvent.type(input, 'https://single.com')
+ await userEvent.click(screen.getByRole('button', { name: /run/i }))
+
+ // Wait for results
+ await waitFor(() => {
+ expect(screen.getByText('Single')).toBeInTheDocument()
+ })
+
+ // Click select all/reset all
+ const selectAllCheckbox = screen.getByText(/selectAll|resetAll/i)
+ await userEvent.click(selectAllCheckbox)
+
+ // Assert
+ expect(onCheckedCrawlResultChange).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/datasets/create/website/jina-reader/options.tsx b/web/app/components/datasets/create/website/jina-reader/options.tsx
index e18cff8d1a..3b8eab469e 100644
--- a/web/app/components/datasets/create/website/jina-reader/options.tsx
+++ b/web/app/components/datasets/create/website/jina-reader/options.tsx
@@ -4,7 +4,7 @@ import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import CheckboxWithLabel from '../base/checkbox-with-label'
import Field from '../base/field'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { CrawlOptions } from '@/models/datasets'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
@@ -37,6 +37,7 @@ const Options: FC = ({
isChecked={payload.crawl_sub_pages}
onChange={handleChange('crawl_sub_pages')}
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
+ testId='crawl-sub-pages'
/>
= ({
onChange={handleChange('use_sitemap')}
tooltip={t(`${I18N_PREFIX}.useSitemapTooltip`) as string}
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
+ testId='use-sitemap'
/>
({
+ createWatercrawlTask: jest.fn(),
+ checkWatercrawlTaskStatus: jest.fn(),
+}))
+
+jest.mock('@/utils', () => ({
+ sleep: jest.fn(() => Promise.resolve()),
+}))
+
+// Mock modal context
+const mockSetShowAccountSettingModal = jest.fn()
+jest.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowAccountSettingModal: mockSetShowAccountSettingModal,
+ }),
+}))
+
+// ============================================================================
+// Test Data Factories
+// ============================================================================
+
+// Note: limit and max_depth are typed as `number | string` in CrawlOptions
+// Tests may use number, string, or empty string values to cover all valid cases
+const createDefaultCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({
+ crawl_sub_pages: true,
+ only_main_content: true,
+ includes: '',
+ excludes: '',
+ limit: 10,
+ max_depth: 2,
+ use_sitemap: false,
+ ...overrides,
+})
+
+const createCrawlResultItem = (overrides: Partial