{
- 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
index a2e2527605..bdc991116c 100644
--- a/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx
+++ b/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react'
+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'
@@ -405,4 +405,276 @@ describe('EditAnnotationModal', () => {
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 (
+
+ )
+ })
+
+ 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 (
+
+ )
+ }
+
+ 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) => (
+
+
+
+))
+
+let latestListProps: any
+
+jest.mock('./list', () => (props: any) => {
+ latestListProps = props
+ if (!props.list.length)
+ return
+ return (
+
+
+
+
+
+ )
+})
+
+jest.mock('./view-annotation-modal', () => (props: any) => {
+ if (!props.isShow)
+ return null
+ return (
+
+
{props.item.question}
+
+
+
+ )
+})
+
+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}
+
+
+ ),
+ 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/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 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 (
+
+
+
+
+ )
+ },
+}))
+
+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
index 00c0776718..2ff1034537 100644
--- 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
@@ -4,12 +4,6 @@ import AgentSetting from './index'
import { MAX_ITERATIONS_NUM } from '@/config'
import type { AgentConfig } from '@/models/debug'
-jest.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
-}))
-
jest.mock('ahooks', () => {
const actual = jest.requireActual('ahooks')
return {
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.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx
index 4793b5fe49..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'
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.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.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.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 && }
+
+
+ ),
+}))
+
+jest.mock('./params-config', () => ({
+ __esModule: true,
+ default: ({ disabled, selectedDatasets }: any) => (
+
+ ),
+}))
+
+jest.mock('./context-var', () => ({
+ __esModule: true,
+ default: ({ value, options, onChange }: any) => (
+
+ ),
+}))
+
+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}
+
+
+
+
+
+
+ ),
+}))
+
+// 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) => (
+
+ )
+
+ 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 }) => (
+
+ ),
+}))
+
+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) => (
+
+ )
+
+ 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..e2c5307b03
--- /dev/null
+++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx
@@ -0,0 +1,537 @@
+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 { useMembers } from '@/service/use-common'
+import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app'
+import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
+
+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/use-common', () => ({
+ __esModule: true,
+ ...jest.requireActual('@/service/use-common'),
+ useMembers: 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 mockUseMembers = useMembers 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(
+
+
+ ,
+ )
+}
+
+const createMemberList = (): DataSet['partial_member_list'] => ([
+ 'member-2',
+])
+
+const renderSettingsModal = async (dataset: DataSet) => {
+ renderWithProviders(dataset)
+ await waitFor(() => expect(mockUseMembers).toHaveBeenCalled())
+}
+
+describe('SettingsModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockIsWorkspaceDatasetOperator = false
+ mockUseMembers.mockReturnValue({
+ data: {
+ 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',
+ },
+ ],
+ },
+ } as ReturnType)
+ 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)
+ mockUpdateDatasetSetting.mockResolvedValue(createDataset())
+ })
+
+ // Rendering and basic field bindings.
+ describe('Rendering', () => {
+ it('should render dataset details when dataset is provided', async () => {
+ // Arrange
+ const dataset = createDataset()
+
+ // Act
+ await renderSettingsModal(dataset)
+
+ // Assert
+ expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue('Test Dataset')
+ expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue('Description')
+ })
+
+ it('should show external knowledge info when dataset is external', async () => {
+ // Arrange
+ 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',
+ },
+ })
+
+ // Act
+ await renderSettingsModal(dataset)
+
+ // Assert
+ expect(screen.getByText('External Knowledge API')).toBeInTheDocument()
+ expect(screen.getByText('https://api.external.com')).toBeInTheDocument()
+ expect(screen.getByText('ext-id-123')).toBeInTheDocument()
+ })
+ })
+
+ // User interactions that update visible state.
+ describe('Interactions', () => {
+ it('should call onCancel when cancel button is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ await renderSettingsModal(createDataset())
+ await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+
+ // Assert
+ expect(mockOnCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should update name input when user types', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ await renderSettingsModal(createDataset())
+
+ const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
+
+ // Act
+ await user.clear(nameInput)
+ await user.type(nameInput, 'New Dataset Name')
+
+ // Assert
+ expect(nameInput).toHaveValue('New Dataset Name')
+ })
+
+ it('should update description input when user types', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ await renderSettingsModal(createDataset())
+
+ const descriptionInput = screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')
+
+ // Act
+ await user.clear(descriptionInput)
+ await user.type(descriptionInput, 'New description')
+
+ // Assert
+ expect(descriptionInput).toHaveValue('New description')
+ })
+
+ it('should show and dismiss retrieval change tip when indexing method changes', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const dataset = createDataset({ indexing_technique: IndexingType.ECONOMICAL })
+
+ // Act
+ await renderSettingsModal(dataset)
+ await user.click(screen.getByText('datasetCreation.stepTwo.qualified'))
+
+ // Assert
+ expect(await screen.findByText('appDebug.datasetConfig.retrieveChangeTip')).toBeInTheDocument()
+
+ // Act
+ await user.click(screen.getByLabelText('close-retrieval-change-tip'))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.queryByText('appDebug.datasetConfig.retrieveChangeTip')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should open account setting modal when embedding model tip is clicked', async () => {
+ // Arrange
+ const user = userEvent.setup()
+
+ // Act
+ await renderSettingsModal(createDataset())
+ await user.click(screen.getByText('datasetSettings.form.embeddingModelTipLink'))
+
+ // Assert
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
+ })
+ })
+
+ // Validation guardrails before saving.
+ describe('Validation', () => {
+ it('should block save when dataset name is empty', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ await renderSettingsModal(createDataset())
+
+ const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
+
+ // Act
+ await user.clear(nameInput)
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ // Assert
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'datasetSettings.form.nameError',
+ }))
+ expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
+ })
+
+ it('should block save when reranking is enabled without model', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ mockUseModelList.mockReturnValue({ data: [] })
+ const dataset = createDataset({}, createRetrievalConfig({
+ reranking_enable: true,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ }))
+
+ // Act
+ await renderSettingsModal(dataset)
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ // Assert
+ expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'appDebug.datasetConfig.rerankModelRequired',
+ }))
+ expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
+ })
+ })
+
+ // Save flows and side effects.
+ describe('Save', () => {
+ it('should save internal dataset changes when form is valid', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ 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,
+ })
+
+ // Act
+ await renderSettingsModal(dataset)
+
+ const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
+ await user.clear(nameInput)
+ await user.type(nameInput, 'Updated Internal Dataset')
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ // Assert
+ 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('should save external dataset changes when partial members configured', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ const dataset = createDataset({
+ provider: 'external',
+ permission: DatasetPermission.partialMembers,
+ partial_member_list: createMemberList(),
+ external_retrieval_model: {
+ top_k: 5,
+ score_threshold: 0.3,
+ score_threshold_enabled: true,
+ },
+ }, {
+ score_threshold_enabled: true,
+ score_threshold: 0.8,
+ })
+
+ // Act
+ await renderSettingsModal(dataset)
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ // Assert
+ 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('should disable save button while saving', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ mockUpdateDatasetSetting.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
+
+ // Act
+ await renderSettingsModal(createDataset())
+
+ const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
+ await user.click(saveButton)
+
+ // Assert
+ expect(saveButton).toBeDisabled()
+ })
+
+ it('should show error toast when save fails', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ mockUpdateDatasetSetting.mockRejectedValue(new Error('API Error'))
+
+ // Act
+ await renderSettingsModal(createDataset())
+ await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
+
+ // Assert
+ 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..c191ff5d46 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
@@ -1,13 +1,10 @@
import type { FC } from 'react'
-import { useMemo, useRef, useState } from 'react'
-import { useMount } from 'ahooks'
+import { useEffect, useMemo, useRef, useState } from 'react'
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,20 +15,17 @@ 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'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
-import { fetchMembers } from '@/service/common'
import type { Member } from '@/models/common'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { useDocLink } from '@/context/i18n'
+import { useMembers } from '@/service/use-common'
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
+import { RetrievalChangeTip, RetrievalSection } from './retrieval-section'
type SettingsModalProps = {
currentDataset: DataSet
@@ -68,6 +62,7 @@ const SettingsModal: FC = ({
const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(localeCurrentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
const [selectedMemberIDs, setSelectedMemberIDs] = useState(currentDataset.partial_member_list || [])
const [memberList, setMemberList] = useState([])
+ const { data: membersData } = useMembers()
const [indexMethod, setIndexMethod] = useState(currentDataset.indexing_technique)
const [retrievalConfig, setRetrievalConfig] = useState(localeCurrentDataset?.retrieval_model_dict as RetrievalConfig)
@@ -165,17 +160,12 @@ const SettingsModal: FC = ({
}
}
- const getMembers = async () => {
- const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} })
- if (!accounts)
+ useEffect(() => {
+ if (!membersData?.accounts)
setMemberList([])
else
- setMemberList(accounts)
- }
-
- useMount(() => {
- getMembers()
- })
+ setMemberList(membersData.accounts)
+ }, [membersData])
const showMultiModalTip = useMemo(() => {
return checkShowMultiModalTip({
@@ -298,92 +288,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 (
+
+
+
+
+ )
+}
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/index.tsx b/web/app/components/app/configuration/index.tsx
index 2537062e13..4da12319f2 100644
--- a/web/app/components/app/configuration/index.tsx
+++ b/web/app/components/app/configuration/index.tsx
@@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import useSWR from 'swr'
import { basePath } from '@/utils/var'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@@ -72,7 +71,7 @@ import type { Features as FeaturesData, FileUpload } from '@/app/components/base
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
-import { fetchFileUploadConfig } from '@/service/common'
+import { useFileUploadConfig } from '@/service/use-common'
import {
correctModelProvider,
correctToolProvider,
@@ -101,7 +100,7 @@ const Configuration: FC = () => {
showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal,
setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal,
})))
- const { data: fileUploadConfigResponse } = useSWR({ url: '/files/upload' }, fetchFileUploadConfig)
+ const { data: fileUploadConfigResponse } = useFileUploadConfig()
const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
const [formattingChanged, setFormattingChanged] = useState(false)
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/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx
index 990e679c79..6f177643ae 100644
--- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx
+++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx
@@ -1,6 +1,5 @@
import type { FC } from 'react'
import { useState } from 'react'
-import useSWR from 'swr'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation'
@@ -9,7 +8,6 @@ import Button from '@/app/components/base/button'
import EmojiPicker from '@/app/components/base/emoji-picker'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
-import { fetchCodeBasedExtensionList } from '@/service/common'
import { SimpleSelect } from '@/app/components/base/select'
import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
@@ -21,6 +19,7 @@ import { useToastContext } from '@/app/components/base/toast'
import AppIcon from '@/app/components/base/app-icon'
import { noop } from 'lodash-es'
import { useDocLink } from '@/context/i18n'
+import { useCodeBasedExtensions } from '@/service/use-common'
const systemTypes = ['api']
type ExternalDataToolModalProps = {
@@ -46,10 +45,7 @@ const ExternalDataToolModal: FC = ({
const { locale } = useContext(I18n)
const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' })
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
- const { data: codeBasedExtensionList } = useSWR(
- '/code-based-extension?module=external_data_tool',
- fetchCodeBasedExtensionList,
- )
+ const { data: codeBasedExtensionList } = useCodeBasedExtensions('external_data_tool')
const providers: Provider[] = [
{
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.spec.tsx b/web/app/components/app/duplicate-modal/index.spec.tsx
new file mode 100644
index 0000000000..2d73addeab
--- /dev/null
+++ b/web/app/components/app/duplicate-modal/index.spec.tsx
@@ -0,0 +1,167 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import DuplicateAppModal from './index'
+import Toast from '@/app/components/base/toast'
+import type { ProviderContextState } from '@/context/provider-context'
+import { baseProviderContextValue } from '@/context/provider-context'
+import { Plan } from '@/app/components/billing/type'
+
+const appsFullRenderSpy = jest.fn()
+jest.mock('@/app/components/billing/apps-full-in-dialog', () => ({
+ __esModule: true,
+ default: ({ loc }: { loc: string }) => {
+ appsFullRenderSpy(loc)
+ return AppsFull
+ },
+}))
+
+const useProviderContextMock = jest.fn()
+jest.mock('@/context/provider-context', () => {
+ const actual = jest.requireActual('@/context/provider-context')
+ return {
+ ...actual,
+ useProviderContext: () => useProviderContextMock(),
+ }
+})
+
+const renderComponent = (overrides: Partial> = {}) => {
+ const onConfirm = jest.fn().mockResolvedValue(undefined)
+ const onHide = jest.fn()
+ const props: React.ComponentProps = {
+ appName: 'My App',
+ icon_type: 'emoji',
+ icon: '🚀',
+ icon_background: '#FFEAD5',
+ icon_url: null,
+ show: true,
+ onConfirm,
+ onHide,
+ ...overrides,
+ }
+ const utils = render()
+ return {
+ ...utils,
+ onConfirm,
+ onHide,
+ }
+}
+
+const setupProviderContext = (overrides: Partial = {}) => {
+ useProviderContextMock.mockReturnValue({
+ ...baseProviderContextValue,
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.sandbox,
+ usage: {
+ ...baseProviderContextValue.plan.usage,
+ buildApps: 0,
+ },
+ total: {
+ ...baseProviderContextValue.plan.total,
+ buildApps: 10,
+ },
+ },
+ enableBilling: false,
+ ...overrides,
+ } as ProviderContextState)
+}
+
+describe('DuplicateAppModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ setupProviderContext()
+ })
+
+ // Rendering output based on modal visibility.
+ describe('Rendering', () => {
+ it('should render modal content when show is true', () => {
+ // Arrange
+ renderComponent()
+
+ // Assert
+ expect(screen.getByText('app.duplicateTitle')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('My App')).toBeInTheDocument()
+ })
+
+ it('should not render modal content when show is false', () => {
+ // Arrange
+ renderComponent({ show: false })
+
+ // Assert
+ expect(screen.queryByText('app.duplicateTitle')).not.toBeInTheDocument()
+ })
+ })
+
+ // Prop-driven states such as full plan handling.
+ describe('Props', () => {
+ it('should disable duplicate button and show apps full content when plan is full', () => {
+ // Arrange
+ setupProviderContext({
+ enableBilling: true,
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.sandbox,
+ usage: { ...baseProviderContextValue.plan.usage, buildApps: 10 },
+ total: { ...baseProviderContextValue.plan.total, buildApps: 10 },
+ },
+ })
+ renderComponent()
+
+ // Assert
+ expect(screen.getByTestId('apps-full')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'app.duplicate' })).toBeDisabled()
+ })
+ })
+
+ // User interactions for cancel and confirm flows.
+ describe('Interactions', () => {
+ it('should call onHide when cancel is clicked', async () => {
+ const user = userEvent.setup()
+ // Arrange
+ const { onHide } = renderComponent()
+
+ // Act
+ await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+
+ // Assert
+ expect(onHide).toHaveBeenCalledTimes(1)
+ })
+
+ it('should show error toast when name is empty', async () => {
+ const user = userEvent.setup()
+ const toastSpy = jest.spyOn(Toast, 'notify')
+ // Arrange
+ const { onConfirm, onHide } = renderComponent()
+
+ // Act
+ await user.clear(screen.getByDisplayValue('My App'))
+ await user.click(screen.getByRole('button', { name: 'app.duplicate' }))
+
+ // Assert
+ expect(toastSpy).toHaveBeenCalledWith({ type: 'error', message: 'explore.appCustomize.nameRequired' })
+ expect(onConfirm).not.toHaveBeenCalled()
+ expect(onHide).not.toHaveBeenCalled()
+ })
+
+ it('should submit app info and hide modal when duplicate is clicked', async () => {
+ const user = userEvent.setup()
+ // Arrange
+ const { onConfirm, onHide } = renderComponent()
+
+ // Act
+ await user.clear(screen.getByDisplayValue('My App'))
+ await user.type(screen.getByRole('textbox'), 'New App')
+ await user.click(screen.getByRole('button', { name: 'app.duplicate' }))
+
+ // Assert
+ expect(onConfirm).toHaveBeenCalledWith({
+ name: 'New App',
+ icon_type: 'emoji',
+ icon: '🚀',
+ icon_background: '#FFEAD5',
+ })
+ expect(onHide).toHaveBeenCalledTimes(1)
+ })
+ })
+})
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.spec.tsx b/web/app/components/app/switch-app-modal/index.spec.tsx
new file mode 100644
index 0000000000..b6fe838666
--- /dev/null
+++ b/web/app/components/app/switch-app-modal/index.spec.tsx
@@ -0,0 +1,295 @@
+import React from 'react'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import SwitchAppModal from './index'
+import { ToastContext } from '@/app/components/base/toast'
+import type { App } from '@/types/app'
+import { AppModeEnum } from '@/types/app'
+import { Plan } from '@/app/components/billing/type'
+import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
+
+const mockPush = jest.fn()
+const mockReplace = jest.fn()
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: mockPush,
+ replace: mockReplace,
+ }),
+}))
+
+const mockSetAppDetail = jest.fn()
+jest.mock('@/app/components/app/store', () => ({
+ useStore: (selector: (state: any) => unknown) => selector({ setAppDetail: mockSetAppDetail }),
+}))
+
+const mockSwitchApp = jest.fn()
+const mockDeleteApp = jest.fn()
+jest.mock('@/service/apps', () => ({
+ switchApp: (...args: unknown[]) => mockSwitchApp(...args),
+ deleteApp: (...args: unknown[]) => mockDeleteApp(...args),
+}))
+
+let mockIsEditor = true
+jest.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ isCurrentWorkspaceEditor: mockIsEditor,
+ userProfile: {
+ email: 'user@example.com',
+ },
+ langGeniusVersionInfo: {
+ current_version: '1.0.0',
+ },
+ }),
+}))
+
+let mockEnableBilling = false
+let mockPlan = {
+ type: Plan.sandbox,
+ usage: {
+ buildApps: 0,
+ teamMembers: 0,
+ annotatedResponse: 0,
+ documentsUploadQuota: 0,
+ apiRateLimit: 0,
+ triggerEvents: 0,
+ vectorSpace: 0,
+ },
+ total: {
+ buildApps: 10,
+ teamMembers: 0,
+ annotatedResponse: 0,
+ documentsUploadQuota: 0,
+ apiRateLimit: 0,
+ triggerEvents: 0,
+ vectorSpace: 0,
+ },
+}
+jest.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ plan: mockPlan,
+ enableBilling: mockEnableBilling,
+ }),
+}))
+
+jest.mock('@/app/components/billing/apps-full-in-dialog', () => ({
+ __esModule: true,
+ default: ({ loc }: { loc: string }) => AppsFull {loc}
,
+}))
+
+const createMockApp = (overrides: Partial = {}): App => ({
+ id: 'app-123',
+ name: 'Demo App',
+ description: 'Demo description',
+ author_name: 'Demo author',
+ icon_type: 'emoji',
+ icon: '🚀',
+ icon_background: '#FFEAD5',
+ icon_url: null,
+ use_icon_as_answer_icon: false,
+ mode: AppModeEnum.COMPLETION,
+ enable_site: true,
+ enable_api: true,
+ api_rpm: 60,
+ api_rph: 3600,
+ is_demo: false,
+ model_config: {} as App['model_config'],
+ app_model_config: {} as App['app_model_config'],
+ created_at: Date.now(),
+ updated_at: Date.now(),
+ site: {
+ access_token: 'token',
+ app_base_url: 'https://example.com',
+ } as App['site'],
+ api_base_url: 'https://api.example.com',
+ tags: [],
+ access_mode: 'public_access' as App['access_mode'],
+ ...overrides,
+})
+
+const renderComponent = (overrides: Partial> = {}) => {
+ const notify = jest.fn()
+ const onClose = jest.fn()
+ const onSuccess = jest.fn()
+ const appDetail = createMockApp()
+
+ const utils = render(
+
+
+ ,
+ )
+
+ return {
+ ...utils,
+ notify,
+ onClose,
+ onSuccess,
+ appDetail,
+ }
+}
+
+describe('SwitchAppModal', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockIsEditor = true
+ mockEnableBilling = false
+ mockPlan = {
+ type: Plan.sandbox,
+ usage: {
+ buildApps: 0,
+ teamMembers: 0,
+ annotatedResponse: 0,
+ documentsUploadQuota: 0,
+ apiRateLimit: 0,
+ triggerEvents: 0,
+ vectorSpace: 0,
+ },
+ total: {
+ buildApps: 10,
+ teamMembers: 0,
+ annotatedResponse: 0,
+ documentsUploadQuota: 0,
+ apiRateLimit: 0,
+ triggerEvents: 0,
+ vectorSpace: 0,
+ },
+ }
+ })
+
+ // Rendering behavior for modal visibility and default values.
+ describe('Rendering', () => {
+ it('should render modal content when show is true', () => {
+ // Arrange
+ renderComponent()
+
+ // Assert
+ expect(screen.getByText('app.switch')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('Demo App(copy)')).toBeInTheDocument()
+ })
+
+ it('should not render modal content when show is false', () => {
+ // Arrange
+ renderComponent({ show: false })
+
+ // Assert
+ expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
+ })
+ })
+
+ // Prop-driven UI states such as disabling actions.
+ describe('Props', () => {
+ it('should disable the start button when name is empty', async () => {
+ const user = userEvent.setup()
+ // Arrange
+ renderComponent()
+
+ // Act
+ const nameInput = screen.getByDisplayValue('Demo App(copy)')
+ await user.clear(nameInput)
+
+ // Assert
+ expect(screen.getByRole('button', { name: 'app.switchStart' })).toBeDisabled()
+ })
+
+ it('should render the apps full warning when plan limits are reached', () => {
+ // Arrange
+ mockEnableBilling = true
+ mockPlan = {
+ ...mockPlan,
+ usage: { ...mockPlan.usage, buildApps: 10 },
+ total: { ...mockPlan.total, buildApps: 10 },
+ }
+ renderComponent()
+
+ // Assert
+ expect(screen.getByTestId('apps-full')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'app.switchStart' })).toBeDisabled()
+ })
+ })
+
+ // User interactions that trigger navigation and API calls.
+ describe('Interactions', () => {
+ it('should call onClose when cancel is clicked', async () => {
+ const user = userEvent.setup()
+ // Arrange
+ const { onClose } = renderComponent()
+
+ // Act
+ await user.click(screen.getByRole('button', { name: 'app.newApp.Cancel' }))
+
+ // Assert
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should switch app and navigate with push when keeping original', async () => {
+ const user = userEvent.setup()
+ // Arrange
+ const { appDetail, notify, onClose, onSuccess } = renderComponent()
+ mockSwitchApp.mockResolvedValueOnce({ new_app_id: 'new-app-001' })
+ const setItemSpy = jest.spyOn(Storage.prototype, 'setItem')
+
+ // Act
+ await user.click(screen.getByRole('button', { name: 'app.switchStart' }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockSwitchApp).toHaveBeenCalledWith({
+ appID: appDetail.id,
+ name: 'Demo App(copy)',
+ icon_type: 'emoji',
+ icon: '🚀',
+ icon_background: '#FFEAD5',
+ })
+ })
+ expect(onSuccess).toHaveBeenCalledTimes(1)
+ expect(onClose).toHaveBeenCalledTimes(1)
+ expect(notify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' })
+ expect(setItemSpy).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1')
+ expect(mockPush).toHaveBeenCalledWith('/app/new-app-001/workflow')
+ expect(mockReplace).not.toHaveBeenCalled()
+ })
+
+ it('should delete the original app and use replace when remove original is confirmed', async () => {
+ const user = userEvent.setup()
+ // Arrange
+ const { appDetail } = renderComponent({ inAppDetail: true })
+ mockSwitchApp.mockResolvedValueOnce({ new_app_id: 'new-app-002' })
+
+ // Act
+ await user.click(screen.getByText('app.removeOriginal'))
+ const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
+ await user.click(confirmButton)
+ await user.click(screen.getByRole('button', { name: 'app.switchStart' }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockDeleteApp).toHaveBeenCalledWith(appDetail.id)
+ })
+ expect(mockReplace).toHaveBeenCalledWith('/app/new-app-002/workflow')
+ expect(mockPush).not.toHaveBeenCalled()
+ expect(mockSetAppDetail).toHaveBeenCalledTimes(1)
+ })
+
+ it('should notify error when switch app fails', async () => {
+ const user = userEvent.setup()
+ // Arrange
+ const { notify, onClose, onSuccess } = renderComponent()
+ mockSwitchApp.mockRejectedValueOnce(new Error('fail'))
+
+ // Act
+ await user.click(screen.getByRole('button', { name: 'app.switchStart' }))
+
+ // Assert
+ await waitFor(() => {
+ expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' })
+ })
+ expect(onClose).not.toHaveBeenCalled()
+ expect(onSuccess).not.toHaveBeenCalled()
+ })
+ })
+})
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 && (
+
+ )}
diff --git a/web/app/components/app/workflow-log/filter.spec.tsx b/web/app/components/app/workflow-log/filter.spec.tsx
index d7bec41224..04216e5cc8 100644
--- a/web/app/components/app/workflow-log/filter.spec.tsx
+++ b/web/app/components/app/workflow-log/filter.spec.tsx
@@ -7,6 +7,7 @@
* - Keyword search
*/
+import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Filter, { TIME_PERIOD_MAPPING } from './filter'
@@ -293,12 +294,21 @@ describe('Filter', () => {
const user = userEvent.setup()
const setQueryParams = jest.fn()
- render(
- ,
- )
+ const Wrapper = () => {
+ const [queryParams, updateQueryParams] = useState(createDefaultQueryParams())
+ const handleSetQueryParams = (next: QueryParam) => {
+ updateQueryParams(next)
+ setQueryParams(next)
+ }
+ return (
+
+ )
+ }
+
+ render()
const input = screen.getByPlaceholderText('common.operation.search')
await user.type(input, 'workflow')
diff --git a/web/app/components/app/workflow-log/filter.tsx b/web/app/components/app/workflow-log/filter.tsx
index 0c8d72c1be..a4db4c9642 100644
--- a/web/app/components/app/workflow-log/filter.tsx
+++ b/web/app/components/app/workflow-log/filter.tsx
@@ -65,7 +65,7 @@ const Filter: FC = ({ queryParams, setQueryParams }: IFilterProps)
wrapperClassName='w-[200px]'
showLeftIcon
showClearIcon
- value={queryParams.keyword}
+ value={queryParams.keyword ?? ''}
placeholder={t('common.operation.search')!}
onChange={(e) => {
setQueryParams({ ...queryParams, keyword: e.target.value })
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.spec.tsx b/web/app/components/apps/app-card.spec.tsx
index 40aa66075d..f7ff525ed2 100644
--- a/web/app/components/apps/app-card.spec.tsx
+++ b/web/app/components/apps/app-card.spec.tsx
@@ -42,11 +42,12 @@ jest.mock('@/context/provider-context', () => ({
}),
}))
-// Mock global public store
+// Mock global public store - allow dynamic configuration
+let mockWebappAuthEnabled = false
jest.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (s: any) => any) => selector({
systemFeatures: {
- webapp_auth: { enabled: false },
+ webapp_auth: { enabled: mockWebappAuthEnabled },
branding: { enabled: false },
},
}),
@@ -79,8 +80,9 @@ jest.mock('@/service/access-control', () => ({
}))
// Mock hooks
+const mockOpenAsyncWindow = jest.fn()
jest.mock('@/hooks/use-async-window-open', () => ({
- useAsyncWindowOpen: () => jest.fn(),
+ useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
// Mock utils
@@ -178,21 +180,10 @@ jest.mock('next/dynamic', () => {
}
})
-/**
- * Mock components that require special handling in test environment.
- *
- * Per frontend testing skills (mocking.md), we should NOT mock simple base components.
- * However, the following require mocking due to:
- * - Portal-based rendering that doesn't work well in happy-dom
- * - Deep dependency chains importing ES modules (like ky) incompatible with Jest
- * - Complex state management that requires controlled test behavior
- */
-
-// Popover uses portals for positioning which requires mocking in happy-dom environment
+// Popover uses @headlessui/react portals - mock for controlled interaction testing
jest.mock('@/app/components/base/popover', () => {
const MockPopover = ({ htmlContent, btnElement, btnClassName }: any) => {
const [isOpen, setIsOpen] = React.useState(false)
- // Call btnClassName to cover lines 430-433
const computedClassName = typeof btnClassName === 'function' ? btnClassName(isOpen) : ''
return React.createElement('div', { 'data-testid': 'custom-popover', 'className': computedClassName },
React.createElement('div', {
@@ -210,13 +201,13 @@ jest.mock('@/app/components/base/popover', () => {
return { __esModule: true, default: MockPopover }
})
-// Tooltip uses portals for positioning - minimal mock preserving popup content as title attribute
+// Tooltip uses portals - minimal mock preserving popup content as title attribute
jest.mock('@/app/components/base/tooltip', () => ({
__esModule: true,
default: ({ children, popupContent }: any) => React.createElement('div', { title: popupContent }, children),
}))
-// TagSelector imports service/tag which depends on ky ES module - mock to avoid Jest ES module issues
+// TagSelector has API dependency (service/tag) - mock for isolated testing
jest.mock('@/app/components/base/tag-management/selector', () => ({
__esModule: true,
default: ({ tags }: any) => {
@@ -227,7 +218,7 @@ jest.mock('@/app/components/base/tag-management/selector', () => ({
},
}))
-// AppTypeIcon has complex icon mapping logic - mock for focused component testing
+// AppTypeIcon has complex icon mapping - mock for focused component testing
jest.mock('@/app/components/app/type-selector', () => ({
AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }),
}))
@@ -278,6 +269,8 @@ describe('AppCard', () => {
beforeEach(() => {
jest.clearAllMocks()
+ mockOpenAsyncWindow.mockReset()
+ mockWebappAuthEnabled = false
})
describe('Rendering', () => {
@@ -536,6 +529,46 @@ describe('AppCard', () => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
+
+ it('should close edit modal when onHide is called', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('app.editApp'))
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument()
+ })
+
+ // Click close button to trigger onHide
+ fireEvent.click(screen.getByTestId('close-edit-modal'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('edit-app-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should close duplicate modal when onHide is called', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('app.duplicate'))
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('duplicate-modal')).toBeInTheDocument()
+ })
+
+ // Click close button to trigger onHide
+ fireEvent.click(screen.getByTestId('close-duplicate-modal'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('duplicate-modal')).not.toBeInTheDocument()
+ })
+ })
})
describe('Styling', () => {
@@ -852,6 +885,31 @@ describe('AppCard', () => {
expect(workflowService.fetchWorkflowDraft).toHaveBeenCalled()
})
})
+
+ it('should close DSL export modal when onClose is called', async () => {
+ (workflowService.fetchWorkflowDraft as jest.Mock).mockResolvedValueOnce({
+ environment_variables: [{ value_type: 'secret', name: 'API_KEY' }],
+ })
+
+ const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('app.export'))
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('dsl-export-modal')).toBeInTheDocument()
+ })
+
+ // Click close button to trigger onClose
+ fireEvent.click(screen.getByTestId('close-dsl-export'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('dsl-export-modal')).not.toBeInTheDocument()
+ })
+ })
})
describe('Edge Cases', () => {
@@ -1054,6 +1112,276 @@ describe('AppCard', () => {
const tagSelector = screen.getByLabelText('tag-selector')
expect(tagSelector).toBeInTheDocument()
+
+ // Click on tag selector wrapper to trigger stopPropagation
+ const tagSelectorWrapper = tagSelector.closest('div')
+ if (tagSelectorWrapper)
+ fireEvent.click(tagSelectorWrapper)
+ })
+
+ it('should handle popover mouse leave', async () => {
+ render()
+
+ // Open popover
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ expect(screen.getByTestId('popover-content')).toBeInTheDocument()
+ })
+
+ // Trigger mouse leave on the outer popover-content
+ fireEvent.mouseLeave(screen.getByTestId('popover-content'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should handle operations menu mouse leave', async () => {
+ render()
+
+ // Open popover
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ expect(screen.getByText('app.editApp')).toBeInTheDocument()
+ })
+
+ // Find the Operations wrapper div (contains the menu items)
+ const editButton = screen.getByText('app.editApp')
+ const operationsWrapper = editButton.closest('div.relative')
+
+ // Trigger mouse leave on the Operations wrapper to call onMouseLeave
+ if (operationsWrapper)
+ fireEvent.mouseLeave(operationsWrapper)
+ })
+
+ it('should click open in explore button', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ const openInExploreBtn = screen.getByText('app.openInExplore')
+ fireEvent.click(openInExploreBtn)
+ })
+
+ // Verify openAsyncWindow was called with callback and options
+ await waitFor(() => {
+ expect(mockOpenAsyncWindow).toHaveBeenCalledWith(
+ expect.any(Function),
+ expect.objectContaining({ onError: expect.any(Function) }),
+ )
+ })
+ })
+
+ it('should handle open in explore via async window', async () => {
+ // Configure mockOpenAsyncWindow to actually call the callback
+ mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise) => {
+ await callback()
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ const openInExploreBtn = screen.getByText('app.openInExplore')
+ fireEvent.click(openInExploreBtn)
+ })
+
+ const { fetchInstalledAppList } = require('@/service/explore')
+ await waitFor(() => {
+ expect(fetchInstalledAppList).toHaveBeenCalledWith(mockApp.id)
+ })
+ })
+
+ it('should handle open in explore API failure', async () => {
+ const { fetchInstalledAppList } = require('@/service/explore')
+ fetchInstalledAppList.mockRejectedValueOnce(new Error('API Error'))
+
+ // Configure mockOpenAsyncWindow to call the callback and trigger error
+ mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise, options: any) => {
+ try {
+ await callback()
+ }
+ catch (err) {
+ options?.onError?.(err)
+ }
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ const openInExploreBtn = screen.getByText('app.openInExplore')
+ fireEvent.click(openInExploreBtn)
+ })
+
+ await waitFor(() => {
+ expect(fetchInstalledAppList).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('Access Control', () => {
+ it('should render operations menu correctly', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ expect(screen.getByText('app.editApp')).toBeInTheDocument()
+ expect(screen.getByText('app.duplicate')).toBeInTheDocument()
+ expect(screen.getByText('app.export')).toBeInTheDocument()
+ expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Open in Explore - No App Found', () => {
+ it('should handle case when installed_apps is empty array', async () => {
+ const { fetchInstalledAppList } = require('@/service/explore')
+ fetchInstalledAppList.mockResolvedValueOnce({ installed_apps: [] })
+
+ // Configure mockOpenAsyncWindow to call the callback and trigger error
+ mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise, options: any) => {
+ try {
+ await callback()
+ }
+ catch (err) {
+ options?.onError?.(err)
+ }
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ const openInExploreBtn = screen.getByText('app.openInExplore')
+ fireEvent.click(openInExploreBtn)
+ })
+
+ await waitFor(() => {
+ expect(fetchInstalledAppList).toHaveBeenCalled()
+ })
+ })
+
+ it('should handle case when API throws in callback', async () => {
+ const { fetchInstalledAppList } = require('@/service/explore')
+ fetchInstalledAppList.mockRejectedValueOnce(new Error('Network error'))
+
+ // Configure mockOpenAsyncWindow to call the callback without catching
+ mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise) => {
+ return await callback()
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ const openInExploreBtn = screen.getByText('app.openInExplore')
+ fireEvent.click(openInExploreBtn)
+ })
+
+ await waitFor(() => {
+ expect(fetchInstalledAppList).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('Draft Trigger Apps', () => {
+ it('should not show open in explore option for apps with has_draft_trigger', async () => {
+ const draftTriggerApp = createMockApp({ has_draft_trigger: true })
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ expect(screen.getByText('app.editApp')).toBeInTheDocument()
+ // openInExplore should not be shown for draft trigger apps
+ expect(screen.queryByText('app.openInExplore')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Non-editor User', () => {
+ it('should handle non-editor workspace users', () => {
+ // This tests the isCurrentWorkspaceEditor=true branch (default mock)
+ render()
+ expect(screen.getByTitle('Test App')).toBeInTheDocument()
+ })
+ })
+
+ describe('WebApp Auth Enabled', () => {
+ beforeEach(() => {
+ mockWebappAuthEnabled = true
+ })
+
+ it('should show access control option when webapp_auth is enabled', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ expect(screen.getByText('app.accessControl')).toBeInTheDocument()
+ })
+ })
+
+ it('should click access control button', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ const accessControlBtn = screen.getByText('app.accessControl')
+ fireEvent.click(accessControlBtn)
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('access-control-modal')).toBeInTheDocument()
+ })
+ })
+
+ it('should close access control modal and call onRefresh', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('app.accessControl'))
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('access-control-modal')).toBeInTheDocument()
+ })
+
+ // Confirm access control
+ fireEvent.click(screen.getByTestId('confirm-access-control'))
+
+ await waitFor(() => {
+ expect(mockOnRefresh).toHaveBeenCalled()
+ })
+ })
+
+ it('should show open in explore when userCanAccessApp is true', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ expect(screen.getByText('app.openInExplore')).toBeInTheDocument()
+ })
+ })
+
+ it('should close access control modal when onClose is called', async () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('popover-trigger'))
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('app.accessControl'))
+ })
+
+ await waitFor(() => {
+ expect(screen.getByTestId('access-control-modal')).toBeInTheDocument()
+ })
+
+ // Click close button to trigger onClose
+ fireEvent.click(screen.getByTestId('close-access-control'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument()
+ })
})
})
})
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/list.spec.tsx b/web/app/components/apps/list.spec.tsx
index fe664a4a50..3bc8a27375 100644
--- a/web/app/components/apps/list.spec.tsx
+++ b/web/app/components/apps/list.spec.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { act, fireEvent, render, screen } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
// Mock next/navigation
@@ -28,20 +28,29 @@ jest.mock('@/context/global-public-context', () => ({
}),
}))
-// Mock custom hooks
+// Mock custom hooks - allow dynamic query state
const mockSetQuery = jest.fn()
+const mockQueryState = {
+ tagIDs: [] as string[],
+ keywords: '',
+ isCreatedByMe: false,
+}
jest.mock('./hooks/use-apps-query-state', () => ({
__esModule: true,
default: () => ({
- query: { tagIDs: [], keywords: '', isCreatedByMe: false },
+ query: mockQueryState,
setQuery: mockSetQuery,
}),
}))
+// Store callback for testing DSL file drop
+let mockOnDSLFileDropped: ((file: File) => void) | null = null
+let mockDragging = false
jest.mock('./hooks/use-dsl-drag-drop', () => ({
- useDSLDragDrop: () => ({
- dragging: false,
- }),
+ useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
+ mockOnDSLFileDropped = onDSLFileDropped
+ return { dragging: mockDragging }
+ },
}))
const mockSetActiveTab = jest.fn()
@@ -49,55 +58,90 @@ jest.mock('@/hooks/use-tab-searchparams', () => ({
useTabSearchParams: () => ['all', mockSetActiveTab],
}))
-// Mock service hooks
+// Mock service hooks - use object for mutable state (jest.mock is hoisted)
const mockRefetch = jest.fn()
+const mockFetchNextPage = jest.fn()
+
+const mockServiceState = {
+ error: null as Error | null,
+ hasNextPage: false,
+ isLoading: false,
+ isFetchingNextPage: false,
+}
+
+const defaultAppData = {
+ pages: [{
+ data: [
+ {
+ id: 'app-1',
+ name: 'Test App 1',
+ description: 'Description 1',
+ mode: AppModeEnum.CHAT,
+ icon: '🤖',
+ icon_type: 'emoji',
+ icon_background: '#FFEAD5',
+ tags: [],
+ author_name: 'Author 1',
+ created_at: 1704067200,
+ updated_at: 1704153600,
+ },
+ {
+ id: 'app-2',
+ name: 'Test App 2',
+ description: 'Description 2',
+ mode: AppModeEnum.WORKFLOW,
+ icon: '⚙️',
+ icon_type: 'emoji',
+ icon_background: '#E4FBCC',
+ tags: [],
+ author_name: 'Author 2',
+ created_at: 1704067200,
+ updated_at: 1704153600,
+ },
+ ],
+ total: 2,
+ }],
+}
+
jest.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
- data: {
- pages: [{
- data: [
- {
- id: 'app-1',
- name: 'Test App 1',
- description: 'Description 1',
- mode: AppModeEnum.CHAT,
- icon: '🤖',
- icon_type: 'emoji',
- icon_background: '#FFEAD5',
- tags: [],
- author_name: 'Author 1',
- created_at: 1704067200,
- updated_at: 1704153600,
- },
- {
- id: 'app-2',
- name: 'Test App 2',
- description: 'Description 2',
- mode: AppModeEnum.WORKFLOW,
- icon: '⚙️',
- icon_type: 'emoji',
- icon_background: '#E4FBCC',
- tags: [],
- author_name: 'Author 2',
- created_at: 1704067200,
- updated_at: 1704153600,
- },
- ],
- total: 2,
- }],
- },
- isLoading: false,
- isFetchingNextPage: false,
- fetchNextPage: jest.fn(),
- hasNextPage: false,
- error: null,
+ data: defaultAppData,
+ isLoading: mockServiceState.isLoading,
+ isFetchingNextPage: mockServiceState.isFetchingNextPage,
+ fetchNextPage: mockFetchNextPage,
+ hasNextPage: mockServiceState.hasNextPage,
+ error: mockServiceState.error,
refetch: mockRefetch,
}),
}))
// Mock tag store
jest.mock('@/app/components/base/tag-management/store', () => ({
- useStore: () => false,
+ useStore: (selector: (state: { tagList: any[]; setTagList: any; showTagManagementModal: boolean; setShowTagManagementModal: any }) => any) => {
+ const state = {
+ tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app' }],
+ setTagList: jest.fn(),
+ showTagManagementModal: false,
+ setShowTagManagementModal: jest.fn(),
+ }
+ return selector(state)
+ },
+}))
+
+// Mock tag service to avoid API calls in TagFilter
+jest.mock('@/service/tag', () => ({
+ fetchTagList: jest.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
+}))
+
+// Store TagFilter onChange callback for testing
+let mockTagFilterOnChange: ((value: string[]) => void) | null = null
+jest.mock('@/app/components/base/tag-management/filter', () => ({
+ __esModule: true,
+ default: ({ onChange }: { onChange: (value: string[]) => void }) => {
+ const React = require('react')
+ mockTagFilterOnChange = onChange
+ return React.createElement('div', { 'data-testid': 'tag-filter' }, 'common.tag.placeholder')
+ },
}))
// Mock config
@@ -110,9 +154,17 @@ jest.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
-// Mock debounce hook
+// Mock ahooks - useMount only executes once on mount, not on fn change
jest.mock('ahooks', () => ({
useDebounceFn: (fn: () => void) => ({ run: fn }),
+ useMount: (fn: () => void) => {
+ const React = require('react')
+ const fnRef = React.useRef(fn)
+ fnRef.current = fn
+ React.useEffect(() => {
+ fnRef.current()
+ }, [])
+ },
}))
// Mock dynamic imports
@@ -127,10 +179,11 @@ jest.mock('next/dynamic', () => {
}
}
if (fnString.includes('create-from-dsl-modal')) {
- return function MockCreateFromDSLModal({ show, onClose }: any) {
+ return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) {
if (!show) return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'),
+ React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'),
)
}
}
@@ -174,127 +227,83 @@ jest.mock('./footer', () => ({
},
}))
-/**
- * Mock base components that have deep dependency chains or require controlled test behavior.
- *
- * Per frontend testing skills (mocking.md), we generally should NOT mock base components.
- * However, the following require mocking due to:
- * - Deep dependency chains importing ES modules (like ky) incompatible with Jest
- * - Need for controlled interaction behavior in tests (onChange, onClear handlers)
- * - Complex internal state that would make tests flaky
- *
- * These mocks preserve the component's props interface to test List's integration correctly.
- */
-jest.mock('@/app/components/base/tab-slider-new', () => ({
- __esModule: true,
- default: ({ value, onChange, options }: any) => {
- const React = require('react')
- return React.createElement('div', { 'data-testid': 'tab-slider', 'role': 'tablist' },
- options.map((opt: any) =>
- React.createElement('button', {
- 'key': opt.value,
- 'data-testid': `tab-${opt.value}`,
- 'role': 'tab',
- 'aria-selected': value === opt.value,
- 'onClick': () => onChange(opt.value),
- }, opt.text),
- ),
- )
- },
-}))
-
-jest.mock('@/app/components/base/input', () => ({
- __esModule: true,
- default: ({ value, onChange, onClear }: any) => {
- const React = require('react')
- return React.createElement('div', { 'data-testid': 'search-input' },
- React.createElement('input', {
- 'data-testid': 'search-input-field',
- 'role': 'searchbox',
- 'value': value || '',
- onChange,
- }),
- React.createElement('button', {
- 'data-testid': 'clear-search',
- 'aria-label': 'Clear search',
- 'onClick': onClear,
- }, 'Clear'),
- )
- },
-}))
-
-jest.mock('@/app/components/base/tag-management/filter', () => ({
- __esModule: true,
- default: ({ value, onChange }: any) => {
- const React = require('react')
- return React.createElement('div', { 'data-testid': 'tag-filter', 'role': 'listbox' },
- React.createElement('button', {
- 'data-testid': 'add-tag-filter',
- 'onClick': () => onChange([...value, 'new-tag']),
- }, 'Add Tag'),
- )
- },
-}))
-
-jest.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
- __esModule: true,
- default: ({ label, isChecked, onChange }: any) => {
- const React = require('react')
- return React.createElement('label', { 'data-testid': 'created-by-me-checkbox' },
- React.createElement('input', {
- 'type': 'checkbox',
- 'role': 'checkbox',
- 'checked': isChecked,
- 'aria-checked': isChecked,
- onChange,
- 'data-testid': 'created-by-me-input',
- }),
- label,
- )
- },
-}))
-
// Import after mocks
import List from './list'
+// Store IntersectionObserver callback
+let intersectionCallback: IntersectionObserverCallback | null = null
+const mockObserve = jest.fn()
+const mockDisconnect = jest.fn()
+
+// Mock IntersectionObserver
+beforeAll(() => {
+ globalThis.IntersectionObserver = class MockIntersectionObserver {
+ constructor(callback: IntersectionObserverCallback) {
+ intersectionCallback = callback
+ }
+
+ observe = mockObserve
+ disconnect = mockDisconnect
+ unobserve = jest.fn()
+ root = null
+ rootMargin = ''
+ thresholds = []
+ takeRecords = () => []
+ } as unknown as typeof IntersectionObserver
+})
+
describe('List', () => {
beforeEach(() => {
jest.clearAllMocks()
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
+ mockDragging = false
+ mockOnDSLFileDropped = null
+ mockTagFilterOnChange = null
+ mockServiceState.error = null
+ mockServiceState.hasNextPage = false
+ mockServiceState.isLoading = false
+ mockServiceState.isFetchingNextPage = false
+ mockQueryState.tagIDs = []
+ mockQueryState.keywords = ''
+ mockQueryState.isCreatedByMe = false
+ intersectionCallback = null
localStorage.clear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(
)
- expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
+ // Tab slider renders app type tabs
+ expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
render(
)
- expect(screen.getByTestId('tab-all')).toBeInTheDocument()
- expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument()
- expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument()
- expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument()
- expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument()
- expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument()
+ expect(screen.getByText('app.types.all')).toBeInTheDocument()
+ expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
+ expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
+ expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
+ expect(screen.getByText('app.types.agent')).toBeInTheDocument()
+ expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should render search input', () => {
render(
)
- expect(screen.getByTestId('search-input')).toBeInTheDocument()
+ // Input component renders a searchbox
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
render(
)
- expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
+ // Tag filter renders with placeholder text
+ expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should render created by me checkbox', () => {
render(
)
- expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument()
+ expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
@@ -324,7 +333,7 @@ describe('List', () => {
it('should call setActiveTab when tab is clicked', () => {
render(
)
- fireEvent.click(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`))
+ fireEvent.click(screen.getByText('app.types.workflow'))
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
})
@@ -332,7 +341,7 @@ describe('List', () => {
it('should call setActiveTab for all tab', () => {
render(
)
- fireEvent.click(screen.getByTestId('tab-all'))
+ fireEvent.click(screen.getByText('app.types.all'))
expect(mockSetActiveTab).toHaveBeenCalledWith('all')
})
@@ -341,23 +350,38 @@ describe('List', () => {
describe('Search Functionality', () => {
it('should render search input field', () => {
render(
)
- expect(screen.getByTestId('search-input-field')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should handle search input change', () => {
render(
)
- const input = screen.getByTestId('search-input-field')
+ const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
expect(mockSetQuery).toHaveBeenCalled()
})
- it('should clear search when clear button is clicked', () => {
+ it('should handle search input interaction', () => {
render(
)
- fireEvent.click(screen.getByTestId('clear-search'))
+ const input = screen.getByRole('textbox')
+ expect(input).toBeInTheDocument()
+ })
+ it('should handle search clear button click', () => {
+ // Set initial keywords to make clear button visible
+ mockQueryState.keywords = 'existing search'
+
+ render(
)
+
+ // Find and click clear button (Input component uses .group class for clear icon container)
+ const clearButton = document.querySelector('.group')
+ expect(clearButton).toBeInTheDocument()
+ if (clearButton)
+ fireEvent.click(clearButton)
+
+ // handleKeywordsChange should be called with empty string
expect(mockSetQuery).toHaveBeenCalled()
})
})
@@ -365,16 +389,14 @@ describe('List', () => {
describe('Tag Filter', () => {
it('should render tag filter component', () => {
render(
)
- expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
+ expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
- it('should handle tag filter change', () => {
+ it('should render tag filter with placeholder', () => {
render(
)
- fireEvent.click(screen.getByTestId('add-tag-filter'))
-
- // Tag filter change triggers debounced setTagIDs
- expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
+ // Tag filter is rendered
+ expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
})
@@ -387,7 +409,9 @@ describe('List', () => {
it('should handle checkbox change', () => {
render(
)
- const checkbox = screen.getByTestId('created-by-me-input')
+ // Checkbox component uses data-testid="checkbox-{id}"
+ // CheckboxWithLabel doesn't pass testId, so id is undefined
+ const checkbox = screen.getByTestId('checkbox-undefined')
fireEvent.click(checkbox)
expect(mockSetQuery).toHaveBeenCalled()
@@ -436,10 +460,10 @@ describe('List', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(
)
- expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
+ expect(screen.getByText('app.types.all')).toBeInTheDocument()
rerender(
)
- expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
+ expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
@@ -452,9 +476,9 @@ describe('List', () => {
it('should render with all filter options visible', () => {
render(
)
- expect(screen.getByTestId('search-input')).toBeInTheDocument()
- expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
- expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
+ expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})
@@ -469,27 +493,27 @@ describe('List', () => {
it('should render all app type tabs', () => {
render(
)
- expect(screen.getByTestId('tab-all')).toBeInTheDocument()
- expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument()
- expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument()
- expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument()
- expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument()
- expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument()
+ expect(screen.getByText('app.types.all')).toBeInTheDocument()
+ expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
+ expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
+ expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
+ expect(screen.getByText('app.types.agent')).toBeInTheDocument()
+ expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should call setActiveTab for each app type', () => {
render(
)
- const appModes = [
- AppModeEnum.WORKFLOW,
- AppModeEnum.ADVANCED_CHAT,
- AppModeEnum.CHAT,
- AppModeEnum.AGENT_CHAT,
- AppModeEnum.COMPLETION,
+ const appTypeTexts = [
+ { mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
+ { mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
+ { mode: AppModeEnum.CHAT, text: 'app.types.chatbot' },
+ { mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' },
+ { mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
]
- appModes.forEach((mode) => {
- fireEvent.click(screen.getByTestId(`tab-${mode}`))
+ appTypeTexts.forEach(({ mode, text }) => {
+ fireEvent.click(screen.getByText(text))
expect(mockSetActiveTab).toHaveBeenCalledWith(mode)
})
})
@@ -499,7 +523,7 @@ describe('List', () => {
it('should display search input with correct attributes', () => {
render(
)
- const input = screen.getByTestId('search-input-field')
+ const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('value', '')
})
@@ -507,8 +531,7 @@ describe('List', () => {
it('should have tag filter component', () => {
render(
)
- const tagFilter = screen.getByTestId('tag-filter')
- expect(tagFilter).toBeInTheDocument()
+ expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should display created by me label', () => {
@@ -547,18 +570,17 @@ describe('List', () => {
// --------------------------------------------------------------------------
describe('Additional Coverage', () => {
it('should render dragging state overlay when dragging', () => {
- // Test dragging state is handled
+ mockDragging = true
const { container } = render(
)
- // Component should render successfully
+ // Component should render successfully with dragging state
expect(container).toBeInTheDocument()
})
it('should handle app mode filter in query params', () => {
- // Test that different modes are handled in query
render(
)
- const workflowTab = screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)
+ const workflowTab = screen.getByText('app.types.workflow')
fireEvent.click(workflowTab)
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
@@ -570,4 +592,168 @@ describe('List', () => {
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
})
+
+ describe('DSL File Drop', () => {
+ it('should handle DSL file drop and show modal', () => {
+ render(
)
+
+ // Simulate DSL file drop via the callback
+ const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
+ act(() => {
+ if (mockOnDSLFileDropped)
+ mockOnDSLFileDropped(mockFile)
+ })
+
+ // Modal should be shown
+ expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
+ })
+
+ it('should close DSL modal when onClose is called', () => {
+ render(
)
+
+ // Open modal via DSL file drop
+ const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
+ act(() => {
+ if (mockOnDSLFileDropped)
+ mockOnDSLFileDropped(mockFile)
+ })
+
+ expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
+
+ // Close modal
+ fireEvent.click(screen.getByTestId('close-dsl-modal'))
+
+ expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
+ })
+
+ it('should close DSL modal and refetch when onSuccess is called', () => {
+ render(
)
+
+ // Open modal via DSL file drop
+ const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
+ act(() => {
+ if (mockOnDSLFileDropped)
+ mockOnDSLFileDropped(mockFile)
+ })
+
+ expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
+
+ // Click success button
+ fireEvent.click(screen.getByTestId('success-dsl-modal'))
+
+ // Modal should be closed and refetch should be called
+ expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
+ expect(mockRefetch).toHaveBeenCalled()
+ })
+ })
+
+ describe('Tag Filter Change', () => {
+ it('should handle tag filter value change', () => {
+ jest.useFakeTimers()
+ render(
)
+
+ // TagFilter component is rendered
+ expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
+
+ // Trigger tag filter change via captured callback
+ act(() => {
+ if (mockTagFilterOnChange)
+ mockTagFilterOnChange(['tag-1', 'tag-2'])
+ })
+
+ // Advance timers to trigger debounced setTagIDs
+ act(() => {
+ jest.advanceTimersByTime(500)
+ })
+
+ // setQuery should have been called with updated tagIDs
+ expect(mockSetQuery).toHaveBeenCalled()
+
+ jest.useRealTimers()
+ })
+
+ it('should handle empty tag filter selection', () => {
+ jest.useFakeTimers()
+ render(
)
+
+ // Trigger tag filter change with empty array
+ act(() => {
+ if (mockTagFilterOnChange)
+ mockTagFilterOnChange([])
+ })
+
+ // Advance timers
+ act(() => {
+ jest.advanceTimersByTime(500)
+ })
+
+ expect(mockSetQuery).toHaveBeenCalled()
+
+ jest.useRealTimers()
+ })
+ })
+
+ describe('Infinite Scroll', () => {
+ it('should call fetchNextPage when intersection observer triggers', () => {
+ mockServiceState.hasNextPage = true
+ render(
)
+
+ // Simulate intersection
+ if (intersectionCallback) {
+ act(() => {
+ intersectionCallback!(
+ [{ isIntersecting: true } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ )
+ })
+ }
+
+ expect(mockFetchNextPage).toHaveBeenCalled()
+ })
+
+ it('should not call fetchNextPage when not intersecting', () => {
+ mockServiceState.hasNextPage = true
+ render(
)
+
+ // Simulate non-intersection
+ if (intersectionCallback) {
+ act(() => {
+ intersectionCallback!(
+ [{ isIntersecting: false } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ )
+ })
+ }
+
+ expect(mockFetchNextPage).not.toHaveBeenCalled()
+ })
+
+ it('should not call fetchNextPage when loading', () => {
+ mockServiceState.hasNextPage = true
+ mockServiceState.isLoading = true
+ render(
)
+
+ if (intersectionCallback) {
+ act(() => {
+ intersectionCallback!(
+ [{ isIntersecting: true } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ )
+ })
+ }
+
+ expect(mockFetchNextPage).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Error State', () => {
+ it('should handle error state in useEffect', () => {
+ mockServiceState.error = new Error('Test error')
+ const { container } = render(
)
+
+ // Component should still render
+ expect(container).toBeInTheDocument()
+ // Disconnect should be called when there's an error (cleanup)
+ })
+ })
})
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 (