test: add unit tests for chat/embedded-chatbot components (#32361)

Co-authored-by: akashseth-ifp <akash.seth@infocusp.com>
This commit is contained in:
Poojan 2026-02-24 18:28:45 +05:30 committed by GitHub
parent bcd5dd0f81
commit b8fbd7b0f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1504 additions and 59 deletions

View File

@ -0,0 +1,400 @@
import type { ChatConfig, ChatItem, ChatItemInTree } from '../types'
import type { EmbeddedChatbotContextValue } from './context'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import {
AppSourceType,
fetchSuggestedQuestions,
submitHumanInputForm,
} from '@/service/share'
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { useChat } from '../chat/hooks'
import ChatWrapper from './chat-wrapper'
import { useEmbeddedChatbotContext } from './context'
vi.mock('./context', () => ({
useEmbeddedChatbotContext: vi.fn(),
}))
vi.mock('../chat/hooks', () => ({
useChat: vi.fn(),
}))
vi.mock('./inputs-form', () => ({
__esModule: true,
default: () => <div>inputs form</div>,
}))
vi.mock('../chat', () => ({
__esModule: true,
default: ({
chatNode,
chatList,
inputDisabled,
questionIcon,
answerIcon,
onSend,
onRegenerate,
switchSibling,
onHumanInputFormSubmit,
onStopResponding,
}: {
chatNode: React.ReactNode
chatList: ChatItem[]
inputDisabled: boolean
questionIcon?: React.ReactNode
answerIcon?: React.ReactNode
onSend: (message: string) => void
onRegenerate: (chatItem: ChatItem, editedQuestion?: { message: string, files?: never[] }) => void
switchSibling: (siblingMessageId: string) => void
onHumanInputFormSubmit: (formToken: string, formData: Record<string, string>) => Promise<void>
onStopResponding: () => void
}) => (
<div>
<div>{chatNode}</div>
{answerIcon}
{chatList.map(item => <div key={item.id}>{item.content}</div>)}
<div>
chat count:
{' '}
{chatList.length}
</div>
{questionIcon}
<button onClick={() => onSend('hello world')}>send through chat</button>
<button onClick={() => onRegenerate({ id: 'answer-1', isAnswer: true, content: 'answer', parentMessageId: 'question-1' })}>regenerate answer</button>
<button onClick={() => switchSibling('sibling-2')}>switch sibling</button>
<button disabled={inputDisabled}>send message</button>
<button onClick={onStopResponding}>stop responding</button>
<button onClick={() => onHumanInputFormSubmit('form-token', { answer: 'ok' })}>submit human input</button>
</div>
),
}))
vi.mock('@/service/share', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/share')>()
return {
...actual,
fetchSuggestedQuestions: vi.fn(),
getUrl: vi.fn(() => '/chat-messages'),
stopChatMessageResponding: vi.fn(),
submitHumanInputForm: vi.fn(),
}
})
vi.mock('@/service/workflow', () => ({
submitHumanInputForm: vi.fn(),
}))
const mockIsDify = vi.fn(() => false)
vi.mock('./utils', () => ({
isDify: () => mockIsDify(),
}))
type UseChatReturn = ReturnType<typeof useChat>
const createContextValue = (overrides: Partial<EmbeddedChatbotContextValue> = {}): EmbeddedChatbotContextValue => ({
appMeta: { tool_icons: {} },
appData: {
app_id: 'app-1',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
enable_site: true,
end_user_id: 'user-1',
site: {
title: 'Embedded App',
icon_type: 'emoji',
icon: 'bot',
icon_background: '#000000',
icon_url: '',
use_icon_as_answer_icon: false,
},
},
appParams: {} as ChatConfig,
appChatListDataLoading: false,
currentConversationId: '',
currentConversationItem: undefined,
appPrevChatList: [],
pinnedConversationList: [],
conversationList: [],
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: vi.fn(),
inputsForms: [],
handleNewConversation: vi.fn(),
handleStartChat: vi.fn(),
handleChangeConversation: vi.fn(),
handleNewConversationCompleted: vi.fn(),
chatShouldReloadKey: 'reload-key',
isMobile: false,
isInstalledApp: false,
appSourceType: AppSourceType.webApp,
allowResetChat: true,
appId: 'app-1',
disableFeedback: false,
handleFeedback: vi.fn(),
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
themeBuilder: undefined,
clearChatList: false,
setClearChatList: vi.fn(),
isResponding: false,
setIsResponding: vi.fn(),
currentConversationInputs: {},
setCurrentConversationInputs: vi.fn(),
allInputsHidden: false,
initUserVariables: {},
...overrides,
})
const createUseChatReturn = (overrides: Partial<UseChatReturn> = {}): UseChatReturn => ({
chatList: [],
setTargetMessageId: vi.fn() as UseChatReturn['setTargetMessageId'],
handleSend: vi.fn(),
handleResume: vi.fn(),
setIsResponding: vi.fn() as UseChatReturn['setIsResponding'],
handleStop: vi.fn(),
handleSwitchSibling: vi.fn(),
isResponding: false,
suggestedQuestions: [],
handleRestart: vi.fn(),
handleAnnotationEdited: vi.fn(),
handleAnnotationAdded: vi.fn(),
handleAnnotationRemoved: vi.fn(),
...overrides,
})
describe('EmbeddedChatbot chat-wrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue())
vi.mocked(useChat).mockReturnValue(createUseChatReturn())
})
describe('Welcome behavior', () => {
it('should show opening message and suggested question for a new chat', () => {
const handleSwitchSibling = vi.fn()
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
handleSwitchSibling,
chatList: [{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome to the app', suggestedQuestions: ['How does it work?'] }],
}))
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
appPrevChatList: [
{
id: 'parent-node',
content: 'parent',
isAnswer: true,
children: [
{
id: 'paused-workflow',
content: 'paused',
isAnswer: true,
workflow_run_id: 'run-1',
humanInputFormDataList: [{ label: 'Need info' }],
} as unknown as ChatItem,
],
} as unknown as ChatItem,
],
}))
render(<ChatWrapper />)
expect(screen.getByText('How does it work?')).toBeInTheDocument()
expect(handleSwitchSibling).toHaveBeenCalledWith('paused-workflow', expect.objectContaining({
isPublicAPI: true,
}))
const resumeOptions = handleSwitchSibling.mock.calls[0]?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void }
resumeOptions.onGetSuggestedQuestions('resume-1')
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resume-1', AppSourceType.webApp, 'app-1')
})
it('should hide or show welcome content based on chat state', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
inputsForms: [{ variable: 'name', label: 'Name', required: true, type: InputVarType.textInput }],
currentConversationId: '',
allInputsHidden: false,
}))
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
chatList: [{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome to the app' }],
}))
render(<ChatWrapper />)
expect(screen.queryByText('Welcome to the app')).not.toBeInTheDocument()
expect(screen.getByText('inputs form')).toBeInTheDocument()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
inputsForms: [],
currentConversationId: '',
allInputsHidden: true,
}))
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
chatList: [{ id: 'opening-2', isAnswer: true, isOpeningStatement: true, content: 'Fallback welcome' }],
}))
render(<ChatWrapper />)
expect(screen.queryByText('inputs form')).not.toBeInTheDocument()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
appData: null,
}))
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
isResponding: false,
chatList: [{ id: 'opening-3', isAnswer: true, isOpeningStatement: true, content: 'Should be hidden' }],
}))
render(<ChatWrapper />)
expect(screen.queryByText('Should be hidden')).not.toBeInTheDocument()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue())
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
isResponding: true,
chatList: [{ id: 'opening-4', isAnswer: true, isOpeningStatement: true, content: 'Should be hidden while responding' }],
}))
render(<ChatWrapper />)
expect(screen.queryByText('Should be hidden while responding')).not.toBeInTheDocument()
})
})
describe('Input and avatar behavior', () => {
it('should disable sending when required fields are incomplete or uploading', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
inputsForms: [{ variable: 'email', label: 'Email', required: true, type: InputVarType.textInput }],
newConversationInputsRef: { current: {} },
}))
render(<ChatWrapper />)
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
inputsForms: [{ variable: 'file', label: 'File', required: true, type: InputVarType.multiFiles }],
newConversationInputsRef: {
current: {
file: [
{
transferMethod: 'local_file',
},
],
},
},
}))
render(<ChatWrapper />)
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
inputsForms: [{ variable: 'singleFile', label: 'Single file', required: true, type: InputVarType.singleFile }],
newConversationInputsRef: {
current: {
singleFile: {
transferMethod: 'local_file',
},
},
},
}))
render(<ChatWrapper />)
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
})
it('should show the user name when avatar data is provided', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
initUserVariables: {
avatar_url: 'https://example.com/avatar.png',
name: 'Alice',
},
}))
render(<ChatWrapper />)
expect(screen.getByRole('img', { name: 'Alice' })).toBeInTheDocument()
})
})
describe('Human input submit behavior', () => {
it('should submit via installed app service when the app is installed', async () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
isInstalledApp: true,
}))
render(<ChatWrapper />)
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
await waitFor(() => {
expect(submitHumanInputFormService).toHaveBeenCalledWith('form-token', { answer: 'ok' })
})
expect(submitHumanInputForm).not.toHaveBeenCalled()
})
it('should submit via share service and support chat actions in web app mode', async () => {
const handleSend = vi.fn()
const handleSwitchSibling = vi.fn()
const handleStop = vi.fn()
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
handleSend,
handleSwitchSibling,
handleStop,
chatList: [
{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome' },
{ id: 'question-1', isAnswer: false, content: 'Question' },
{ id: 'answer-1', isAnswer: true, content: 'Answer', parentMessageId: 'question-1' },
] as ChatItemInTree[],
}))
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
isInstalledApp: false,
appSourceType: AppSourceType.tryApp,
isMobile: true,
inputsForms: [{ variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }],
currentConversationId: 'conversation-1',
}))
mockIsDify.mockReturnValue(true)
render(<ChatWrapper />)
expect(screen.getByText('chat count: 3')).toBeInTheDocument()
expect(screen.queryByText('inputs form')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'send through chat' }))
fireEvent.click(screen.getByRole('button', { name: 'regenerate answer' }))
fireEvent.click(screen.getByRole('button', { name: 'switch sibling' }))
fireEvent.click(screen.getByRole('button', { name: 'stop responding' }))
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
await waitFor(() => {
expect(submitHumanInputForm).toHaveBeenCalledWith('form-token', { answer: 'ok' })
})
expect(handleSend).toHaveBeenCalledTimes(2)
const sendOptions = handleSend.mock.calls[0]?.[2] as { onGetSuggestedQuestions: (responseItemId: string) => void }
sendOptions.onGetSuggestedQuestions('resp-1')
expect(handleSwitchSibling).toHaveBeenCalledWith('sibling-2', expect.objectContaining({
isPublicAPI: false,
}))
const switchOptions = handleSwitchSibling.mock.calls.find(call => call[0] === 'sibling-2')?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void }
switchOptions.onGetSuggestedQuestions('resp-2')
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-1', AppSourceType.tryApp, 'app-1')
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-2', AppSourceType.tryApp, 'app-1')
expect(handleStop).toHaveBeenCalled()
expect(screen.queryByRole('img', { name: 'Alice' })).not.toBeInTheDocument()
cleanup()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
isMobile: true,
currentConversationId: '',
inputsForms: [{ variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }],
}))
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
chatList: [{ id: 'opening-mobile', isAnswer: true, isOpeningStatement: true, content: 'Mobile welcome' }],
}))
render(<ChatWrapper />)
expect(screen.getByText('inputs form')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,362 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import type { EmbeddedChatbotContextValue } from '../context'
import type { AppData } from '@/models/share'
import type { SystemFeatures } from '@/types/feature'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { InstallationScope, LicenseStatus } from '@/types/feature'
import { useEmbeddedChatbotContext } from '../context'
import Header from './index'
vi.mock('../context', () => ({
useEmbeddedChatbotContext: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown', () => ({
default: () => <div data-testid="view-form-dropdown" />,
}))
// Mock next/image to render a normal img tag for testing
vi.mock('next/image', () => ({
__esModule: true,
default: (props: ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => {
const { unoptimized: _, ...rest } = props
return <img {...rest} />
},
}))
type GlobalPublicStoreMock = {
systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void
}
describe('EmbeddedChatbot Header', () => {
const defaultAppData: AppData = {
app_id: 'test-app-id',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
enable_site: true,
end_user_id: 'test-user-id',
site: {
title: 'Test Site',
},
}
const defaultContext: Partial<EmbeddedChatbotContextValue> = {
appData: defaultAppData,
currentConversationId: 'test-conv-id',
inputsForms: [],
allInputsHidden: false,
}
const defaultSystemFeatures: SystemFeatures = {
trial_models: [],
plugin_installation_permission: {
plugin_installation_scope: InstallationScope.ALL,
restrict_to_marketplace_only: false,
},
sso_enforced_for_signin: false,
sso_enforced_for_signin_protocol: '',
sso_enforced_for_web: false,
sso_enforced_for_web_protocol: '',
enable_marketplace: false,
enable_change_email: false,
enable_email_code_login: false,
enable_email_password_login: false,
enable_social_oauth_login: false,
is_allow_create_workspace: false,
is_allow_register: false,
is_email_setup: false,
license: {
status: LicenseStatus.NONE,
expired_at: '',
},
branding: {
enabled: true,
workspace_logo: '',
login_page_logo: '',
favicon: '',
application_title: '',
},
webapp_auth: {
enabled: false,
allow_sso: false,
sso_config: { protocol: '' },
allow_email_code_login: false,
allow_email_password_login: false,
},
enable_trial_app: false,
enable_explore_banner: false,
}
const setupIframe = () => {
const mockPostMessage = vi.fn()
const mockTop = { postMessage: mockPostMessage }
Object.defineProperty(window, 'self', { value: {}, configurable: true })
Object.defineProperty(window, 'top', { value: mockTop, configurable: true })
Object.defineProperty(window, 'parent', { value: mockTop, configurable: true })
return mockPostMessage
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(defaultContext as EmbeddedChatbotContextValue)
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: defaultSystemFeatures,
setSystemFeatures: vi.fn(),
}))
Object.defineProperty(window, 'self', { value: window, configurable: true })
Object.defineProperty(window, 'top', { value: window, configurable: true })
})
describe('Desktop Rendering', () => {
it('should render desktop header with branding by default', async () => {
render(<Header title="Test Chatbot" />)
expect(screen.getByTestId('webapp-brand')).toBeInTheDocument()
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
})
it('should render custom logo when provided in appData', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
appData: {
...defaultAppData,
custom_config: {
...defaultAppData.custom_config,
replace_webapp_logo: 'https://example.com/logo.png',
},
},
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
const img = screen.getByAltText('logo')
expect(img).toHaveAttribute('src', 'https://example.com/logo.png')
})
it('should render workspace logo when branding is enabled and logo exists', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
workspace_logo: 'https://example.com/workspace.png',
},
},
setSystemFeatures: vi.fn(),
}))
render(<Header title="Test Chatbot" />)
const img = screen.getByAltText('logo')
expect(img).toHaveAttribute('src', 'https://example.com/workspace.png')
})
it('should render Dify logo by default when no branding or custom logo is provided', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: false,
},
},
setSystemFeatures: vi.fn(),
}))
render(<Header title="Test Chatbot" />)
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
})
it('should NOT render branding when remove_webapp_brand is true', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
appData: {
...defaultAppData,
custom_config: {
...defaultAppData.custom_config,
remove_webapp_brand: true,
},
},
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.queryByTestId('webapp-brand')).not.toBeInTheDocument()
})
it('should render reset button when allowResetChat is true and conversation exists', () => {
render(<Header title="Test Chatbot" allowResetChat={true} />)
expect(screen.getByTestId('reset-chat-button')).toBeInTheDocument()
})
it('should call onCreateNewChat when reset button is clicked', async () => {
const user = userEvent.setup()
const onCreateNewChat = vi.fn()
render(<Header title="Test Chatbot" allowResetChat={true} onCreateNewChat={onCreateNewChat} />)
await user.click(screen.getByTestId('reset-chat-button'))
expect(onCreateNewChat).toHaveBeenCalled()
})
it('should render ViewFormDropdown when conditions are met', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
inputsForms: [{ id: '1' }],
allInputsHidden: false,
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.getByTestId('view-form-dropdown')).toBeInTheDocument()
})
it('should NOT render ViewFormDropdown when inputs are hidden', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
inputsForms: [{ id: '1' }],
allInputsHidden: true,
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
})
it('should NOT render ViewFormDropdown when currentConversationId is missing', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
currentConversationId: '',
inputsForms: [{ id: '1' }],
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
})
})
describe('Mobile Rendering', () => {
it('should render mobile header with title', () => {
render(<Header title="Mobile Chatbot" isMobile />)
expect(screen.getByText('Mobile Chatbot')).toBeInTheDocument()
})
it('should render customer icon in mobile header', () => {
render(<Header title="Mobile Chatbot" isMobile customerIcon={<div data-testid="custom-icon" />} />)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('should render mobile reset button when allowed', () => {
render(<Header title="Mobile Chatbot" isMobile allowResetChat />)
expect(screen.getByTestId('mobile-reset-chat-button')).toBeInTheDocument()
})
})
describe('Iframe Communication', () => {
it('should send dify-chatbot-iframe-ready on mount', () => {
const mockPostMessage = setupIframe()
render(<Header title="Iframe" />)
expect(mockPostMessage).toHaveBeenCalledWith(
{ type: 'dify-chatbot-iframe-ready' },
'*',
)
})
it('should update expand button visibility and handle click', async () => {
const user = userEvent.setup()
const mockPostMessage = setupIframe()
render(<Header title="Iframe" />)
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://parent.com',
data: {
type: 'dify-chatbot-config',
payload: { isToggledByButton: true, isDraggable: false },
},
}))
const expandBtn = await screen.findByTestId('expand-button')
expect(expandBtn).toBeInTheDocument()
await user.click(expandBtn)
expect(mockPostMessage).toHaveBeenCalledWith(
{ type: 'dify-chatbot-expand-change' },
'https://parent.com',
)
expect(expandBtn.querySelector('.i-ri-collapse-diagonal-2-line')).toBeInTheDocument()
})
it('should NOT show expand button if isDraggable is true', async () => {
setupIframe()
render(<Header title="Iframe" />)
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://parent.com',
data: {
type: 'dify-chatbot-config',
payload: { isToggledByButton: true, isDraggable: true },
},
}))
await waitFor(() => {
expect(screen.queryByTestId('expand-button')).not.toBeInTheDocument()
})
})
it('should ignore messages from different origins after security lock', async () => {
setupIframe()
render(<Header title="Iframe" />)
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://secure.com',
data: { type: 'dify-chatbot-config', payload: { isToggledByButton: true, isDraggable: false } },
}))
await screen.findByTestId('expand-button')
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://malicious.com',
data: { type: 'dify-chatbot-config', payload: { isToggledByButton: false, isDraggable: false } },
}))
expect(screen.getByTestId('expand-button')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle document.referrer for targetOrigin', () => {
const mockPostMessage = setupIframe()
Object.defineProperty(document, 'referrer', { value: 'https://referrer.com', configurable: true })
render(<Header title="Referrer" />)
expect(mockPostMessage).toHaveBeenCalledWith(
expect.anything(),
'https://referrer.com',
)
})
it('should NOT add message listener if not in iframe', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
render(<Header title="Direct" />)
expect(addSpy).not.toHaveBeenCalledWith('message', expect.any(Function))
})
})
})

