{visibleInputsForms.map(form => (
-
+
{form.type !== InputVarType.checkbox && (
-
{form.label}
+
{form.label}
{!form.required && (
-
{t('panel.optional', { ns: 'workflow' })}
+
{t('panel.optional', { ns: 'workflow' })}
)}
)}
@@ -125,7 +125,7 @@ const InputsFormContent = ({ showTip }: Props) => {
value={inputsFormValue?.[form.variable] || ''}
onChange={v => handleFormChange(form.variable, v)}
noWrapper
- className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
+ className="h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
placeholder={
{form.json_schema}
}
@@ -134,7 +134,7 @@ const InputsFormContent = ({ showTip }: Props) => {
))}
{showTip && (
-
{t('chat.chatFormTip', { ns: 'share' })}
+
{t('chat.chatFormTip', { ns: 'share' })}
)}
)
diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.spec.tsx
new file mode 100644
index 0000000000..7568f606df
--- /dev/null
+++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.spec.tsx
@@ -0,0 +1,121 @@
+/* eslint-disable ts/no-explicit-any */
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { AppSourceType } from '@/service/share'
+import { useEmbeddedChatbotContext } from '../context'
+import InputsFormNode from './index'
+
+vi.mock('../context', () => ({
+ useEmbeddedChatbotContext: vi.fn(),
+}))
+
+// Mock InputsFormContent to avoid complex integration in this test
+vi.mock('./content', () => ({
+ default: () =>
,
+}))
+
+const mockContextValue = {
+ appSourceType: AppSourceType.webApp,
+ isMobile: false,
+ currentConversationId: null,
+ themeBuilder: null,
+ handleStartChat: vi.fn(),
+ allInputsHidden: false,
+ inputsForms: [{ variable: 'test' }],
+}
+
+describe('InputsFormNode', () => {
+ const user = userEvent.setup()
+ const setCollapsed = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ vi.mocked(useEmbeddedChatbotContext).mockReturnValue(mockContextValue as unknown as any)
+ })
+
+ it('should return null if allInputsHidden is true', () => {
+ vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
+ ...mockContextValue,
+ allInputsHidden: true,
+ } as unknown as any)
+ const { container } = render(
)
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should return null if inputsForms is empty', () => {
+ vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
+ ...mockContextValue,
+ inputsForms: [],
+ } as unknown as any)
+ const { container } = render(
)
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render expanded state correctly', () => {
+ render(
)
+ expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
+ expect(screen.getByTestId('mock-inputs-form-content')).toBeInTheDocument()
+ expect(screen.getByTestId('inputs-form-start-chat-button')).toBeInTheDocument()
+ })
+
+ it('should render collapsed state correctly', () => {
+ render(
)
+ expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
+ expect(screen.queryByTestId('mock-inputs-form-content')).not.toBeInTheDocument()
+ expect(screen.getByTestId('inputs-form-edit-button')).toBeInTheDocument()
+ })
+
+ it('should handle edit button click', async () => {
+ render(
)
+ await user.click(screen.getByTestId('inputs-form-edit-button'))
+ expect(setCollapsed).toHaveBeenCalledWith(false)
+ })
+
+ it('should handle close button click', async () => {
+ vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
+ ...mockContextValue,
+ currentConversationId: 'conv-123',
+ } as unknown as any)
+ render(
)
+ await user.click(screen.getByTestId('inputs-form-close-button'))
+ expect(setCollapsed).toHaveBeenCalledWith(true)
+ })
+
+ it('should handle start chat button click', async () => {
+ const handleStartChat = vi.fn(cb => cb())
+
+ vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
+ ...mockContextValue,
+ handleStartChat,
+ } as unknown as any)
+ render(
)
+ await user.click(screen.getByTestId('inputs-form-start-chat-button'))
+ expect(handleStartChat).toHaveBeenCalled()
+ expect(setCollapsed).toHaveBeenCalledWith(true)
+ })
+
+ it('should apply theme primary color to start chat button', () => {
+ vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
+ ...mockContextValue,
+ themeBuilder: {
+ theme: {
+ primaryColor: '#ff0000',
+ },
+ },
+ } as unknown as any)
+ render(
)
+ const button = screen.getByTestId('inputs-form-start-chat-button')
+ expect(button).toHaveStyle({ backgroundColor: '#ff0000' })
+ })
+
+ it('should apply tryApp styles when appSourceType is tryApp', () => {
+ vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
+ ...mockContextValue,
+ appSourceType: AppSourceType.tryApp,
+ } as unknown as any)
+ render(
)
+ const mainDiv = screen.getByTestId('inputs-form-node')
+ expect(mainDiv).toHaveClass('mb-0 px-0')
+ })
+})
diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx
index 3039d69059..5f53bdec2d 100644
--- a/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
import Divider from '@/app/components/base/divider'
-import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import { AppSourceType } from '@/service/share'
import { cn } from '@/utils/classnames'
import { useEmbeddedChatbotContext } from '../context'
@@ -33,7 +32,10 @@ const InputsFormNode = ({
return null
return (
-
+
-
-
{t('chat.chatSettingsTitle', { ns: 'share' })}
+
+
{t('chat.chatSettingsTitle', { ns: 'share' })}
{collapsed && (
-
setCollapsed(false)}>{t('operation.edit', { ns: 'common' })}
+
setCollapsed(false)}
+ data-testid="inputs-form-edit-button"
+ >
+ {t('operation.edit', { ns: 'common' })}
+
)}
{!collapsed && currentConversationId && (
-
setCollapsed(true)}>{t('operation.close', { ns: 'common' })}
+
setCollapsed(true)}
+ data-testid="inputs-form-close-button"
+ >
+ {t('operation.close', { ns: 'common' })}
+
)}
{!collapsed && (
@@ -66,6 +84,7 @@ const InputsFormNode = ({
variant="primary"
className="w-full"
onClick={() => handleStartChat(() => setCollapsed(true))}
+ data-testid="inputs-form-start-chat-button"
style={
themeBuilder?.theme
? {
diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.spec.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.spec.tsx
new file mode 100644
index 0000000000..9f7fa727fd
--- /dev/null
+++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.spec.tsx
@@ -0,0 +1,53 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ViewFormDropdown from './view-form-dropdown'
+
+// Mock InputsFormContent to avoid complex integration in this test
+vi.mock('./content', () => ({
+ default: () =>
,
+}))
+
+// Note: PortalToFollowElem is mocked globally in vitest.setup.ts
+// to render children in the normal DOM flow when open is true.
+
+describe('ViewFormDropdown', () => {
+ const user = userEvent.setup()
+
+ it('should render the trigger button', () => {
+ render(
)
+ expect(screen.getByTestId('view-form-dropdown-trigger')).toBeInTheDocument()
+ })
+
+ it('should not show content initially', () => {
+ render(
)
+ expect(screen.queryByTestId('view-form-dropdown-content')).not.toBeInTheDocument()
+ })
+
+ it('should show content when trigger is clicked', async () => {
+ render(
)
+ await user.click(screen.getByTestId('view-form-dropdown-trigger'))
+
+ expect(screen.getByTestId('view-form-dropdown-content')).toBeInTheDocument()
+ expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
+ expect(screen.getByTestId('mock-inputs-form-content')).toBeInTheDocument()
+ })
+
+ it('should close content when trigger is clicked again', async () => {
+ render(
)
+ const trigger = screen.getByTestId('view-form-dropdown-trigger')
+
+ await user.click(trigger) // Open
+ expect(screen.getByTestId('view-form-dropdown-content')).toBeInTheDocument()
+
+ await user.click(trigger) // Close
+ expect(screen.queryByTestId('view-form-dropdown-content')).not.toBeInTheDocument()
+ })
+
+ it('should apply iconColor class to the icon', async () => {
+ render(
)
+ await user.click(screen.getByTestId('view-form-dropdown-trigger'))
+
+ const icon = screen.getByTestId('view-form-dropdown-trigger').querySelector('.i-ri-chat-settings-line')
+ expect(icon).toHaveClass('text-red-500')
+ })
+})
diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx
index e584c873b3..50df5b0a4d 100644
--- a/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx
@@ -1,18 +1,18 @@
-import {
- RiChatSettingsLine,
-} from '@remixicon/react'
+import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
-import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
type Props = {
iconColor?: string
}
-const ViewFormDropdown = ({ iconColor }: Props) => {
+
+const ViewFormDropdown = ({
+ iconColor,
+}: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -26,18 +26,23 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
crossAxis: 4,
}}
>
-
setOpen(v => !v)}
- >
-
-
+ setOpen(v => !v)}>
+
+
-
+
-
-
{t('chat.chatSettingsTitle', { ns: 'share' })}
+
+
{t('chat.chatSettingsTitle', { ns: 'share' })}
@@ -45,7 +50,6 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
-
)
}
diff --git a/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx b/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx
new file mode 100644
index 0000000000..3f39dd836f
--- /dev/null
+++ b/web/app/components/base/checkbox/assets/indeterminate-icon.spec.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from '@testing-library/react'
+import IndeterminateIcon from './indeterminate-icon'
+
+describe('IndeterminateIcon', () => {
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render(
)
+ expect(screen.getByTestId('indeterminate-icon')).toBeInTheDocument()
+ })
+
+ it('should render an svg element', () => {
+ const { container } = render(
)
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx
index d4319650f5..2ca448fed0 100644
--- a/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx
+++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.spec.tsx
@@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => {
it('should render year options', () => {
const props = createOptionsProps()
- render(
)
+ const { container } = render(
)
- const allItems = screen.getAllByRole('listitem')
- expect(allItems).toHaveLength(212)
+ const yearList = container.querySelectorAll('ul')[1]
+ expect(yearList?.children).toHaveLength(200)
})
})
diff --git a/web/app/components/base/drawer/index.tsx b/web/app/components/base/drawer/index.tsx
index 2f44ce75af..ab01a4114d 100644
--- a/web/app/components/base/drawer/index.tsx
+++ b/web/app/components/base/drawer/index.tsx
@@ -1,6 +1,5 @@
'use client'
import { Dialog, DialogBackdrop, DialogTitle } from '@headlessui/react'
-import { XMarkIcon } from '@heroicons/react/24/outline'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import Button from '../button'
@@ -81,7 +80,18 @@ export default function Drawer({
)}
{showClose && (
-
+ {
+ if (e.key === 'Enter' || e.key === ' ')
+ onClose()
+ }}
+ role="button"
+ tabIndex={0}
+ aria-label={t('operation.close', { ns: 'common' })}
+ data-testid="close-icon"
+ />
)}
diff --git a/web/app/components/base/error-boundary/index.spec.tsx b/web/app/components/base/error-boundary/index.spec.tsx
new file mode 100644
index 0000000000..1caca84d79
--- /dev/null
+++ b/web/app/components/base/error-boundary/index.spec.tsx
@@ -0,0 +1,383 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import * as React from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import ErrorBoundary, { ErrorFallback, useAsyncError, useErrorHandler, withErrorBoundary } from './index'
+
+const mockConfig = vi.hoisted(() => ({
+ isDev: false,
+}))
+
+vi.mock('@/config', () => ({
+ get IS_DEV() {
+ return mockConfig.isDev
+ },
+}))
+
+type ThrowOnRenderProps = {
+ message?: string
+ shouldThrow: boolean
+}
+
+const ThrowOnRender = ({ shouldThrow, message = 'render boom' }: ThrowOnRenderProps) => {
+ if (shouldThrow)
+ throw new Error(message)
+
+ return
Child content rendered
+}
+
+let consoleErrorSpy: ReturnType
+
+describe('ErrorBoundary', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockConfig.isDev = false
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore()
+ })
+
+ // Verify default render and default fallback behavior.
+ describe('Rendering', () => {
+ it('should render children when no error occurs', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Child content rendered')).toBeInTheDocument()
+ })
+
+ it('should render default fallback with title and message when child throws', async () => {
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByText('Something went wrong')).toBeInTheDocument()
+ expect(screen.getByText('An unexpected error occurred while rendering this component.')).toBeInTheDocument()
+ })
+
+ it('should render custom title, message, and className in fallback', async () => {
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByText('Custom crash title')).toBeInTheDocument()
+ expect(screen.getByText('Custom recovery message')).toBeInTheDocument()
+
+ const fallbackRoot = document.querySelector('.custom-boundary')
+ expect(fallbackRoot).toBeInTheDocument()
+ expect(fallbackRoot).not.toHaveClass('min-h-[200px]')
+ })
+ })
+
+ // Validate explicit fallback prop variants.
+ describe('Fallback props', () => {
+ it('should render node fallback when fallback prop is a React node', async () => {
+ render(
+ Node fallback content }>
+
+ ,
+ )
+
+ expect(await screen.findByText('Node fallback content')).toBeInTheDocument()
+ })
+
+ it('should render function fallback with error message when fallback prop is a function', async () => {
+ render(
+ (
+
+ Function fallback:
+ {' '}
+ {error.message}
+
+ )}
+ >
+
+ ,
+ )
+
+ expect(await screen.findByText('Function fallback: function fallback boom')).toBeInTheDocument()
+ })
+ })
+
+ // Validate error reporting and details panel behavior.
+ describe('Error reporting', () => {
+ it('should call onError with error and errorInfo when child throws', async () => {
+ const onError = vi.fn()
+
+ render(
+
+
+ ,
+ )
+
+ await screen.findByText('Something went wrong')
+
+ expect(onError).toHaveBeenCalledTimes(1)
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'render boom' }),
+ expect.objectContaining({ componentStack: expect.any(String) }),
+ )
+ })
+
+ it('should render details block when showDetails is true', async () => {
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByText('Error Details (Development Only)')).toBeInTheDocument()
+ expect(screen.getByText('Error:')).toBeInTheDocument()
+ expect(screen.getByText(/details boom/i)).toBeInTheDocument()
+ })
+
+ it('should log boundary errors in development mode', async () => {
+ mockConfig.isDev = true
+
+ render(
+
+
+ ,
+ )
+
+ await screen.findByText('Something went wrong')
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'ErrorBoundary caught an error:',
+ expect.objectContaining({ message: 'dev boom' }),
+ )
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error Info:',
+ expect.objectContaining({ componentStack: expect.any(String) }),
+ )
+ })
+ })
+
+ // Validate recovery controls and automatic reset triggers.
+ describe('Recovery', () => {
+ it('should hide recovery actions when enableRecovery is false', async () => {
+ render(
+
+
+ ,
+ )
+
+ await screen.findByText('Something went wrong')
+
+ expect(screen.queryByRole('button', { name: 'Try Again' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Reload Page' })).not.toBeInTheDocument()
+ })
+
+ it('should reset and render children when Try Again is clicked', async () => {
+ const onReset = vi.fn()
+
+ const RecoveryHarness = () => {
+ const [shouldThrow, setShouldThrow] = React.useState(true)
+ return (
+ {
+ onReset()
+ setShouldThrow(false)
+ }}
+ >
+
+
+ )
+ }
+
+ render( )
+ fireEvent.click(await screen.findByRole('button', { name: 'Try Again' }))
+
+ await screen.findByText('Child content rendered')
+ expect(onReset).toHaveBeenCalledTimes(1)
+ })
+
+ it('should reset after resetKeys change when boundary is in error state', async () => {
+ const ResetKeysHarness = () => {
+ const [shouldThrow, setShouldThrow] = React.useState(true)
+ const [boundaryKey, setBoundaryKey] = React.useState(0)
+
+ return (
+ <>
+ {
+ setShouldThrow(false)
+ setBoundaryKey(1)
+ }}
+ >
+ Recover with keys
+
+
+
+
+ >
+ )
+ }
+
+ render( )
+ await screen.findByText('Something went wrong')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Recover with keys' }))
+
+ await waitFor(() => {
+ expect(screen.getByText('Child content rendered')).toBeInTheDocument()
+ })
+ })
+
+ it('should reset after children change when resetOnPropsChange is true', async () => {
+ const ResetOnPropsHarness = () => {
+ const [shouldThrow, setShouldThrow] = React.useState(true)
+ const [childLabel, setChildLabel] = React.useState('first child')
+
+ return (
+ <>
+ {
+ setShouldThrow(false)
+ setChildLabel('second child')
+ }}
+ >
+ Replace children
+
+
+ {shouldThrow ? : {childLabel}
}
+
+ >
+ )
+ }
+
+ render( )
+ await screen.findByText('Something went wrong')
+
+ fireEvent.click(screen.getByRole('button', { name: 'Replace children' }))
+
+ await waitFor(() => {
+ expect(screen.getByText('second child')).toBeInTheDocument()
+ })
+ })
+ })
+})
+
+describe('ErrorBoundary utility exports', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore()
+ })
+
+ // Validate imperative error hook behavior.
+ describe('useErrorHandler', () => {
+ it('should trigger error boundary fallback when setError is called', async () => {
+ const HookConsumer = () => {
+ const setError = useErrorHandler()
+ return (
+ setError(new Error('handler boom'))}>
+ Trigger hook error
+
+ )
+ }
+
+ render(
+ Hook fallback shown }>
+
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Trigger hook error' }))
+
+ expect(await screen.findByText('Hook fallback shown')).toBeInTheDocument()
+ })
+ })
+
+ // Validate async error bridge hook behavior.
+ describe('useAsyncError', () => {
+ it('should trigger error boundary fallback when async error callback is called', async () => {
+ const AsyncHookConsumer = () => {
+ const throwAsyncError = useAsyncError()
+ return (
+
throwAsyncError(new Error('async hook boom'))}>
+ Trigger async hook error
+
+ )
+ }
+
+ render(
+
Async fallback shown }>
+
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Trigger async hook error' }))
+
+ expect(await screen.findByText('Async fallback shown')).toBeInTheDocument()
+ })
+ })
+
+ // Validate HOC wrapper behavior and metadata.
+ describe('withErrorBoundary', () => {
+ it('should wrap component and render custom title when wrapped component throws', async () => {
+ type WrappedProps = {
+ shouldThrow: boolean
+ }
+
+ const WrappedTarget = ({ shouldThrow }: WrappedProps) => {
+ if (shouldThrow)
+ throw new Error('wrapped boom')
+ return
Wrapped content
+ }
+
+ const Wrapped = withErrorBoundary(WrappedTarget, {
+ customTitle: 'Wrapped boundary title',
+ })
+
+ render(
)
+
+ expect(await screen.findByText('Wrapped boundary title')).toBeInTheDocument()
+ })
+
+ it('should set displayName using wrapped component name', () => {
+ const NamedComponent = () =>
named content
+ const Wrapped = withErrorBoundary(NamedComponent)
+
+ expect(Wrapped.displayName).toBe('withErrorBoundary(NamedComponent)')
+ })
+ })
+
+ // Validate simple fallback helper component.
+ describe('ErrorFallback', () => {
+ it('should render message and call reset action when button is clicked', () => {
+ const resetErrorBoundaryAction = vi.fn()
+
+ render(
+
,
+ )
+
+ expect(screen.getByText('Oops! Something went wrong')).toBeInTheDocument()
+ expect(screen.getByText('fallback helper message')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
+
+ expect(resetErrorBoundaryAction).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/web/app/components/base/features/context.spec.tsx b/web/app/components/base/features/context.spec.tsx
new file mode 100644
index 0000000000..e57cbd82c2
--- /dev/null
+++ b/web/app/components/base/features/context.spec.tsx
@@ -0,0 +1,69 @@
+import { render, screen } from '@testing-library/react'
+import * as React from 'react'
+import { useContext } from 'react'
+import { FeaturesContext, FeaturesProvider } from './context'
+
+const TestConsumer = () => {
+ const store = useContext(FeaturesContext)
+ if (!store)
+ return
no store
+
+ const { features } = store.getState()
+ return
{features.moreLikeThis?.enabled ? 'enabled' : 'disabled'}
+}
+
+describe('FeaturesProvider', () => {
+ it('should provide store to children when FeaturesProvider wraps them', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('status')).toHaveTextContent('disabled')
+ })
+
+ it('should provide initial features state when features prop is provided', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('status')).toHaveTextContent('enabled')
+ })
+
+ it('should maintain the same store reference across re-renders', () => {
+ const storeRefs: Array
> = []
+
+ const StoreRefCollector = () => {
+ const store = useContext(FeaturesContext)
+ storeRefs.push(store)
+ return null
+ }
+
+ const { rerender } = render(
+
+
+ ,
+ )
+
+ rerender(
+
+
+ ,
+ )
+
+ expect(storeRefs[0]).toBe(storeRefs[1])
+ })
+
+ it('should handle empty features object', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('status')).toHaveTextContent('disabled')
+ })
+})
diff --git a/web/app/components/base/features/hooks.spec.ts b/web/app/components/base/features/hooks.spec.ts
new file mode 100644
index 0000000000..aa0aa1e85e
--- /dev/null
+++ b/web/app/components/base/features/hooks.spec.ts
@@ -0,0 +1,63 @@
+import { renderHook } from '@testing-library/react'
+import * as React from 'react'
+import { FeaturesContext } from './context'
+import { useFeatures, useFeaturesStore } from './hooks'
+import { createFeaturesStore } from './store'
+
+describe('useFeatures', () => {
+ it('should return selected state from the store when useFeatures is called with selector', () => {
+ const store = createFeaturesStore({
+ features: { moreLikeThis: { enabled: true } },
+ })
+
+ const wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(FeaturesContext.Provider, { value: store }, children)
+
+ const { result } = renderHook(
+ () => useFeatures(s => s.features.moreLikeThis?.enabled),
+ { wrapper },
+ )
+
+ expect(result.current).toBe(true)
+ })
+
+ it('should throw error when used outside FeaturesContext.Provider', () => {
+ // Act & Assert
+ expect(() => {
+ renderHook(() => useFeatures(s => s.features))
+ }).toThrow('Missing FeaturesContext.Provider in the tree')
+ })
+
+ it('should return undefined when feature does not exist', () => {
+ const store = createFeaturesStore({ features: {} })
+
+ const wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(FeaturesContext.Provider, { value: store }, children)
+
+ const { result } = renderHook(
+ () => useFeatures(s => (s.features as Record).nonexistent as boolean | undefined),
+ { wrapper },
+ )
+
+ expect(result.current).toBeUndefined()
+ })
+})
+
+describe('useFeaturesStore', () => {
+ it('should return the store from context when used within provider', () => {
+ const store = createFeaturesStore()
+
+ const wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(FeaturesContext.Provider, { value: store }, children)
+
+ const { result } = renderHook(() => useFeaturesStore(), { wrapper })
+
+ expect(result.current).toBe(store)
+ })
+
+ it('should return null when used outside provider', () => {
+ const { result } = renderHook(() => useFeaturesStore())
+
+ expect(result.current).toBeNull()
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.spec.tsx
new file mode 100644
index 0000000000..65f45d10de
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.spec.tsx
@@ -0,0 +1,149 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import AnnotationCtrlButton from './annotation-ctrl-button'
+
+const mockSetShowAnnotationFullModal = vi.fn()
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowAnnotationFullModal: mockSetShowAnnotationFullModal,
+ }),
+}))
+
+let mockAnnotatedResponseUsage = 5
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ plan: {
+ usage: { get annotatedResponse() { return mockAnnotatedResponseUsage } },
+ total: { annotatedResponse: 100 },
+ },
+ enableBilling: true,
+ }),
+}))
+
+const mockAddAnnotation = vi.fn().mockResolvedValue({
+ id: 'annotation-1',
+ account: { name: 'Test User' },
+})
+
+vi.mock('@/service/annotation', () => ({
+ addAnnotation: (...args: unknown[]) => mockAddAnnotation(...args),
+}))
+
+vi.mock('@/app/components/base/toast', () => ({
+ default: { notify: vi.fn() },
+}))
+
+describe('AnnotationCtrlButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockAnnotatedResponseUsage = 5
+ })
+
+ it('should render edit button when cached', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should call onEdit when edit button is clicked', () => {
+ const onEdit = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(onEdit).toHaveBeenCalled()
+ })
+
+ it('should render add button when not cached and has answer', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should not render any button when not cached and no answer', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('should call addAnnotation and onAdded when add button is clicked', async () => {
+ const onAdded = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button'))
+
+ await waitFor(() => {
+ expect(mockAddAnnotation).toHaveBeenCalledWith('test-app', {
+ message_id: 'msg-1',
+ question: 'test query',
+ answer: 'test answer',
+ })
+ expect(onAdded).toHaveBeenCalledWith('annotation-1', 'Test User')
+ })
+ })
+
+ it('should show annotation full modal when annotation limit is reached', () => {
+ mockAnnotatedResponseUsage = 100
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(mockSetShowAnnotationFullModal).toHaveBeenCalled()
+ expect(mockAddAnnotation).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.spec.tsx
new file mode 100644
index 0000000000..d541c006f6
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.spec.tsx
@@ -0,0 +1,415 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import Toast from '@/app/components/base/toast'
+import ConfigParamModal from './config-param-modal'
+
+let mockHooksReturn: {
+ modelList: { provider: { provider: string }, models: { model: string }[] }[]
+ defaultModel: { provider: { provider: string }, model: string } | undefined
+ currentModel: boolean | undefined
+} = {
+ modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
+ defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
+ currentModel: true,
+}
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useModelListAndDefaultModelAndCurrentProviderAndModel: () => mockHooksReturn,
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
+ ModelTypeEnum: {
+ textEmbedding: 'text-embedding',
+ },
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
+ default: ({ defaultModel, onSelect }: { defaultModel?: { provider: string, model: string }, onSelect: (val: { provider: string, model: string }) => void }) => (
+
+ Model Selector
+ onSelect({ provider: 'cohere', model: 'embed-english' })}>Select
+
+ ),
+}))
+
+vi.mock('@/app/components/base/toast', () => ({
+ default: { notify: vi.fn() },
+}))
+
+vi.mock('@/config', () => ({
+ ANNOTATION_DEFAULT: { score_threshold: 0.9 },
+}))
+
+const defaultAnnotationConfig = {
+ id: 'test-id',
+ enabled: false,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+}
+
+describe('ConfigParamModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockHooksReturn = {
+ modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
+ defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
+ currentModel: true,
+ }
+ })
+
+ it('should not render when isShow is false', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByText(/initSetup/)).not.toBeInTheDocument()
+ })
+
+ it('should render init title when isInit is true', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/initSetup\.title/)).toBeInTheDocument()
+ })
+
+ it('should render config title when isInit is false', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/initSetup\.configTitle/)).toBeInTheDocument()
+ })
+
+ it('should render score slider', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('slider')).toBeInTheDocument()
+ })
+
+ it('should render model selector', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('model-selector')).toBeInTheDocument()
+ })
+
+ it('should render cancel and confirm buttons', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
+ expect(screen.getByText(/initSetup\.confirmBtn/)).toBeInTheDocument()
+ })
+
+ it('should display score threshold value', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('0.90')).toBeInTheDocument()
+ })
+
+ it('should render configConfirmBtn when isInit is false', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/initSetup\.configConfirmBtn/)).toBeInTheDocument()
+ })
+
+ it('should call onSave with embedding model and score when save is clicked', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined)
+ render(
+ ,
+ )
+
+ // Click the confirm/save button
+ const buttons = screen.getAllByRole('button')
+ const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
+ fireEvent.click(saveBtn!)
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(
+ { embedding_provider_name: 'openai', embedding_model_name: 'text-embedding-ada-002' },
+ 0.9,
+ )
+ })
+ })
+
+ it('should show error toast when embedding model is not set', () => {
+ const configWithoutModel = {
+ ...defaultAnnotationConfig,
+ embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
+ }
+
+ // Override hooks to return no default model and no valid current model
+ mockHooksReturn = {
+ modelList: [],
+ defaultModel: undefined,
+ currentModel: undefined,
+ }
+
+ render(
+ ,
+ )
+
+ const buttons = screen.getAllByRole('button')
+ const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
+ fireEvent.click(saveBtn!)
+
+ expect(Toast.notify).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'error' }),
+ )
+ })
+
+ it('should call onHide when cancel is clicked and not loading', () => {
+ const onHide = vi.fn()
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByText(/operation\.cancel/))
+
+ expect(onHide).toHaveBeenCalled()
+ })
+
+ it('should render slider with expected bounds and current value', () => {
+ render(
+ ,
+ )
+
+ const slider = screen.getByRole('slider')
+ expect(slider).toHaveAttribute('aria-valuemin', '80')
+ expect(slider).toHaveAttribute('aria-valuemax', '100')
+ expect(slider).toHaveAttribute('aria-valuenow', '90')
+ })
+
+ it('should update embedding model when model selector is used', () => {
+ render(
+ ,
+ )
+
+ // Click the select model button in mock
+ fireEvent.click(screen.getByTestId('select-model'))
+
+ // Model selector should now show the new provider/model
+ expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'cohere')
+ expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'embed-english')
+ })
+
+ it('should call onSave with updated score from annotation config', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined)
+ render(
+ ,
+ )
+
+ // Save
+ const buttons = screen.getAllByRole('button')
+ const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
+ fireEvent.click(saveBtn!)
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({ embedding_provider_name: 'openai' }),
+ 0.95,
+ )
+ })
+ })
+
+ it('should call onSave with updated model after model selector change', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined)
+ render(
+ ,
+ )
+
+ // Change model
+ fireEvent.click(screen.getByTestId('select-model'))
+
+ // Save
+ const buttons = screen.getAllByRole('button')
+ const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
+ fireEvent.click(saveBtn!)
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(
+ { embedding_provider_name: 'cohere', embedding_model_name: 'embed-english' },
+ 0.9,
+ )
+ })
+ })
+
+ it('should use default model when annotation config has no embedding model', () => {
+ const configWithoutModel = {
+ ...defaultAnnotationConfig,
+ embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
+ }
+ render(
+ ,
+ )
+
+ // Model selector should be initialized with the default model
+ expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'openai')
+ expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'text-embedding-ada-002')
+ })
+
+ it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => {
+ const configWithoutThreshold = {
+ ...defaultAnnotationConfig,
+ score_threshold: 0,
+ }
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '90')
+ })
+
+ it('should set loading state while saving', async () => {
+ let resolveOnSave: () => void
+ const onSave = vi.fn().mockImplementation(() => new Promise((resolve) => {
+ resolveOnSave = resolve
+ }))
+ const onHide = vi.fn()
+
+ render(
+ ,
+ )
+
+ // Click save
+ const buttons = screen.getAllByRole('button')
+ const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
+ fireEvent.click(saveBtn!)
+
+ // While loading, clicking cancel should not call onHide
+ fireEvent.click(screen.getByText(/operation\.cancel/))
+ expect(onHide).not.toHaveBeenCalled()
+
+ // Resolve the save
+ resolveOnSave!()
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.spec.tsx
new file mode 100644
index 0000000000..8d8a8e55cb
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.spec.tsx
@@ -0,0 +1,37 @@
+import { render, screen } from '@testing-library/react'
+import { Item } from './config-param'
+
+describe('ConfigParam Item', () => {
+ it('should render title text', () => {
+ render(
+ -
+
children
+ ,
+ )
+
+ expect(screen.getByText('Score Threshold')).toBeInTheDocument()
+ })
+
+ it('should render children', () => {
+ render(
+ -
+
Child
+ ,
+ )
+
+ expect(screen.getByTestId('child-content')).toBeInTheDocument()
+ })
+
+ it('should render tooltip icon', () => {
+ render(
+ -
+
children
+ ,
+ )
+
+ // Tooltip component renders an icon next to the title
+ expect(screen.getByText(/Title/)).toBeInTheDocument()
+ // The Tooltip component is rendered as a sibling, confirming the tooltip prop is used
+ expect(screen.getByText(/Title/).closest('div')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/index.spec.tsx
new file mode 100644
index 0000000000..ce9e2f7cf2
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/annotation-reply/index.spec.tsx
@@ -0,0 +1,420 @@
+import type { Features } from '../../types'
+import type { OnFeaturesChange } from '@/app/components/base/features/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { FeaturesProvider } from '../../context'
+import AnnotationReply from './index'
+
+const mockPush = vi.fn()
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: mockPush }),
+ usePathname: () => '/app/test-app-id/configuration',
+}))
+
+let mockIsShowAnnotationConfigInit = false
+let mockIsShowAnnotationFullModal = false
+const mockHandleEnableAnnotation = vi.fn().mockResolvedValue(undefined)
+const mockHandleDisableAnnotation = vi.fn().mockResolvedValue(undefined)
+const mockSetIsShowAnnotationConfigInit = vi.fn((v: boolean) => {
+ mockIsShowAnnotationConfigInit = v
+})
+const mockSetIsShowAnnotationFullModal = vi.fn((v: boolean) => {
+ mockIsShowAnnotationFullModal = v
+})
+
+let capturedSetAnnotationConfig: ((config: Record) => void) | null = null
+
+vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config', () => ({
+ default: ({ setAnnotationConfig }: { setAnnotationConfig: (config: Record) => void }) => {
+ capturedSetAnnotationConfig = setAnnotationConfig
+ return {
+ handleEnableAnnotation: mockHandleEnableAnnotation,
+ handleDisableAnnotation: mockHandleDisableAnnotation,
+ get isShowAnnotationConfigInit() { return mockIsShowAnnotationConfigInit },
+ setIsShowAnnotationConfigInit: mockSetIsShowAnnotationConfigInit,
+ get isShowAnnotationFullModal() { return mockIsShowAnnotationFullModal },
+ setIsShowAnnotationFullModal: mockSetIsShowAnnotationFullModal,
+ }
+ },
+}))
+
+vi.mock('@/app/components/billing/annotation-full/modal', () => ({
+ default: ({ show, onHide }: { show: boolean, onHide: () => void }) => (
+ show
+ ? (
+
+ Hide
+
+ )
+ : null
+ ),
+}))
+
+vi.mock('@/config', () => ({
+ ANNOTATION_DEFAULT: { score_threshold: 0.9 },
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
+ modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
+ defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
+ currentModel: true,
+ }),
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
+ ModelTypeEnum: {
+ textEmbedding: 'text-embedding',
+ },
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
+ default: () => (
+ Model Selector
+ ),
+}))
+
+const defaultFeatures: Features = {
+ moreLikeThis: { enabled: false },
+ opening: { enabled: false },
+ suggested: { enabled: false },
+ text2speech: { enabled: false },
+ speech2text: { enabled: false },
+ citation: { enabled: false },
+ moderation: { enabled: false },
+ file: { enabled: false },
+ annotationReply: { enabled: false },
+}
+
+const renderWithProvider = (
+ props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
+ featureOverrides?: Partial,
+) => {
+ const features = { ...defaultFeatures, ...featureOverrides }
+ return render(
+
+
+ ,
+ )
+}
+
+describe('AnnotationReply', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsShowAnnotationConfigInit = false
+ mockIsShowAnnotationFullModal = false
+ capturedSetAnnotationConfig = null
+ })
+
+ it('should render the annotation reply title', () => {
+ renderWithProvider()
+
+ expect(screen.getByText(/feature\.annotation\.title/)).toBeInTheDocument()
+ })
+
+ it('should render description when not enabled', () => {
+ renderWithProvider()
+
+ expect(screen.getByText(/feature\.annotation\.description/)).toBeInTheDocument()
+ })
+
+ it('should render a switch toggle', () => {
+ renderWithProvider()
+
+ expect(screen.getByRole('switch')).toBeInTheDocument()
+ })
+
+ it('should call setIsShowAnnotationConfigInit when switch is toggled on', () => {
+ renderWithProvider()
+
+ fireEvent.click(screen.getByRole('switch'))
+
+ expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(true)
+ })
+
+ it('should call handleDisableAnnotation when switch is toggled off', () => {
+ renderWithProvider({}, {
+ annotationReply: {
+ enabled: true,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ fireEvent.click(screen.getByRole('switch'))
+
+ expect(mockHandleDisableAnnotation).toHaveBeenCalled()
+ })
+
+ it('should show score threshold and embedding model when enabled', () => {
+ renderWithProvider({}, {
+ annotationReply: {
+ enabled: true,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ expect(screen.getByText('0.9')).toBeInTheDocument()
+ expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
+ })
+
+ it('should show dash when score threshold is not set', () => {
+ renderWithProvider({}, {
+ annotationReply: {
+ enabled: true,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ expect(screen.getByText('-')).toBeInTheDocument()
+ })
+
+ it('should show buttons when hovering over enabled feature', () => {
+ renderWithProvider({}, {
+ annotationReply: {
+ enabled: true,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+
+ expect(screen.getByText(/operation\.params/)).toBeInTheDocument()
+ expect(screen.getByText(/feature\.annotation\.cacheManagement/)).toBeInTheDocument()
+ })
+
+ it('should call setIsShowAnnotationConfigInit when params button is clicked', () => {
+ renderWithProvider({}, {
+ annotationReply: {
+ enabled: true,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+ fireEvent.click(screen.getByText(/operation\.params/))
+
+ expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(true)
+ })
+
+ it('should navigate to annotations page when cache management is clicked', () => {
+ renderWithProvider({}, {
+ annotationReply: {
+ enabled: true,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+ fireEvent.click(screen.getByText(/feature\.annotation\.cacheManagement/))
+
+ expect(mockPush).toHaveBeenCalledWith('/app/test-app-id/annotations')
+ })
+
+ it('should show config param modal when isShowAnnotationConfigInit is true', () => {
+ mockIsShowAnnotationConfigInit = true
+ renderWithProvider()
+
+ expect(screen.getByText(/initSetup\.title/)).toBeInTheDocument()
+ })
+
+ it('should hide config modal when hide is clicked', () => {
+ mockIsShowAnnotationConfigInit = true
+ renderWithProvider()
+
+ fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ }))
+
+ expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(false)
+ })
+
+ it('should call handleEnableAnnotation when config save is clicked', async () => {
+ mockIsShowAnnotationConfigInit = true
+ renderWithProvider({}, {
+ annotationReply: {
+ enabled: true,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ fireEvent.click(screen.getByText(/initSetup\.confirmBtn/))
+
+ expect(mockHandleEnableAnnotation).toHaveBeenCalled()
+ })
+
+ it('should show annotation full modal when isShowAnnotationFullModal is true', () => {
+ mockIsShowAnnotationFullModal = true
+ renderWithProvider()
+
+ expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument()
+ })
+
+ it('should hide annotation full modal when hide is clicked', () => {
+ mockIsShowAnnotationFullModal = true
+ renderWithProvider()
+
+ fireEvent.click(screen.getByTestId('full-hide'))
+
+ expect(mockSetIsShowAnnotationFullModal).toHaveBeenCalledWith(false)
+ })
+
+ it('should call handleEnableAnnotation and hide config modal on save', async () => {
+ mockIsShowAnnotationConfigInit = true
+ renderWithProvider({}, {
+ annotationReply: {
+ enabled: true,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ fireEvent.click(screen.getByText(/initSetup\.confirmBtn/))
+
+ // handleEnableAnnotation should be called with embedding model and score
+ expect(mockHandleEnableAnnotation).toHaveBeenCalledWith(
+ { embedding_provider_name: 'openai', embedding_model_name: 'text-embedding-ada-002' },
+ 0.9,
+ )
+
+ // After save resolves, config init should be hidden
+ await vi.waitFor(() => {
+ expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(false)
+ })
+ })
+
+ it('should update features and call onChange when updateAnnotationReply is invoked', () => {
+ const onChange = vi.fn()
+ renderWithProvider({ onChange }, {
+ annotationReply: {
+ enabled: true,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ // The captured setAnnotationConfig is the component's updateAnnotationReply callback
+ expect(capturedSetAnnotationConfig).not.toBeNull()
+ capturedSetAnnotationConfig!({
+ enabled: true,
+ score_threshold: 0.8,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'new-model',
+ },
+ })
+
+ expect(onChange).toHaveBeenCalled()
+ })
+
+ it('should update features without calling onChange when onChange is not provided', () => {
+ renderWithProvider({}, {
+ annotationReply: {
+ enabled: true,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ // Should not throw when onChange is not provided
+ expect(capturedSetAnnotationConfig).not.toBeNull()
+ expect(() => {
+ capturedSetAnnotationConfig!({
+ enabled: true,
+ score_threshold: 0.7,
+ })
+ }).not.toThrow()
+ })
+
+ it('should hide info display when hovering over enabled feature', () => {
+ renderWithProvider({}, {
+ annotationReply: {
+ enabled: true,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ // Before hover, info is visible
+ expect(screen.getByText('0.9')).toBeInTheDocument()
+
+ const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+
+ // After hover, buttons shown instead of info
+ expect(screen.getByText(/operation\.params/)).toBeInTheDocument()
+ expect(screen.queryByText('0.9')).not.toBeInTheDocument()
+ })
+
+ it('should show info display again when mouse leaves', () => {
+ renderWithProvider({}, {
+ annotationReply: {
+ enabled: true,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ },
+ })
+
+ const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+ fireEvent.mouseLeave(card)
+
+ expect(screen.getByText('0.9')).toBeInTheDocument()
+ })
+
+ it('should pass isInit prop to ConfigParamModal', () => {
+ mockIsShowAnnotationConfigInit = true
+ renderWithProvider()
+
+ expect(screen.getByText(/initSetup\.confirmBtn/)).toBeInTheDocument()
+ expect(screen.queryByText(/initSetup\.configConfirmBtn/)).not.toBeInTheDocument()
+ })
+
+ it('should not show annotation full modal when isShowAnnotationFullModal is false', () => {
+ mockIsShowAnnotationFullModal = false
+ renderWithProvider()
+
+ expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.spec.tsx
new file mode 100644
index 0000000000..21e187091c
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.spec.tsx
@@ -0,0 +1,50 @@
+import { render, screen } from '@testing-library/react'
+import Slider from './index'
+
+describe('BaseSlider', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the slider component', () => {
+ render( )
+
+ expect(screen.getByRole('slider')).toBeInTheDocument()
+ })
+
+ it('should display the formatted value in the thumb', () => {
+ render( )
+
+ expect(screen.getByText('0.85')).toBeInTheDocument()
+ })
+
+ it('should use default min/max/step when not provided', () => {
+ render( )
+
+ const slider = screen.getByRole('slider')
+ expect(slider).toHaveAttribute('aria-valuemin', '0')
+ expect(slider).toHaveAttribute('aria-valuemax', '100')
+ expect(slider).toHaveAttribute('aria-valuenow', '50')
+ })
+
+ it('should use custom min/max/step when provided', () => {
+ render( )
+
+ const slider = screen.getByRole('slider')
+ expect(slider).toHaveAttribute('aria-valuemin', '80')
+ expect(slider).toHaveAttribute('aria-valuemax', '100')
+ expect(slider).toHaveAttribute('aria-valuenow', '90')
+ })
+
+ it('should handle NaN value as 0', () => {
+ render( )
+
+ expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
+ })
+
+ it('should pass disabled prop', () => {
+ render( )
+
+ expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.spec.tsx
new file mode 100644
index 0000000000..008c6369e1
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.spec.tsx
@@ -0,0 +1,50 @@
+import { render, screen } from '@testing-library/react'
+import ScoreSlider from './index'
+
+vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider', () => ({
+ default: ({ value, onChange, min, max }: { value: number, onChange: (v: number) => void, min: number, max: number }) => (
+ onChange(Number(e.target.value))}
+ />
+ ),
+}))
+
+describe('ScoreSlider', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the slider', () => {
+ render( )
+
+ expect(screen.getByTestId('slider')).toBeInTheDocument()
+ })
+
+ it('should display easy match and accurate match labels', () => {
+ render( )
+
+ expect(screen.getByText('0.8')).toBeInTheDocument()
+ expect(screen.getByText('1.0')).toBeInTheDocument()
+ expect(screen.getByText(/feature\.annotation\.scoreThreshold\.easyMatch/)).toBeInTheDocument()
+ expect(screen.getByText(/feature\.annotation\.scoreThreshold\.accurateMatch/)).toBeInTheDocument()
+ })
+
+ it('should render with custom className', () => {
+ const { container } = render( )
+
+ // Verifying the component renders successfully with a custom className
+ expect(screen.getByTestId('slider')).toBeInTheDocument()
+ expect(container.firstChild).toHaveClass('custom-class')
+ })
+
+ it('should pass value to the slider', () => {
+ render( )
+
+ expect(screen.getByTestId('slider')).toHaveValue('95')
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/type.spec.ts b/web/app/components/base/features/new-feature-panel/annotation-reply/type.spec.ts
new file mode 100644
index 0000000000..0bbb6d695b
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/annotation-reply/type.spec.ts
@@ -0,0 +1,8 @@
+import { PageType } from './type'
+
+describe('PageType', () => {
+ it('should have log and annotation values', () => {
+ expect(PageType.log).toBe('log')
+ expect(PageType.annotation).toBe('annotation')
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.spec.ts b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.spec.ts
new file mode 100644
index 0000000000..f7ea3a0117
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.spec.ts
@@ -0,0 +1,241 @@
+import type { AnnotationReplyConfig } from '@/models/debug'
+import { act, renderHook } from '@testing-library/react'
+import useAnnotationConfig from './use-annotation-config'
+
+let mockIsAnnotationFull = false
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ plan: {
+ usage: { annotatedResponse: mockIsAnnotationFull ? 100 : 5 },
+ total: { annotatedResponse: 100 },
+ },
+ enableBilling: true,
+ }),
+}))
+
+vi.mock('@/service/annotation', () => ({
+ updateAnnotationStatus: vi.fn().mockResolvedValue({ job_id: 'test-job-id' }),
+ queryAnnotationJobStatus: vi.fn().mockResolvedValue({ job_status: 'completed' }),
+}))
+
+vi.mock('@/utils', () => ({
+ sleep: vi.fn().mockResolvedValue(undefined),
+}))
+
+describe('useAnnotationConfig', () => {
+ const defaultConfig: AnnotationReplyConfig = {
+ id: 'test-id',
+ enabled: false,
+ score_threshold: 0.9,
+ embedding_model: {
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ },
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsAnnotationFull = false
+ })
+
+ it('should initialize with annotation config init hidden', () => {
+ const setAnnotationConfig = vi.fn()
+ const { result } = renderHook(() => useAnnotationConfig({
+ appId: 'test-app',
+ annotationConfig: defaultConfig,
+ setAnnotationConfig,
+ }))
+
+ expect(result.current.isShowAnnotationConfigInit).toBe(false)
+ expect(result.current.isShowAnnotationFullModal).toBe(false)
+ })
+
+ it('should show annotation config init modal', () => {
+ const setAnnotationConfig = vi.fn()
+ const { result } = renderHook(() => useAnnotationConfig({
+ appId: 'test-app',
+ annotationConfig: defaultConfig,
+ setAnnotationConfig,
+ }))
+
+ act(() => {
+ result.current.setIsShowAnnotationConfigInit(true)
+ })
+
+ expect(result.current.isShowAnnotationConfigInit).toBe(true)
+ })
+
+ it('should hide annotation config init modal', () => {
+ const setAnnotationConfig = vi.fn()
+ const { result } = renderHook(() => useAnnotationConfig({
+ appId: 'test-app',
+ annotationConfig: defaultConfig,
+ setAnnotationConfig,
+ }))
+
+ act(() => {
+ result.current.setIsShowAnnotationConfigInit(true)
+ })
+ act(() => {
+ result.current.setIsShowAnnotationConfigInit(false)
+ })
+
+ expect(result.current.isShowAnnotationConfigInit).toBe(false)
+ })
+
+ it('should enable annotation and update config', async () => {
+ const setAnnotationConfig = vi.fn()
+ const { result } = renderHook(() => useAnnotationConfig({
+ appId: 'test-app',
+ annotationConfig: defaultConfig,
+ setAnnotationConfig,
+ }))
+
+ await act(async () => {
+ await result.current.handleEnableAnnotation({
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-3-small',
+ }, 0.95)
+ })
+
+ expect(setAnnotationConfig).toHaveBeenCalled()
+ const updatedConfig = setAnnotationConfig.mock.calls[0][0]
+ expect(updatedConfig.enabled).toBe(true)
+ expect(updatedConfig.embedding_model.embedding_model_name).toBe('text-embedding-3-small')
+ })
+
+ it('should disable annotation and update config', async () => {
+ const enabledConfig = { ...defaultConfig, enabled: true }
+ const setAnnotationConfig = vi.fn()
+ const { result } = renderHook(() => useAnnotationConfig({
+ appId: 'test-app',
+ annotationConfig: enabledConfig,
+ setAnnotationConfig,
+ }))
+
+ await act(async () => {
+ await result.current.handleDisableAnnotation({
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ })
+ })
+
+ expect(setAnnotationConfig).toHaveBeenCalled()
+ const updatedConfig = setAnnotationConfig.mock.calls[0][0]
+ expect(updatedConfig.enabled).toBe(false)
+ })
+
+ it('should not disable when already disabled', async () => {
+ const setAnnotationConfig = vi.fn()
+ const { result } = renderHook(() => useAnnotationConfig({
+ appId: 'test-app',
+ annotationConfig: defaultConfig,
+ setAnnotationConfig,
+ }))
+
+ await act(async () => {
+ await result.current.handleDisableAnnotation({
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ })
+ })
+
+ expect(setAnnotationConfig).not.toHaveBeenCalled()
+ })
+
+ it('should set score threshold', () => {
+ const setAnnotationConfig = vi.fn()
+ const { result } = renderHook(() => useAnnotationConfig({
+ appId: 'test-app',
+ annotationConfig: defaultConfig,
+ setAnnotationConfig,
+ }))
+
+ act(() => {
+ result.current.setScore(0.85)
+ })
+
+ expect(setAnnotationConfig).toHaveBeenCalled()
+ const updatedConfig = setAnnotationConfig.mock.calls[0][0]
+ expect(updatedConfig.score_threshold).toBe(0.85)
+ })
+
+ it('should set score and embedding model together', () => {
+ const setAnnotationConfig = vi.fn()
+ const { result } = renderHook(() => useAnnotationConfig({
+ appId: 'test-app',
+ annotationConfig: defaultConfig,
+ setAnnotationConfig,
+ }))
+
+ act(() => {
+ result.current.setScore(0.95, {
+ embedding_provider_name: 'cohere',
+ embedding_model_name: 'embed-english',
+ })
+ })
+
+ expect(setAnnotationConfig).toHaveBeenCalled()
+ const updatedConfig = setAnnotationConfig.mock.calls[0][0]
+ expect(updatedConfig.score_threshold).toBe(0.95)
+ expect(updatedConfig.embedding_model.embedding_provider_name).toBe('cohere')
+ })
+
+ it('should show annotation full modal instead of config init when annotation is full', () => {
+ mockIsAnnotationFull = true
+ const setAnnotationConfig = vi.fn()
+ const { result } = renderHook(() => useAnnotationConfig({
+ appId: 'test-app',
+ annotationConfig: defaultConfig,
+ setAnnotationConfig,
+ }))
+
+ act(() => {
+ result.current.setIsShowAnnotationConfigInit(true)
+ })
+
+ expect(result.current.isShowAnnotationFullModal).toBe(true)
+ expect(result.current.isShowAnnotationConfigInit).toBe(false)
+ })
+
+ it('should not enable annotation when annotation is full', async () => {
+ mockIsAnnotationFull = true
+ const setAnnotationConfig = vi.fn()
+ const { result } = renderHook(() => useAnnotationConfig({
+ appId: 'test-app',
+ annotationConfig: defaultConfig,
+ setAnnotationConfig,
+ }))
+
+ await act(async () => {
+ await result.current.handleEnableAnnotation({
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-3-small',
+ })
+ })
+
+ expect(setAnnotationConfig).not.toHaveBeenCalled()
+ })
+
+ it('should set default score_threshold when enabling without one', async () => {
+ const configWithoutThreshold = { ...defaultConfig, score_threshold: undefined as unknown as number }
+ const setAnnotationConfig = vi.fn()
+ const { result } = renderHook(() => useAnnotationConfig({
+ appId: 'test-app',
+ annotationConfig: configWithoutThreshold,
+ setAnnotationConfig,
+ }))
+
+ await act(async () => {
+ await result.current.handleEnableAnnotation({
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-3-small',
+ }, 0.95)
+ })
+
+ expect(setAnnotationConfig).toHaveBeenCalled()
+ const updatedConfig = setAnnotationConfig.mock.calls[0][0]
+ expect(updatedConfig.enabled).toBe(true)
+ expect(updatedConfig.score_threshold).toBeDefined()
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/citation.spec.tsx b/web/app/components/base/features/new-feature-panel/citation.spec.tsx
new file mode 100644
index 0000000000..ed50ea9337
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/citation.spec.tsx
@@ -0,0 +1,48 @@
+import type { OnFeaturesChange } from '../types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import * as React from 'react'
+import { FeaturesProvider } from '../context'
+import Citation from './citation'
+
+const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('Citation', () => {
+ it('should render the citation feature card', () => {
+ renderWithProvider()
+
+ expect(screen.getByText(/feature\.citation\.title/)).toBeInTheDocument()
+ })
+
+ it('should render description text', () => {
+ renderWithProvider()
+
+ expect(screen.getByText(/feature\.citation\.description/)).toBeInTheDocument()
+ })
+
+ it('should render a switch toggle', () => {
+ renderWithProvider()
+
+ expect(screen.getByRole('switch')).toBeInTheDocument()
+ })
+
+ it('should call onChange when toggled', () => {
+ const onChange = vi.fn()
+ renderWithProvider({ onChange })
+
+ fireEvent.click(screen.getByRole('switch'))
+
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not throw when onChange is not provided', () => {
+ renderWithProvider()
+
+ expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/index.spec.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/index.spec.tsx
new file mode 100644
index 0000000000..20e85c9378
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/conversation-opener/index.spec.tsx
@@ -0,0 +1,187 @@
+import type { Features } from '../../types'
+import type { OnFeaturesChange } from '@/app/components/base/features/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { FeaturesProvider } from '../../context'
+import ConversationOpener from './index'
+
+const mockSetShowOpeningModal = vi.fn()
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowOpeningModal: mockSetShowOpeningModal,
+ }),
+}))
+
+const defaultFeatures: Features = {
+ moreLikeThis: { enabled: false },
+ opening: { enabled: false },
+ suggested: { enabled: false },
+ text2speech: { enabled: false },
+ speech2text: { enabled: false },
+ citation: { enabled: false },
+ moderation: { enabled: false },
+ file: { enabled: false },
+ annotationReply: { enabled: false },
+}
+
+const renderWithProvider = (
+ props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
+ featureOverrides?: Partial,
+) => {
+ const features = { ...defaultFeatures, ...featureOverrides }
+ return render(
+
+
+ ,
+ )
+}
+
+describe('ConversationOpener', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the conversation opener title', () => {
+ renderWithProvider()
+
+ expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument()
+ })
+
+ it('should render description when not enabled', () => {
+ renderWithProvider()
+
+ expect(screen.getByText(/feature\.conversationOpener\.description/)).toBeInTheDocument()
+ })
+
+ it('should render a switch toggle', () => {
+ renderWithProvider()
+
+ expect(screen.getByRole('switch')).toBeInTheDocument()
+ })
+
+ it('should call onChange when toggled', () => {
+ const onChange = vi.fn()
+ renderWithProvider({ onChange })
+
+ fireEvent.click(screen.getByRole('switch'))
+
+ expect(onChange).toHaveBeenCalled()
+ })
+
+ it('should show opening statement when enabled and not hovering', () => {
+ renderWithProvider({}, {
+ opening: { enabled: true, opening_statement: 'Welcome to the app!' },
+ })
+
+ expect(screen.getByText('Welcome to the app!')).toBeInTheDocument()
+ })
+
+ it('should show placeholder when enabled but no opening statement', () => {
+ renderWithProvider({}, {
+ opening: { enabled: true, opening_statement: '' },
+ })
+
+ expect(screen.getByText(/openingStatement\.placeholder/)).toBeInTheDocument()
+ })
+
+ it('should show edit button when hovering over enabled feature', () => {
+ renderWithProvider({}, {
+ opening: { enabled: true, opening_statement: 'Hello' },
+ })
+
+ const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+
+ expect(screen.getByText(/openingStatement\.writeOpener/)).toBeInTheDocument()
+ })
+
+ it('should open modal when edit button is clicked', () => {
+ renderWithProvider({}, {
+ opening: { enabled: true, opening_statement: 'Hello' },
+ })
+
+ const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+ fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
+
+ expect(mockSetShowOpeningModal).toHaveBeenCalled()
+ })
+
+ it('should not open modal when disabled', () => {
+ renderWithProvider({ disabled: true }, {
+ opening: { enabled: true, opening_statement: 'Hello' },
+ })
+
+ const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+ fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
+
+ expect(mockSetShowOpeningModal).not.toHaveBeenCalled()
+ })
+
+ it('should pass opening data to modal', () => {
+ renderWithProvider({}, {
+ opening: { enabled: true, opening_statement: 'Hello' },
+ })
+
+ const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+ fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
+
+ const modalCall = mockSetShowOpeningModal.mock.calls[0][0]
+ expect(modalCall.payload).toBeDefined()
+ expect(modalCall.onSaveCallback).toBeDefined()
+ expect(modalCall.onCancelCallback).toBeDefined()
+ })
+
+ it('should invoke onSaveCallback and update features', () => {
+ const onChange = vi.fn()
+ renderWithProvider({ onChange }, {
+ opening: { enabled: true, opening_statement: 'Hello' },
+ })
+
+ const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+ fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
+
+ const modalCall = mockSetShowOpeningModal.mock.calls[0][0]
+ modalCall.onSaveCallback({ enabled: true, opening_statement: 'Updated' })
+
+ expect(onChange).toHaveBeenCalled()
+ })
+
+ it('should invoke onCancelCallback', () => {
+ const onChange = vi.fn()
+ renderWithProvider({ onChange }, {
+ opening: { enabled: true, opening_statement: 'Hello' },
+ })
+
+ const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+ fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
+
+ const modalCall = mockSetShowOpeningModal.mock.calls[0][0]
+ modalCall.onCancelCallback()
+
+ expect(onChange).toHaveBeenCalled()
+ })
+
+ it('should show info and hide when hovering over enabled feature', () => {
+ renderWithProvider({}, {
+ opening: { enabled: true, opening_statement: 'Welcome!' },
+ })
+
+ // Before hover, opening statement visible
+ expect(screen.getByText('Welcome!')).toBeInTheDocument()
+
+ const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
+ fireEvent.mouseEnter(card)
+
+ // After hover, button visible, statement hidden
+ expect(screen.getByText(/openingStatement\.writeOpener/)).toBeInTheDocument()
+
+ fireEvent.mouseLeave(card)
+
+ // After leave, statement visible again
+ expect(screen.getByText('Welcome!')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.spec.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.spec.tsx
new file mode 100644
index 0000000000..c5acda4bd5
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.spec.tsx
@@ -0,0 +1,510 @@
+import type { OpeningStatement } from '@/app/components/base/features/types'
+import type { InputVar } from '@/app/components/workflow/types'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { InputVarType } from '@/app/components/workflow/types'
+import OpeningSettingModal from './modal'
+
+const getPromptEditor = () => {
+ const editor = document.querySelector('[data-lexical-editor="true"]')
+ expect(editor).toBeInTheDocument()
+ return editor as HTMLElement
+}
+
+vi.mock('@/utils/var', () => ({
+ checkKeys: (_keys: string[]) => ({ isValid: true }),
+ getNewVar: (key: string, type: string) => ({ key, name: key, type, required: true }),
+}))
+
+vi.mock('@/app/components/app/configuration/config-prompt/confirm-add-var', () => ({
+ default: ({ varNameArr, onConfirm, onCancel }: {
+ varNameArr: string[]
+ onConfirm: () => void
+ onCancel: () => void
+ }) => (
+
+ {varNameArr.join(',')}
+ Confirm
+ Cancel
+
+ ),
+}))
+
+vi.mock('react-sortablejs', () => ({
+ ReactSortable: ({ children }: { children: React.ReactNode }) => {children}
,
+}))
+
+const defaultData: OpeningStatement = {
+ enabled: true,
+ opening_statement: 'Hello, how can I help?',
+ suggested_questions: ['Question 1', 'Question 2'],
+}
+
+const createMockInputVar = (overrides: Partial = {}): InputVar => ({
+ variable: 'name',
+ label: 'Name',
+ type: InputVarType.textInput,
+ required: true,
+ ...overrides,
+})
+
+describe('OpeningSettingModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render the modal title', async () => {
+ await render(
+ ,
+ )
+
+ expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument()
+ })
+
+ it('should render the opening statement in the editor', async () => {
+ await render(
+ ,
+ )
+
+ expect(getPromptEditor()).toHaveTextContent('Hello, how can I help?')
+ })
+
+ it('should render suggested questions', async () => {
+ await render(
+ ,
+ )
+
+ expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
+ })
+
+ it('should render cancel and save buttons', async () => {
+ await render(
+ ,
+ )
+
+ expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
+ expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
+ })
+
+ it('should call onCancel when cancel is clicked', async () => {
+ const onCancel = vi.fn()
+ await render(
+ ,
+ )
+
+ await userEvent.click(screen.getByText(/operation\.cancel/))
+
+ expect(onCancel).toHaveBeenCalled()
+ })
+
+ it('should call onCancel when close icon is clicked', async () => {
+ const onCancel = vi.fn()
+ await render(
+ ,
+ )
+
+ const closeButton = screen.getByTestId('close-modal')
+ await userEvent.click(closeButton)
+
+ expect(onCancel).toHaveBeenCalled()
+ })
+
+ it('should call onCancel when close icon receives Enter key', async () => {
+ const onCancel = vi.fn()
+ await render(
+ ,
+ )
+
+ const closeButton = screen.getByTestId('close-modal')
+ closeButton.focus()
+ await userEvent.keyboard('{Enter}')
+
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onCancel when close icon receives Space key', async () => {
+ const onCancel = vi.fn()
+ await render(
+ ,
+ )
+
+ const closeButton = screen.getByTestId('close-modal')
+ closeButton.focus()
+ fireEvent.keyDown(closeButton, { key: ' ' })
+
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onSave with updated data when save is clicked', async () => {
+ const onSave = vi.fn()
+ await render(
+ ,
+ )
+
+ await userEvent.click(screen.getByText(/operation\.save/))
+
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
+ opening_statement: 'Hello, how can I help?',
+ suggested_questions: ['Question 1', 'Question 2'],
+ }))
+ })
+
+ it('should disable save when opening statement is empty', async () => {
+ await render(
+ ,
+ )
+
+ const saveButton = screen.getByText(/operation\.save/).closest('button')
+ expect(saveButton).toBeDisabled()
+ })
+
+ it('should add a new suggested question', async () => {
+ await render(
+ ,
+ )
+
+ // Before adding: 2 existing questions
+ expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
+
+ await userEvent.click(screen.getByText(/variableConfig\.addOption/))
+
+ // After adding: the 2 existing questions still present plus 1 new empty one
+ expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
+ // The new empty question renders as an input with empty value
+ const allInputs = screen.getAllByDisplayValue('')
+ expect(allInputs.length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('should delete a suggested question via save verification', async () => {
+ const onSave = vi.fn()
+ await render(
+ ,
+ )
+
+ // Question should be present initially
+ expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
+
+ const deleteIconWrapper = screen.getByTestId('delete-question-Question 1').parentElement
+ expect(deleteIconWrapper).toBeTruthy()
+ await userEvent.click(deleteIconWrapper!)
+
+ // After deletion, Question 1 should be gone
+ expect(screen.queryByDisplayValue('Question 1')).not.toBeInTheDocument()
+ })
+
+ it('should update a suggested question value', async () => {
+ await render(
+ ,
+ )
+
+ const input = screen.getByDisplayValue('Question 1')
+ await userEvent.clear(input)
+ await userEvent.type(input, 'Updated Question')
+
+ expect(input).toHaveValue('Updated Question')
+ })
+
+ it('should show confirm dialog when variables are not in prompt', async () => {
+ await render(
+ ,
+ )
+
+ await userEvent.click(screen.getByText(/operation\.save/))
+
+ expect(screen.getByTestId('confirm-add-var')).toBeInTheDocument()
+ })
+
+ it('should save without variable check when confirm cancel is clicked', async () => {
+ const onSave = vi.fn()
+ await render(
+ ,
+ )
+
+ await userEvent.click(screen.getByText(/operation\.save/))
+ await userEvent.click(screen.getByTestId('cancel-add'))
+
+ expect(onSave).toHaveBeenCalled()
+ })
+
+ it('should show question count', async () => {
+ await render(
+ ,
+ )
+
+ // Count is displayed as "2/10" across child elements
+ expect(screen.getByText(/openingStatement\.openingQuestion/)).toBeInTheDocument()
+ })
+
+ it('should call onAutoAddPromptVariable when confirm add is clicked', async () => {
+ const onAutoAddPromptVariable = vi.fn()
+ await render(
+ ,
+ )
+
+ await userEvent.click(screen.getByText(/operation\.save/))
+ // Confirm add var dialog should appear
+ await userEvent.click(screen.getByTestId('confirm-add'))
+
+ expect(onAutoAddPromptVariable).toHaveBeenCalled()
+ })
+
+ it('should not show add button when max questions reached', async () => {
+ const questionsAtMax: OpeningStatement = {
+ enabled: true,
+ opening_statement: 'Hello',
+ suggested_questions: Array.from({ length: 10 }, (_, i) => `Q${i + 1}`),
+ }
+ await render(
+ ,
+ )
+
+ expect(screen.queryByText(/variableConfig\.addOption/)).not.toBeInTheDocument()
+ })
+
+ it('should apply and remove focused styling on question input focus/blur', async () => {
+ await render(
+ ,
+ )
+
+ const input = screen.getByDisplayValue('Question 1') as HTMLInputElement
+ const questionRow = input.parentElement
+
+ expect(input).toBeInTheDocument()
+ expect(questionRow).not.toHaveClass('border-components-input-border-active')
+
+ await userEvent.click(input)
+ expect(questionRow).toHaveClass('border-components-input-border-active')
+
+ // Tab press to blur
+ await userEvent.tab()
+ expect(questionRow).not.toHaveClass('border-components-input-border-active')
+ })
+
+ it('should apply and remove deleting styling on delete icon hover', async () => {
+ await render(
+ ,
+ )
+
+ const questionInput = screen.getByDisplayValue('Question 1') as HTMLInputElement
+ const questionRow = questionInput.parentElement
+ const deleteIconWrapper = screen.getByTestId('delete-question-Question 1').parentElement
+
+ expect(questionRow).not.toHaveClass('border-components-input-border-destructive')
+ expect(deleteIconWrapper).toBeTruthy()
+
+ await userEvent.hover(deleteIconWrapper!)
+ expect(questionRow).toHaveClass('border-components-input-border-destructive')
+
+ await userEvent.unhover(deleteIconWrapper!)
+ expect(questionRow).not.toHaveClass('border-components-input-border-destructive')
+ })
+
+ it('should handle save with empty suggested questions', async () => {
+ const onSave = vi.fn()
+ await render(
+ ,
+ )
+
+ await userEvent.click(screen.getByText(/operation\.save/))
+
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
+ suggested_questions: [],
+ }))
+ })
+
+ it('should not save when opening statement is only whitespace', async () => {
+ const onSave = vi.fn()
+ await render(
+ ,
+ )
+
+ await userEvent.click(screen.getByText(/operation\.save/))
+
+ expect(onSave).not.toHaveBeenCalled()
+ })
+
+ it('should skip variable check when variables match prompt variables', async () => {
+ const onSave = vi.fn()
+ await render(
+ ,
+ )
+
+ await userEvent.click(screen.getByText(/operation\.save/))
+
+ // Variable is in promptVariables, so no confirm dialog
+ expect(screen.queryByTestId('confirm-add-var')).not.toBeInTheDocument()
+ expect(onSave).toHaveBeenCalled()
+ })
+
+ it('should skip variable check when variables match workflow variables', async () => {
+ const onSave = vi.fn()
+ await render(
+ ,
+ )
+
+ await userEvent.click(screen.getByText(/operation\.save/))
+
+ // Variable matches workflow variables, so no confirm dialog
+ expect(screen.queryByTestId('confirm-add-var')).not.toBeInTheDocument()
+ expect(onSave).toHaveBeenCalled()
+ })
+
+ it('should show confirm dialog when variables not in workflow variables', async () => {
+ await render(
+ ,
+ )
+
+ await userEvent.click(screen.getByText(/operation\.save/))
+
+ expect(screen.getByTestId('confirm-add-var')).toBeInTheDocument()
+ })
+
+ it('should use updated opening statement after prop changes', async () => {
+ const onSave = vi.fn()
+ const view = await render(
+ ,
+ )
+
+ await act(async () => {
+ view.rerender(
+ ,
+ )
+ await Promise.resolve()
+ await new Promise(resolve => setTimeout(resolve, 0))
+ })
+
+ await userEvent.click(screen.getByText(/operation\.save/))
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
+ opening_statement: 'New greeting!',
+ }))
+ })
+
+ it('should render empty opening statement with placeholder in editor', async () => {
+ await render(
+ ,
+ )
+
+ const editor = getPromptEditor()
+ expect(editor.textContent?.trim()).toBe('')
+ expect(screen.getByText('appDebug.openingStatement.placeholder')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx
index 79520134a4..a7f1e013f5 100644
--- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx
+++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx
@@ -1,7 +1,6 @@
import type { OpeningStatement } from '@/app/components/base/features/types'
import type { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug'
-import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
@@ -139,7 +138,7 @@ const OpeningSettingModal = ({
)}
key={index}
>
-
+
setDeletingID(index)}
onMouseLeave={() => setDeletingID(null)}
>
-
+