mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
Merge commit '9c339239' into sandboxed-agent-rebase
Made-with: Cursor # Conflicts: # api/README.md # api/controllers/console/app/workflow_draft_variable.py # api/core/agent/cot_agent_runner.py # api/core/agent/fc_agent_runner.py # api/core/app/apps/advanced_chat/app_runner.py # api/core/plugin/backwards_invocation/model.py # api/core/prompt/advanced_prompt_transform.py # api/core/workflow/nodes/base/node.py # api/core/workflow/nodes/llm/llm_utils.py # api/core/workflow/nodes/llm/node.py # api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py # api/core/workflow/nodes/question_classifier/question_classifier_node.py # api/core/workflow/runtime/graph_runtime_state.py # api/extensions/storage/base_storage.py # api/factories/variable_factory.py # api/pyproject.toml # api/services/variable_truncator.py # api/uv.lock # web/app/account/oauth/authorize/page.tsx # web/app/components/app/configuration/config-var/config-modal/field.tsx # web/app/components/base/alert.tsx # web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx # web/app/components/base/chat/chat/answer/more.tsx # web/app/components/base/chat/chat/answer/operation.tsx # web/app/components/base/chat/chat/answer/workflow-process.tsx # web/app/components/base/chat/chat/citation/index.tsx # web/app/components/base/chat/chat/citation/popup.tsx # web/app/components/base/chat/chat/citation/progress-tooltip.tsx # web/app/components/base/chat/chat/citation/tooltip.tsx # web/app/components/base/chat/chat/question.tsx # web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx # web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx # web/app/components/base/markdown-blocks/form.tsx # web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx # web/app/components/base/tag-management/panel.tsx # web/app/components/base/tag-management/trigger.tsx # web/app/components/header/account-setting/index.tsx # web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx # web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx # web/app/signin/utils/post-login-redirect.ts # web/eslint-suppressions.json # web/package.json # web/pnpm-lock.yaml
This commit is contained in:
@ -0,0 +1,364 @@
|
||||
/* 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,
|
||||
enable_collaboration_mode: 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,
|
||||
enable_creators_platform: 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,9 +88,11 @@ 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="text-text-tertiary system-2xs-medium-uppercase">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
{
|
||||
@ -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>
|
||||
)}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user