View File

@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { Theme } from '../theme/theme-context'
import { RiCollapseDiagonal2Line, RiExpandDiagonal2Line, RiResetLeftLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -89,11 +88,13 @@ const Header: FC<IHeaderProps> = ({
{/* powered by */}
<div className="shrink-0">
{!appData?.custom_config?.remove_webapp_brand && (
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-2',
)}
<div
className={cn(
'flex shrink-0 items-center gap-1.5 px-2',
)}
data-testid="webapp-brand"
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">{t('chat.poweredBy', { ns: 'share' })}</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
@ -112,11 +113,11 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
>
<ActionButton size="l" onClick={handleToggleExpand}>
<ActionButton size="l" onClick={handleToggleExpand} data-testid="expand-button">
{
expanded
? <RiCollapseDiagonal2Line className="h-[18px] w-[18px]" />
: <RiExpandDiagonal2Line className="h-[18px] w-[18px]" />
? <div className="i-ri-collapse-diagonal-2-line h-[18px] w-[18px]" />
: <div className="i-ri-expand-diagonal-2-line h-[18px] w-[18px]" />
}
</ActionButton>
</Tooltip>
@ -126,8 +127,8 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={t('chat.resetChat', { ns: 'share' })}
>
<ActionButton size="l" onClick={onCreateNewChat}>
<RiResetLeftLine className="h-[18px] w-[18px]" />
<ActionButton size="l" onClick={onCreateNewChat} data-testid="reset-chat-button">
<div className="i-ri-reset-left-line h-[18px] w-[18px]" />
</ActionButton>
</Tooltip>
)}
@ -147,7 +148,7 @@ const Header: FC<IHeaderProps> = ({
<div className="flex grow items-center space-x-3">
{customerIcon}
<div
className="system-md-semibold truncate"
className="truncate system-md-semibold"
style={CssTransform(theme?.colorFontOnHeaderStyle ?? '')}
>
{title}
@ -159,11 +160,11 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
>
<ActionButton size="l" onClick={handleToggleExpand}>
<ActionButton size="l" onClick={handleToggleExpand} data-testid="mobile-expand-button">
{
expanded
? <RiCollapseDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
: <RiExpandDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
? <div className={cn('i-ri-collapse-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
: <div className={cn('i-ri-expand-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
}
</ActionButton>
</Tooltip>
@ -173,8 +174,8 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={t('chat.resetChat', { ns: 'share' })}
>
<ActionButton size="l" onClick={onCreateNewChat}>
<RiResetLeftLine className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
<ActionButton size="l" onClick={onCreateNewChat} data-testid="mobile-reset-chat-button">
<div className={cn('i-ri-reset-left-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
</ActionButton>
</Tooltip>
)}

View File

@ -0,0 +1,240 @@
import type { RefObject } from 'react'
import type { ChatConfig } from '../types'
import type { AppData, AppMeta, ConversationItem } from '@/models/share'
import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { defaultSystemFeatures } from '@/types/feature'
import { useEmbeddedChatbot } from './hooks'
import EmbeddedChatbot from './index'
vi.mock('./hooks', () => ({
useEmbeddedChatbot: vi.fn(),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(),
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('./chat-wrapper', () => ({
__esModule: true,
default: () => <div>chat area</div>,
}))
vi.mock('./header', () => ({
__esModule: true,
default: () => <div>chat header</div>,
}))
vi.mock('./theme/theme-context', () => ({
useThemeContext: vi.fn(() => ({
buildTheme: vi.fn(),
theme: {
backgroundHeaderColorStyle: '',
},
})),
}))
const mockIsDify = vi.fn(() => false)
vi.mock('./utils', () => ({
isDify: () => mockIsDify(),
}))
type EmbeddedChatbotHookReturn = ReturnType<typeof useEmbeddedChatbot>
const createHookReturn = (overrides: Partial<EmbeddedChatbotHookReturn> = {}): EmbeddedChatbotHookReturn => {
const appData: AppData = {
app_id: 'app-1',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
enable_site: true,
end_user_id: 'user-1',
site: {
title: 'Embedded App',
chat_color_theme: 'blue',
chat_color_theme_inverted: false,
},
}
const base: EmbeddedChatbotHookReturn = {
appSourceType: 'webApp' as EmbeddedChatbotHookReturn['appSourceType'],
isInstalledApp: false,
appId: 'app-1',
currentConversationId: '',
currentConversationItem: undefined,
removeConversationIdInfo: vi.fn(),
handleConversationIdInfoChange: vi.fn(),
appData,
appParams: {} as ChatConfig,
appMeta: { tool_icons: {} } as AppMeta,
appPinnedConversationData: { data: [], has_more: false, limit: 20 },
appConversationData: { data: [], has_more: false, limit: 20 },
appConversationDataLoading: false,
appChatListData: { data: [], has_more: false, limit: 20 },
appChatListDataLoading: false,
appPrevChatList: [],
pinnedConversationList: [] as ConversationItem[],
conversationList: [] as ConversationItem[],
setShowNewConversationItemInList: vi.fn(),
newConversationInputs: {},
newConversationInputsRef: { current: {} } as unknown as RefObject<Record<string, unknown>>,
handleNewConversationInputsChange: vi.fn(),
inputsForms: [],
handleNewConversation: vi.fn(),
handleStartChat: vi.fn(),
handleChangeConversation: vi.fn(),
handleNewConversationCompleted: vi.fn(),
newConversationId: '',
chatShouldReloadKey: 'reload-key',
allowResetChat: true,
handleFeedback: vi.fn(),
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
clearChatList: false,
setClearChatList: vi.fn(),
isResponding: false,
setIsResponding: vi.fn(),
currentConversationInputs: {},
setCurrentConversationInputs: vi.fn(),
allInputsHidden: false,
initUserVariables: {},
}
return {
...base,
...overrides,
}
}
describe('EmbeddedChatbot index', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn())
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: '',
},
},
setSystemFeatures: vi.fn(),
}))
})
describe('Loading and chat content', () => {
it('should show loading state before chat content', () => {
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({ appChatListDataLoading: true }))
render(<EmbeddedChatbot />)
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByText('chat area')).not.toBeInTheDocument()
})
it('should render chat content when loading finishes', () => {
render(<EmbeddedChatbot />)
expect(screen.getByText('chat area')).toBeInTheDocument()
})
})
describe('Powered by branding', () => {
it('should show workspace logo on mobile when branding is enabled', () => {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
},
},
setSystemFeatures: vi.fn(),
}))
render(<EmbeddedChatbot />)
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png')
})
it('should show custom logo when workspace branding logo is unavailable', () => {
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({
appData: {
app_id: 'app-1',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: 'https://example.com/custom-logo.png',
},
enable_site: true,
end_user_id: 'user-1',
site: {
title: 'Embedded App',
chat_color_theme: 'blue',
chat_color_theme_inverted: false,
},
},
}))
render(<EmbeddedChatbot />)
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/custom-logo.png')
})
it('should hide powered by section when branding is removed', () => {
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({
appData: {
app_id: 'app-1',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: true,
replace_webapp_logo: '',
},
enable_site: true,
end_user_id: 'user-1',
site: {
title: 'Embedded App',
chat_color_theme: 'blue',
chat_color_theme_inverted: false,
},
},
}))
render(<EmbeddedChatbot />)
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
})
it('should not show powered by section on desktop', () => {
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({ appData: null }))
mockIsDify.mockReturnValue(true)
render(<EmbeddedChatbot />)
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
expect(screen.getByText('chat header')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,263 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
import { useEmbeddedChatbotContext } from '../context'
import InputsFormContent from './content'
vi.mock('../context', () => ({
useEmbeddedChatbotContext: vi.fn(),
}))
vi.mock('next/navigation', () => ({
useParams: () => ({ token: 'test-token' }),
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: vi.fn() }),
}))
// Mock CodeEditor to trigger onChange easily
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value, onChange, placeholder }: { value: string, onChange: (v: string) => void, placeholder: string | React.ReactNode }) => (
<textarea
data-testid="mock-code-editor"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={typeof placeholder === 'string' ? placeholder : 'json-placeholder'}
/>
),
}))
// Mock FileUploaderInAttachmentWrapper to trigger onChange easily
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: any[], onChange: (v: any[]) => void }) => (
<div data-testid="mock-file-uploader">
<button onClick={() => onChange([new File([''], 'test.png', { type: 'image/png' })])}>Upload</button>
<span>{value.length > 0 ? value[0].name : 'no file'}</span>
</div>
),
}))
const mockContextValue = {
appParams: {
system_parameters: {
file_size_limit: 10,
},
},
inputsForms: [
{
variable: 'text_var',
label: 'Text Label',
type: InputVarType.textInput,
required: true,
},
{
variable: 'num_var',
label: 'Number Label',
type: InputVarType.number,
required: false,
},
{
variable: 'para_var',
label: 'Paragraph Label',
type: InputVarType.paragraph,
required: true,
},
{
variable: 'bool_var',
label: 'Bool Label',
type: InputVarType.checkbox,
required: true,
},
{
variable: 'select_var',
label: 'Select Label',
type: InputVarType.select,
options: ['Option 1', 'Option 2'],
required: true,
},
{
variable: 'file_var',
label: 'File Label',
type: InputVarType.singleFile,
required: true,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_upload'],
},
{
variable: 'multi_file_var',
label: 'Multi File Label',
type: InputVarType.multiFiles,
required: true,
max_length: 5,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_upload'],
},
{
variable: 'json_var',
label: 'JSON Label',
type: InputVarType.jsonObject,
required: true,
json_schema: '{ "type": "object" }',
},
{
variable: 'hidden_var',
label: 'Hidden Label',
type: InputVarType.textInput,
hide: true,
},
],
currentConversationId: null,
currentConversationInputs: {},
setCurrentConversationInputs: vi.fn(),
newConversationInputs: {},
newConversationInputsRef: { current: {} },
handleNewConversationInputsChange: vi.fn(),
}
describe('InputsFormContent', () => {
const user = userEvent.setup()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(mockContextValue as unknown as any)
})
it('should render visible input forms', () => {
render(<InputsFormContent />)
expect(screen.getAllByText(/Text Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/Number Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/Paragraph Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/Bool Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/Select Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/File Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/Multi File Label/i).length).toBeGreaterThan(0)
expect(screen.getAllByText(/JSON Label/i).length).toBeGreaterThan(0)
expect(screen.queryByText('Hidden Label')).not.toBeInTheDocument()
})
it('should render optional label for non-required fields', () => {
render(<InputsFormContent />)
expect(screen.queryAllByText(/panel.optional/i).length).toBeGreaterThan(0)
})
it('should handle text input changes', async () => {
render(<InputsFormContent />)
const inputs = screen.getAllByPlaceholderText('Text Label')
await user.type(inputs[0], 'hello')
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle number input changes', async () => {
render(<InputsFormContent />)
const inputs = screen.getAllByPlaceholderText('Number Label')
await user.type(inputs[0], '123')
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle paragraph input changes', async () => {
render(<InputsFormContent />)
const inputs = screen.getAllByPlaceholderText('Paragraph Label')
await user.type(inputs[0], 'long text')
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle bool input changes', async () => {
render(<InputsFormContent />)
const checkbox = screen.getByTestId(/checkbox-/i)
await user.click(checkbox)
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle select input changes', async () => {
render(<InputsFormContent />)
const selectTrigger = screen.getAllByText(/Select Label/i).find(el => el.tagName === 'SPAN')
if (!selectTrigger)
throw new Error('Select trigger not found')
await user.click(selectTrigger)
const option = screen.getByText('Option 1')
await user.click(option)
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle single file upload change', async () => {
render(<InputsFormContent />)
const uploadButtons = screen.getAllByText('Upload')
await user.click(uploadButtons[0]) // First one is single file
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle multi files upload change', async () => {
render(<InputsFormContent />)
const uploadButtons = screen.getAllByText('Upload')
await user.click(uploadButtons[1]) // Second one is multi files
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should handle JSON object change', async () => {
render(<InputsFormContent />)
const jsonEditor = screen.getByTestId('mock-code-editor')
fireEvent.change(jsonEditor, { target: { value: '{ "a": 1 }' } })
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
})
it('should show tip when showTip is true', () => {
render(<InputsFormContent showTip />)
expect(screen.getByText(/chat.chatFormTip/i)).toBeInTheDocument()
})
it('should set initial values from context', () => {
const contextWithValues = {
...mockContextValue,
newConversationInputs: {
text_var: 'initial value',
},
}
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(contextWithValues as unknown as any)
render(<InputsFormContent />)
expect(screen.getByDisplayValue('initial value')).toBeInTheDocument()
})
it('should use currentConversationInputs when currentConversationId exists', () => {
const contextWithConv = {
...mockContextValue,
currentConversationId: 'conv-id',
currentConversationInputs: {
text_var: 'conv value',
},
}
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(contextWithConv as unknown as any)
render(<InputsFormContent />)
expect(screen.getByDisplayValue('conv value')).toBeInTheDocument()
})
})

