From b8fbd7b0f6eb039510805c1cafa3a582f6528ee8 Mon Sep 17 00:00:00 2001 From: Poojan Date: Tue, 24 Feb 2026 18:28:45 +0530 Subject: [PATCH] test: add unit tests for chat/embedded-chatbot components (#32361) Co-authored-by: akashseth-ifp --- .../embedded-chatbot/chat-wrapper.spec.tsx | 400 ++++++++++++++++++ .../embedded-chatbot/header/index.spec.tsx | 362 ++++++++++++++++ .../chat/embedded-chatbot/header/index.tsx | 33 +- .../base/chat/embedded-chatbot/index.spec.tsx | 240 +++++++++++ .../inputs-form/content.spec.tsx | 263 ++++++++++++ .../embedded-chatbot/inputs-form/content.tsx | 10 +- .../inputs-form/index.spec.tsx | 121 ++++++ .../embedded-chatbot/inputs-form/index.tsx | 31 +- .../inputs-form/view-form-dropdown.spec.tsx | 53 +++ .../inputs-form/view-form-dropdown.tsx | 32 +- web/eslint-suppressions.json | 18 - 11 files changed, 1504 insertions(+), 59 deletions(-) create mode 100644 web/app/components/base/chat/embedded-chatbot/chat-wrapper.spec.tsx create mode 100644 web/app/components/base/chat/embedded-chatbot/header/index.spec.tsx create mode 100644 web/app/components/base/chat/embedded-chatbot/index.spec.tsx create mode 100644 web/app/components/base/chat/embedded-chatbot/inputs-form/content.spec.tsx create mode 100644 web/app/components/base/chat/embedded-chatbot/inputs-form/index.spec.tsx create mode 100644 web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.spec.tsx diff --git a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.spec.tsx b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.spec.tsx new file mode 100644 index 0000000000..d0b23627f0 --- /dev/null +++ b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.spec.tsx @@ -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: () =>
inputs form
, +})) + +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) => Promise + onStopResponding: () => void + }) => ( +
+
{chatNode}
+ {answerIcon} + {chatList.map(item =>
{item.content}
)} +
+ chat count: + {' '} + {chatList.length} +
+ {questionIcon} + + + + + + +
+ ), +})) + +vi.mock('@/service/share', async (importOriginal) => { + const actual = await importOriginal() + 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 + +const createContextValue = (overrides: Partial = {}): 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 => ({ + 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() + + 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() + + 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() + 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() + 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() + 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() + + 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() + 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() + 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() + + 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() + 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() + + 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() + expect(screen.getByText('inputs form')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.spec.tsx new file mode 100644 index 0000000000..31323c7196 --- /dev/null +++ b/web/app/components/base/chat/embedded-chatbot/header/index.spec.tsx @@ -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: () =>
, +})) + +// Mock next/image to render a normal img tag for testing +vi.mock('next/image', () => ({ + __esModule: true, + default: (props: ImgHTMLAttributes & { unoptimized?: boolean }) => { + const { unoptimized: _, ...rest } = props + return + }, +})) + +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 = { + 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(
) + + 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(
) + + 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(
) + + 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(
) + 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(
) + + expect(screen.queryByTestId('webapp-brand')).not.toBeInTheDocument() + }) + + it('should render reset button when allowResetChat is true and conversation exists', () => { + render(
) + + 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(
) + + 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(
) + + 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(
) + + 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(
) + + expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument() + }) + }) + + describe('Mobile Rendering', () => { + it('should render mobile header with title', () => { + render(
) + + expect(screen.getByText('Mobile Chatbot')).toBeInTheDocument() + }) + + it('should render customer icon in mobile header', () => { + render(
} />) + + expect(screen.getByTestId('custom-icon')).toBeInTheDocument() + }) + + it('should render mobile reset button when allowed', () => { + render(
) + + expect(screen.getByTestId('mobile-reset-chat-button')).toBeInTheDocument() + }) + }) + + describe('Iframe Communication', () => { + it('should send dify-chatbot-iframe-ready on mount', () => { + const mockPostMessage = setupIframe() + render(
) + + 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(
) + + 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(
) + + 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(
) + + 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(
) + + 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(
) + expect(addSpy).not.toHaveBeenCalledWith('message', expect.any(Function)) + }) + }) +}) diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx index fe7afc9e22..9cca48b42a 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx @@ -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 = ({ {/* powered by */}
{!appData?.custom_config?.remove_webapp_brand && ( -
-
{t('chat.poweredBy', { ns: 'share' })}
+
{t('chat.poweredBy', { ns: 'share' })}
{ systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? logo @@ -112,11 +113,11 @@ const Header: FC = ({ - + { expanded - ? - : + ?
+ :
} @@ -126,8 +127,8 @@ const Header: FC = ({ - - + +
)} @@ -147,7 +148,7 @@ const Header: FC = ({
{customerIcon}
{title} @@ -159,11 +160,11 @@ const Header: FC = ({ - + { expanded - ? - : + ?
+ :
} @@ -173,8 +174,8 @@ const Header: FC = ({ - - + +
)} diff --git a/web/app/components/base/chat/embedded-chatbot/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/index.spec.tsx new file mode 100644 index 0000000000..48fe16f7b3 --- /dev/null +++ b/web/app/components/base/chat/embedded-chatbot/index.spec.tsx @@ -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: () =>
chat area
, +})) + +vi.mock('./header', () => ({ + __esModule: true, + default: () =>
chat header
, +})) + +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 + +const createHookReturn = (overrides: Partial = {}): 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>, + 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() + + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('chat area')).not.toBeInTheDocument() + }) + + it('should render chat content when loading finishes', () => { + render() + + 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() + + 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() + + 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() + + 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() + + expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument() + expect(screen.getByText('chat header')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/content.spec.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.spec.tsx new file mode 100644 index 0000000000..de7f810fcb --- /dev/null +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.spec.tsx @@ -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 }) => ( +