View File

@ -45,12 +45,12 @@ const InputsFormContent = ({ showTip }: Props) => {
return (
<div className="space-y-4">
{visibleInputsForms.map(form => (
<div key={form.variable} className="space-y-1">
<div key={form.variable} className="space-y-1" data-testid={`inputs-form-item-${form.variable}`}>
{form.type !== InputVarType.checkbox && (
<div className="flex h-6 items-center gap-1">
<div className="system-md-semibold text-text-secondary">{form.label}</div>
<div className="text-text-secondary system-md-semibold">{form.label}</div>
{!form.required && (
<div className="system-xs-regular text-text-tertiary">{t('panel.optional', { ns: 'workflow' })}</div>
<div className="text-text-tertiary system-xs-regular">{t('panel.optional', { ns: 'workflow' })}</div>
)}
</div>
)}
@ -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={
<div className="whitespace-pre">{form.json_schema}</div>
}
@ -134,7 +134,7 @@ const InputsFormContent = ({ showTip }: Props) => {
</div>
))}
{showTip && (
<div className="system-xs-regular text-text-tertiary">{t('chat.chatFormTip', { ns: 'share' })}</div>
<div className="text-text-tertiary system-xs-regular">{t('chat.chatFormTip', { ns: 'share' })}</div>
)}
</div>
)

View File

@ -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: () => <div data-testid="mock-inputs-form-content" />,
}))
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(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
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(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
expect(container.firstChild).toBeNull()
})
it('should render expanded state correctly', () => {
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
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(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />)
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(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />)
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(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
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(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
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(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
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(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
const mainDiv = screen.getByTestId('inputs-form-node')
expect(mainDiv).toHaveClass('mb-0 px-0')
})
})

View File

@ -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 (
<div className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4', isTryApp && 'mb-0 px-0')}>
<div
data-testid="inputs-form-node"
className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4', isTryApp && 'mb-0 px-0')}
>
<div className={cn(
'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md',
collapsed && 'border border-components-card-border bg-components-card-bg shadow-none',
@ -46,13 +48,29 @@ const InputsFormNode = ({
isMobile && 'px-4 py-3',
)}
>
<Message3Fill className="h-6 w-6 shrink-0" />
<div className="system-xl-semibold grow text-text-secondary">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
<div className="i-custom-public-other-message-3-fill h-6 w-6 shrink-0" />
<div className="grow text-text-secondary system-xl-semibold">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
{collapsed && (
<Button className="uppercase text-text-tertiary" size="small" variant="ghost" onClick={() => setCollapsed(false)}>{t('operation.edit', { ns: 'common' })}</Button>
<Button
className="uppercase text-text-tertiary"
size="small"
variant="ghost"
onClick={() => setCollapsed(false)}
data-testid="inputs-form-edit-button"
>
{t('operation.edit', { ns: 'common' })}
</Button>
)}
{!collapsed && currentConversationId && (
<Button className="uppercase text-text-tertiary" size="small" variant="ghost" onClick={() => setCollapsed(true)}>{t('operation.close', { ns: 'common' })}</Button>
<Button
className="uppercase text-text-tertiary"
size="small"
variant="ghost"
onClick={() => setCollapsed(true)}
data-testid="inputs-form-close-button"
>
{t('operation.close', { ns: 'common' })}
</Button>
)}
</div>
{!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
? {

View File

@ -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: () => <div data-testid="mock-inputs-form-content" />,
}))
// 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(<ViewFormDropdown />)
expect(screen.getByTestId('view-form-dropdown-trigger')).toBeInTheDocument()
})
it('should not show content initially', () => {
render(<ViewFormDropdown />)
expect(screen.queryByTestId('view-form-dropdown-content')).not.toBeInTheDocument()
})
it('should show content when trigger is clicked', async () => {
render(<ViewFormDropdown />)
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(<ViewFormDropdown />)
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(<ViewFormDropdown iconColor="text-red-500" />)
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')
})
})

View File

@ -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,
}}
>
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
>
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
<RiChatSettingsLine className={cn('h-[18px] w-[18px]', iconColor)} />
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<ActionButton
size="l"
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
data-testid="view-form-dropdown-trigger"
>
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[99]">
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm">
<div
data-testid="view-form-dropdown-content"
className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm"
>
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
<Message3Fill className="h-6 w-6 shrink-0" />
<div className="system-xl-semibold grow text-text-secondary">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
<div className="i-custom-public-other-message-3-fill h-6 w-6 shrink-0" />
<div className="grow text-text-secondary system-xl-semibold">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
</div>
<div className="p-6">
<InputsFormContent />
@ -45,7 +50,6 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}

View File

@ -1553,11 +1553,6 @@
"count": 7
}
},
"app/components/base/chat/embedded-chatbot/header/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/base/chat/embedded-chatbot/hooks.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
@ -1569,23 +1564,10 @@
}
},
"app/components/base/chat/embedded-chatbot/inputs-form/content.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
},
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/base/chat/embedded-chatbot/inputs-form/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/chat/utils.ts": {
"ts/no-explicit-any": {
"count": 10