chore: add some tests case code (#29927)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Coding On Star <447357187@qq.com>
This commit is contained in:
Joel
2025-12-19 16:04:23 +08:00
committed by GitHub
parent 2efdb7b887
commit 89e4261883
6 changed files with 1454 additions and 1 deletions

View File

@@ -0,0 +1,379 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DatasetInfo from './index'
import Dropdown from './dropdown'
import Menu from './menu'
import MenuItem from './menu-item'
import type { DataSet } from '@/models/datasets'
import {
ChunkingMode,
DataSourceType,
DatasetPermission,
} from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { RiEditLine } from '@remixicon/react'
let mockDataset: DataSet
let mockIsDatasetOperator = false
const mockReplace = jest.fn()
const mockInvalidDatasetList = jest.fn()
const mockInvalidDatasetDetail = jest.fn()
const mockExportPipeline = jest.fn()
const mockCheckIsUsedInApp = jest.fn()
const mockDeleteDataset = jest.fn()
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: {
icon: '📙',
icon_background: '#FFF4ED',
icon_type: 'emoji',
icon_url: '',
},
description: 'Dataset description',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 1690000000,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 1,
total_document_count: 1,
word_count: 1000,
provider: 'internal',
embedding_model: 'text-embedding-3',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
runtime_mode: 'rag_pipeline',
enable_api: false,
is_multimodal: false,
...overrides,
})
jest.mock('next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }),
}))
jest.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) =>
selector({ isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator }),
}))
jest.mock('@/service/knowledge/use-dataset', () => ({
datasetDetailQueryKeyPrefix: ['dataset', 'detail'],
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
jest.mock('@/service/use-base', () => ({
useInvalid: () => mockInvalidDatasetDetail,
}))
jest.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({
mutateAsync: mockExportPipeline,
}),
}))
jest.mock('@/service/datasets', () => ({
checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args),
deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args),
}))
jest.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'indexing-technique',
}),
}))
jest.mock('@/app/components/datasets/rename-modal', () => ({
__esModule: true,
default: ({
show,
onClose,
onSuccess,
}: {
show: boolean
onClose: () => void
onSuccess?: () => void
}) => {
if (!show)
return null
return (
<div data-testid="rename-modal">
<button type="button" onClick={onSuccess}>Success</button>
<button type="button" onClick={onClose}>Close</button>
</div>
)
},
}))
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
const trigger = screen.getByRole('button')
await user.click(trigger)
}
describe('DatasetInfo', () => {
beforeEach(() => {
jest.clearAllMocks()
mockDataset = createDataset()
mockIsDatasetOperator = false
})
// Rendering of dataset summary details based on expand and dataset state.
describe('Rendering', () => {
it('should show dataset details when expanded', () => {
// Arrange
mockDataset = createDataset({ is_published: true })
render(<DatasetInfo expand />)
// Assert
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
expect(screen.getByText('Dataset description')).toBeInTheDocument()
expect(screen.getByText('dataset.chunkingMode.general')).toBeInTheDocument()
expect(screen.getByText('indexing-technique')).toBeInTheDocument()
})
it('should show external tag when provider is external', () => {
// Arrange
mockDataset = createDataset({ provider: 'external', is_published: false })
render(<DatasetInfo expand />)
// Assert
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
expect(screen.queryByText('dataset.chunkingMode.general')).not.toBeInTheDocument()
})
it('should hide detailed fields when collapsed', () => {
// Arrange
render(<DatasetInfo expand={false} />)
// Assert
expect(screen.queryByText('Dataset Name')).not.toBeInTheDocument()
expect(screen.queryByText('Dataset description')).not.toBeInTheDocument()
})
})
})
describe('MenuItem', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Event handling for menu item interactions.
describe('Interactions', () => {
it('should call handler when clicked', async () => {
const user = userEvent.setup()
const handleClick = jest.fn()
// Arrange
render(<MenuItem name="Edit" Icon={RiEditLine} handleClick={handleClick} />)
// Act
await user.click(screen.getByText('Edit'))
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
})
describe('Menu', () => {
beforeEach(() => {
jest.clearAllMocks()
mockDataset = createDataset()
})
// Rendering of menu options based on runtime mode and delete visibility.
describe('Rendering', () => {
it('should show edit, export, and delete options when rag pipeline and deletable', () => {
// Arrange
mockDataset = createDataset({ runtime_mode: 'rag_pipeline' })
render(
<Menu
showDelete
openRenameModal={jest.fn()}
handleExportPipeline={jest.fn()}
detectIsUsedByApp={jest.fn()}
/>,
)
// Assert
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.operations.exportPipeline')).toBeInTheDocument()
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
})
it('should hide export and delete options when not rag pipeline and not deletable', () => {
// Arrange
mockDataset = createDataset({ runtime_mode: 'general' })
render(
<Menu
showDelete={false}
openRenameModal={jest.fn()}
handleExportPipeline={jest.fn()}
detectIsUsedByApp={jest.fn()}
/>,
)
// Assert
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.queryByText('datasetPipeline.operations.exportPipeline')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
})
describe('Dropdown', () => {
beforeEach(() => {
jest.clearAllMocks()
mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' })
mockIsDatasetOperator = false
mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' })
mockCheckIsUsedInApp.mockResolvedValue({ is_using: false })
mockDeleteDataset.mockResolvedValue({})
if (!('createObjectURL' in URL)) {
Object.defineProperty(URL, 'createObjectURL', {
value: jest.fn(),
writable: true,
})
}
if (!('revokeObjectURL' in URL)) {
Object.defineProperty(URL, 'revokeObjectURL', {
value: jest.fn(),
writable: true,
})
}
})
// Rendering behavior based on workspace role.
describe('Rendering', () => {
it('should hide delete option when user is dataset operator', async () => {
const user = userEvent.setup()
// Arrange
mockIsDatasetOperator = true
render(<Dropdown expand />)
// Act
await openMenu(user)
// Assert
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
// User interactions that trigger modals and exports.
describe('Interactions', () => {
it('should open rename modal when edit is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('common.operation.edit'))
// Assert
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should export pipeline when export is clicked', async () => {
const user = userEvent.setup()
const anchorClickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click')
const createObjectURLSpy = jest.spyOn(URL, 'createObjectURL')
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
// Assert
await waitFor(() => {
expect(mockExportPipeline).toHaveBeenCalledWith({
pipelineId: 'pipeline-1',
include: false,
})
})
expect(createObjectURLSpy).toHaveBeenCalledTimes(1)
expect(anchorClickSpy).toHaveBeenCalledTimes(1)
})
it('should show delete confirmation when delete is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('common.operation.delete'))
// Assert
await waitFor(() => {
expect(screen.getByText('dataset.deleteDatasetConfirmContent')).toBeInTheDocument()
})
})
it('should delete dataset and redirect when confirm is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('common.operation.delete'))
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
// Assert
await waitFor(() => {
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
})
expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1)
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
})
})

View File

@@ -0,0 +1,167 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DuplicateAppModal from './index'
import Toast from '@/app/components/base/toast'
import type { ProviderContextState } from '@/context/provider-context'
import { baseProviderContextValue } from '@/context/provider-context'
import { Plan } from '@/app/components/billing/type'
const appsFullRenderSpy = jest.fn()
jest.mock('@/app/components/billing/apps-full-in-dialog', () => ({
__esModule: true,
default: ({ loc }: { loc: string }) => {
appsFullRenderSpy(loc)
return <div data-testid="apps-full">AppsFull</div>
},
}))
const useProviderContextMock = jest.fn<ProviderContextState, []>()
jest.mock('@/context/provider-context', () => {
const actual = jest.requireActual('@/context/provider-context')
return {
...actual,
useProviderContext: () => useProviderContextMock(),
}
})
const renderComponent = (overrides: Partial<React.ComponentProps<typeof DuplicateAppModal>> = {}) => {
const onConfirm = jest.fn().mockResolvedValue(undefined)
const onHide = jest.fn()
const props: React.ComponentProps<typeof DuplicateAppModal> = {
appName: 'My App',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FFEAD5',
icon_url: null,
show: true,
onConfirm,
onHide,
...overrides,
}
const utils = render(<DuplicateAppModal {...props} />)
return {
...utils,
onConfirm,
onHide,
}
}
const setupProviderContext = (overrides: Partial<ProviderContextState> = {}) => {
useProviderContextMock.mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: {
...baseProviderContextValue.plan.usage,
buildApps: 0,
},
total: {
...baseProviderContextValue.plan.total,
buildApps: 10,
},
},
enableBilling: false,
...overrides,
} as ProviderContextState)
}
describe('DuplicateAppModal', () => {
beforeEach(() => {
jest.clearAllMocks()
setupProviderContext()
})
// Rendering output based on modal visibility.
describe('Rendering', () => {
it('should render modal content when show is true', () => {
// Arrange
renderComponent()
// Assert
expect(screen.getByText('app.duplicateTitle')).toBeInTheDocument()
expect(screen.getByDisplayValue('My App')).toBeInTheDocument()
})
it('should not render modal content when show is false', () => {
// Arrange
renderComponent({ show: false })
// Assert
expect(screen.queryByText('app.duplicateTitle')).not.toBeInTheDocument()
})
})
// Prop-driven states such as full plan handling.
describe('Props', () => {
it('should disable duplicate button and show apps full content when plan is full', () => {
// Arrange
setupProviderContext({
enableBilling: true,
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: { ...baseProviderContextValue.plan.usage, buildApps: 10 },
total: { ...baseProviderContextValue.plan.total, buildApps: 10 },
},
})
renderComponent()
// Assert
expect(screen.getByTestId('apps-full')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'app.duplicate' })).toBeDisabled()
})
})
// User interactions for cancel and confirm flows.
describe('Interactions', () => {
it('should call onHide when cancel is clicked', async () => {
const user = userEvent.setup()
// Arrange
const { onHide } = renderComponent()
// Act
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
// Assert
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should show error toast when name is empty', async () => {
const user = userEvent.setup()
const toastSpy = jest.spyOn(Toast, 'notify')
// Arrange
const { onConfirm, onHide } = renderComponent()
// Act
await user.clear(screen.getByDisplayValue('My App'))
await user.click(screen.getByRole('button', { name: 'app.duplicate' }))
// Assert
expect(toastSpy).toHaveBeenCalledWith({ type: 'error', message: 'explore.appCustomize.nameRequired' })
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
it('should submit app info and hide modal when duplicate is clicked', async () => {
const user = userEvent.setup()
// Arrange
const { onConfirm, onHide } = renderComponent()
// Act
await user.clear(screen.getByDisplayValue('My App'))
await user.type(screen.getByRole('textbox'), 'New App')
await user.click(screen.getByRole('button', { name: 'app.duplicate' }))
// Assert
expect(onConfirm).toHaveBeenCalledWith({
name: 'New App',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FFEAD5',
})
expect(onHide).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,295 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SwitchAppModal from './index'
import { ToastContext } from '@/app/components/base/toast'
import type { App } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import { Plan } from '@/app/components/billing/type'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
const mockPush = jest.fn()
const mockReplace = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
replace: mockReplace,
}),
}))
const mockSetAppDetail = jest.fn()
jest.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: any) => unknown) => selector({ setAppDetail: mockSetAppDetail }),
}))
const mockSwitchApp = jest.fn()
const mockDeleteApp = jest.fn()
jest.mock('@/service/apps', () => ({
switchApp: (...args: unknown[]) => mockSwitchApp(...args),
deleteApp: (...args: unknown[]) => mockDeleteApp(...args),
}))
let mockIsEditor = true
jest.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsEditor,
userProfile: {
email: 'user@example.com',
},
langGeniusVersionInfo: {
current_version: '1.0.0',
},
}),
}))
let mockEnableBilling = false
let mockPlan = {
type: Plan.sandbox,
usage: {
buildApps: 0,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
vectorSpace: 0,
},
total: {
buildApps: 10,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
vectorSpace: 0,
},
}
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: mockPlan,
enableBilling: mockEnableBilling,
}),
}))
jest.mock('@/app/components/billing/apps-full-in-dialog', () => ({
__esModule: true,
default: ({ loc }: { loc: string }) => <div data-testid="apps-full">AppsFull {loc}</div>,
}))
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'app-123',
name: 'Demo App',
description: 'Demo description',
author_name: 'Demo author',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FFEAD5',
icon_url: null,
use_icon_as_answer_icon: false,
mode: AppModeEnum.COMPLETION,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: Date.now(),
updated_at: Date.now(),
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
...overrides,
})
const renderComponent = (overrides: Partial<React.ComponentProps<typeof SwitchAppModal>> = {}) => {
const notify = jest.fn()
const onClose = jest.fn()
const onSuccess = jest.fn()
const appDetail = createMockApp()
const utils = render(
<ToastContext.Provider value={{ notify, close: jest.fn() }}>
<SwitchAppModal
show
appDetail={appDetail}
onClose={onClose}
onSuccess={onSuccess}
{...overrides}
/>
</ToastContext.Provider>,
)
return {
...utils,
notify,
onClose,
onSuccess,
appDetail,
}
}
describe('SwitchAppModal', () => {
beforeEach(() => {
jest.clearAllMocks()
mockIsEditor = true
mockEnableBilling = false
mockPlan = {
type: Plan.sandbox,
usage: {
buildApps: 0,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
vectorSpace: 0,
},
total: {
buildApps: 10,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
vectorSpace: 0,
},
}
})
// Rendering behavior for modal visibility and default values.
describe('Rendering', () => {
it('should render modal content when show is true', () => {
// Arrange
renderComponent()
// Assert
expect(screen.getByText('app.switch')).toBeInTheDocument()
expect(screen.getByDisplayValue('Demo App(copy)')).toBeInTheDocument()
})
it('should not render modal content when show is false', () => {
// Arrange
renderComponent({ show: false })
// Assert
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
})
// Prop-driven UI states such as disabling actions.
describe('Props', () => {
it('should disable the start button when name is empty', async () => {
const user = userEvent.setup()
// Arrange
renderComponent()
// Act
const nameInput = screen.getByDisplayValue('Demo App(copy)')
await user.clear(nameInput)
// Assert
expect(screen.getByRole('button', { name: 'app.switchStart' })).toBeDisabled()
})
it('should render the apps full warning when plan limits are reached', () => {
// Arrange
mockEnableBilling = true
mockPlan = {
...mockPlan,
usage: { ...mockPlan.usage, buildApps: 10 },
total: { ...mockPlan.total, buildApps: 10 },
}
renderComponent()
// Assert
expect(screen.getByTestId('apps-full')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'app.switchStart' })).toBeDisabled()
})
})
// User interactions that trigger navigation and API calls.
describe('Interactions', () => {
it('should call onClose when cancel is clicked', async () => {
const user = userEvent.setup()
// Arrange
const { onClose } = renderComponent()
// Act
await user.click(screen.getByRole('button', { name: 'app.newApp.Cancel' }))
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should switch app and navigate with push when keeping original', async () => {
const user = userEvent.setup()
// Arrange
const { appDetail, notify, onClose, onSuccess } = renderComponent()
mockSwitchApp.mockResolvedValueOnce({ new_app_id: 'new-app-001' })
const setItemSpy = jest.spyOn(Storage.prototype, 'setItem')
// Act
await user.click(screen.getByRole('button', { name: 'app.switchStart' }))
// Assert
await waitFor(() => {
expect(mockSwitchApp).toHaveBeenCalledWith({
appID: appDetail.id,
name: 'Demo App(copy)',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FFEAD5',
})
})
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
expect(notify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' })
expect(setItemSpy).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1')
expect(mockPush).toHaveBeenCalledWith('/app/new-app-001/workflow')
expect(mockReplace).not.toHaveBeenCalled()
})
it('should delete the original app and use replace when remove original is confirmed', async () => {
const user = userEvent.setup()
// Arrange
const { appDetail } = renderComponent({ inAppDetail: true })
mockSwitchApp.mockResolvedValueOnce({ new_app_id: 'new-app-002' })
// Act
await user.click(screen.getByText('app.removeOriginal'))
const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
await user.click(confirmButton)
await user.click(screen.getByRole('button', { name: 'app.switchStart' }))
// Assert
await waitFor(() => {
expect(mockDeleteApp).toHaveBeenCalledWith(appDetail.id)
})
expect(mockReplace).toHaveBeenCalledWith('/app/new-app-002/workflow')
expect(mockPush).not.toHaveBeenCalled()
expect(mockSetAppDetail).toHaveBeenCalledTimes(1)
})
it('should notify error when switch app fails', async () => {
const user = userEvent.setup()
// Arrange
const { notify, onClose, onSuccess } = renderComponent()
mockSwitchApp.mockRejectedValueOnce(new Error('fail'))
// Act
await user.click(screen.getByRole('button', { name: 'app.switchStart' }))
// Assert
await waitFor(() => {
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' })
})
expect(onClose).not.toHaveBeenCalled()
expect(onSuccess).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,241 @@
import React, { useEffect, useRef, useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import RunOnce from './index'
import type { PromptConfig, PromptVariable } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionSettings } from '@/types/app'
import { Resolution, TransferMethod } from '@/types/app'
jest.mock('@/hooks/use-breakpoints', () => {
const MediaType = {
pc: 'pc',
pad: 'pad',
mobile: 'mobile',
}
const mockUseBreakpoints = jest.fn(() => MediaType.pc)
return {
__esModule: true,
default: mockUseBreakpoints,
MediaType,
}
})
jest.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
__esModule: true,
default: ({ value, onChange }: { value?: string; onChange?: (val: string) => void }) => (
<textarea data-testid="code-editor-mock" value={value} onChange={e => onChange?.(e.target.value)} />
),
}))
jest.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => {
function TextGenerationImageUploaderMock({ onFilesChange }: { onFilesChange: (files: any[]) => void }) {
useEffect(() => {
onFilesChange([])
}, [onFilesChange])
return <div data-testid="vision-uploader-mock" />
}
return {
__esModule: true,
default: TextGenerationImageUploaderMock,
}
})
const createPromptVariable = (overrides: Partial<PromptVariable>): PromptVariable => ({
key: 'input',
name: 'Input',
type: 'string',
required: true,
...overrides,
})
const basePromptConfig: PromptConfig = {
prompt_template: 'template',
prompt_variables: [
createPromptVariable({
key: 'textInput',
name: 'Text Input',
type: 'string',
default: 'default text',
}),
createPromptVariable({
key: 'paragraphInput',
name: 'Paragraph Input',
type: 'paragraph',
default: 'paragraph default',
}),
createPromptVariable({
key: 'numberInput',
name: 'Number Input',
type: 'number',
default: 42,
}),
createPromptVariable({
key: 'checkboxInput',
name: 'Checkbox Input',
type: 'checkbox',
}),
],
}
const baseVisionConfig: VisionSettings = {
enabled: true,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: 5,
}
const siteInfo: SiteInfo = {
title: 'Share',
}
const setup = (overrides: {
promptConfig?: PromptConfig
visionConfig?: VisionSettings
runControl?: React.ComponentProps<typeof RunOnce>['runControl']
} = {}) => {
const onInputsChange = jest.fn()
const onSend = jest.fn()
const onVisionFilesChange = jest.fn()
let inputsRefCapture: React.MutableRefObject<Record<string, any>> | null = null
const Wrapper = () => {
const [inputs, setInputs] = useState<Record<string, any>>({})
const inputsRef = useRef<Record<string, any>>({})
inputsRefCapture = inputsRef
return (
<RunOnce
siteInfo={siteInfo}
promptConfig={overrides.promptConfig || basePromptConfig}
inputs={inputs}
inputsRef={inputsRef}
onInputsChange={(updated) => {
inputsRef.current = updated
setInputs(updated)
onInputsChange(updated)
}}
onSend={onSend}
visionConfig={overrides.visionConfig || baseVisionConfig}
onVisionFilesChange={onVisionFilesChange}
runControl={overrides.runControl ?? null}
/>
)
}
const utils = render(<Wrapper />)
return {
...utils,
onInputsChange,
onSend,
onVisionFilesChange,
getInputsRef: () => inputsRefCapture,
}
}
describe('RunOnce', () => {
it('should initialize inputs using prompt defaults', async () => {
const { onInputsChange, onVisionFilesChange } = setup()
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalledWith({
textInput: 'default text',
paragraphInput: 'paragraph default',
numberInput: 42,
checkboxInput: false,
})
})
await waitFor(() => {
expect(onVisionFilesChange).toHaveBeenCalledWith([])
})
expect(screen.getByText('common.imageUploader.imageUpload')).toBeInTheDocument()
})
it('should update inputs when user edits fields', async () => {
const { onInputsChange, getInputsRef } = setup()
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalled()
})
onInputsChange.mockClear()
fireEvent.change(screen.getByPlaceholderText('Text Input'), {
target: { value: 'new text' },
})
fireEvent.change(screen.getByPlaceholderText('Paragraph Input'), {
target: { value: 'paragraph value' },
})
fireEvent.change(screen.getByPlaceholderText('Number Input'), {
target: { value: '99' },
})
const label = screen.getByText('Checkbox Input')
const checkbox = label.closest('div')?.parentElement?.querySelector('div')
expect(checkbox).toBeTruthy()
fireEvent.click(checkbox as HTMLElement)
const latest = onInputsChange.mock.calls[onInputsChange.mock.calls.length - 1][0]
expect(latest).toEqual({
textInput: 'new text',
paragraphInput: 'paragraph value',
numberInput: '99',
checkboxInput: true,
})
expect(getInputsRef()?.current).toEqual(latest)
})
it('should clear inputs when Clear button is pressed', async () => {
const { onInputsChange } = setup()
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalled()
})
onInputsChange.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(onInputsChange).toHaveBeenCalledWith({
textInput: '',
paragraphInput: '',
numberInput: '',
checkboxInput: false,
})
})
it('should submit form and call onSend when Run button clicked', async () => {
const { onSend, onInputsChange } = setup()
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalled()
})
fireEvent.click(screen.getByTestId('run-button'))
expect(onSend).toHaveBeenCalledTimes(1)
})
it('should display stop controls when runControl is provided', async () => {
const onStop = jest.fn()
const runControl = {
onStop,
isStopping: false,
}
const { onInputsChange } = setup({ runControl })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalled()
})
const stopButton = screen.getByTestId('stop-button')
fireEvent.click(stopButton)
expect(onStop).toHaveBeenCalledTimes(1)
})
it('should disable stop button while runControl is stopping', async () => {
const runControl = {
onStop: jest.fn(),
isStopping: true,
}
const { onInputsChange } = setup({ runControl })
await waitFor(() => {
expect(onInputsChange).toHaveBeenCalled()
})
const stopButton = screen.getByTestId('stop-button')
expect(stopButton).toBeDisabled()
})
})

View File

@@ -57,6 +57,8 @@ const RunOnce: FC<IRunOnceProps> = ({
promptConfig.prompt_variables.forEach((item) => {
if (item.type === 'string' || item.type === 'paragraph')
newInputs[item.key] = ''
else if (item.type === 'number')
newInputs[item.key] = ''
else if (item.type === 'checkbox')
newInputs[item.key] = false
else
@@ -92,7 +94,7 @@ const RunOnce: FC<IRunOnceProps> = ({
else if (item.type === 'string' || item.type === 'paragraph')
newInputs[item.key] = item.default || ''
else if (item.type === 'number')
newInputs[item.key] = item.default
newInputs[item.key] = item.default ?? ''
else if (item.type === 'checkbox')
newInputs[item.key] = item.default || false
else if (item.type === 'file')
@@ -230,6 +232,7 @@ const RunOnce: FC<IRunOnceProps> = ({
variant={isRunning ? 'secondary' : 'primary'}
disabled={isRunning && runControl?.isStopping}
onClick={handlePrimaryClick}
data-testid={isRunning ? 'stop-button' : 'run-button'}
>
{isRunning ? (
<>

View File

@@ -0,0 +1,368 @@
import React from 'react'
import { act, render, renderHook, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Marketplace from './index'
import { useMarketplace } from './hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
import type { Collection } from '@/app/components/tools/types'
import { CollectionType } from '@/app/components/tools/types'
import type { Plugin } from '@/app/components/plugins/types'
const listRenderSpy = jest.fn()
jest.mock('@/app/components/plugins/marketplace/list', () => ({
__esModule: true,
default: (props: {
marketplaceCollections: unknown[]
marketplaceCollectionPluginsMap: Record<string, unknown[]>
plugins?: unknown[]
showInstallButton?: boolean
locale: string
}) => {
listRenderSpy(props)
return <div data-testid="marketplace-list" />
},
}))
const mockUseMarketplaceCollectionsAndPlugins = jest.fn()
const mockUseMarketplacePlugins = jest.fn()
jest.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplaceCollectionsAndPlugins: (...args: unknown[]) => mockUseMarketplaceCollectionsAndPlugins(...args),
useMarketplacePlugins: (...args: unknown[]) => mockUseMarketplacePlugins(...args),
}))
const mockUseAllToolProviders = jest.fn()
jest.mock('@/service/use-tools', () => ({
useAllToolProviders: (...args: unknown[]) => mockUseAllToolProviders(...args),
}))
jest.mock('@/utils/var', () => ({
__esModule: true,
getMarketplaceUrl: jest.fn(() => 'https://marketplace.test/market'),
}))
jest.mock('@/i18n-config', () => ({
getLocaleOnClient: () => 'en',
}))
jest.mock('next-themes', () => ({
useTheme: () => ({ theme: 'light' }),
}))
const { getMarketplaceUrl: mockGetMarketplaceUrl } = jest.requireMock('@/utils/var') as {
getMarketplaceUrl: jest.Mock
}
const createToolProvider = (overrides: Partial<Collection> = {}): Collection => ({
id: 'provider-1',
name: 'Provider 1',
author: 'Author',
description: { en_US: 'desc', zh_Hans: '描述' },
icon: 'icon',
label: { en_US: 'label', zh_Hans: '标签' },
type: CollectionType.custom,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
...overrides,
})
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'org',
author: 'author',
name: 'Plugin One',
plugin_id: 'plugin-1',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'plugin-1@1.0.0',
icon: 'icon',
verified: true,
label: { en_US: 'Plugin One' },
brief: { en_US: 'Brief' },
description: { en_US: 'Plugin description' },
introduction: 'Intro',
repository: 'https://example.com',
category: PluginCategoryEnum.tool,
install_count: 0,
endpoint: { settings: [] },
tags: [{ name: 'tag' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const createMarketplaceContext = (overrides: Partial<ReturnType<typeof useMarketplace>> = {}) => ({
isLoading: false,
marketplaceCollections: [],
marketplaceCollectionPluginsMap: {},
plugins: [],
handleScroll: jest.fn(),
page: 1,
...overrides,
})
describe('Marketplace', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Rendering the marketplace panel based on loading and visibility state.
describe('Rendering', () => {
it('should show loading indicator when loading first page', () => {
// Arrange
const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 })
render(
<Marketplace
searchPluginText=""
filterPluginTags={[]}
isMarketplaceArrowVisible={false}
showMarketplacePanel={jest.fn()}
marketplaceContext={marketplaceContext}
/>,
)
// Assert
expect(document.querySelector('svg.spin-animation')).toBeInTheDocument()
expect(screen.queryByTestId('marketplace-list')).not.toBeInTheDocument()
})
it('should render list when not loading', () => {
// Arrange
const marketplaceContext = createMarketplaceContext({
isLoading: false,
plugins: [createPlugin()],
})
render(
<Marketplace
searchPluginText=""
filterPluginTags={[]}
isMarketplaceArrowVisible={false}
showMarketplacePanel={jest.fn()}
marketplaceContext={marketplaceContext}
/>,
)
// Assert
expect(screen.getByTestId('marketplace-list')).toBeInTheDocument()
expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({
showInstallButton: true,
locale: 'en',
}))
})
})
// Prop-driven UI output such as links and action triggers.
describe('Props', () => {
it('should build marketplace link and trigger panel when arrow is clicked', async () => {
const user = userEvent.setup()
// Arrange
const marketplaceContext = createMarketplaceContext()
const showMarketplacePanel = jest.fn()
const { container } = render(
<Marketplace
searchPluginText="vector"
filterPluginTags={['tag-a', 'tag-b']}
isMarketplaceArrowVisible
showMarketplacePanel={showMarketplacePanel}
marketplaceContext={marketplaceContext}
/>,
)
// Act
const arrowIcon = container.querySelector('svg.cursor-pointer')
expect(arrowIcon).toBeTruthy()
await user.click(arrowIcon as SVGElement)
// Assert
expect(showMarketplacePanel).toHaveBeenCalledTimes(1)
expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('', {
language: 'en',
q: 'vector',
tags: 'tag-a,tag-b',
theme: 'light',
})
const marketplaceLink = screen.getByRole('link', { name: /plugin.marketplace.difyMarketplace/i })
expect(marketplaceLink).toHaveAttribute('href', 'https://marketplace.test/market')
})
})
})
describe('useMarketplace', () => {
const mockQueryMarketplaceCollectionsAndPlugins = jest.fn()
const mockQueryPlugins = jest.fn()
const mockQueryPluginsWithDebounced = jest.fn()
const mockResetPlugins = jest.fn()
const mockFetchNextPage = jest.fn()
const setupHookMocks = (overrides?: {
isLoading?: boolean
isPluginsLoading?: boolean
pluginsPage?: number
hasNextPage?: boolean
plugins?: Plugin[] | undefined
}) => {
mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({
isLoading: overrides?.isLoading ?? false,
marketplaceCollections: [],
marketplaceCollectionPluginsMap: {},
queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins,
})
mockUseMarketplacePlugins.mockReturnValue({
plugins: overrides?.plugins,
resetPlugins: mockResetPlugins,
queryPlugins: mockQueryPlugins,
queryPluginsWithDebounced: mockQueryPluginsWithDebounced,
isLoading: overrides?.isPluginsLoading ?? false,
fetchNextPage: mockFetchNextPage,
hasNextPage: overrides?.hasNextPage ?? false,
page: overrides?.pluginsPage,
})
}
beforeEach(() => {
jest.clearAllMocks()
mockUseAllToolProviders.mockReturnValue({
data: [],
isSuccess: true,
})
setupHookMocks()
})
// Query behavior driven by search filters and provider exclusions.
describe('Queries', () => {
it('should query plugins with debounce when search text is provided', async () => {
// Arrange
mockUseAllToolProviders.mockReturnValue({
data: [
createToolProvider({ plugin_id: 'plugin-a' }),
createToolProvider({ plugin_id: undefined }),
],
isSuccess: true,
})
// Act
renderHook(() => useMarketplace('alpha', []))
// Assert
await waitFor(() => {
expect(mockQueryPluginsWithDebounced).toHaveBeenCalledWith({
category: PluginCategoryEnum.tool,
query: 'alpha',
tags: [],
exclude: ['plugin-a'],
type: 'plugin',
})
})
expect(mockQueryMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled()
expect(mockResetPlugins).not.toHaveBeenCalled()
})
it('should query plugins immediately when only tags are provided', async () => {
// Arrange
mockUseAllToolProviders.mockReturnValue({
data: [createToolProvider({ plugin_id: 'plugin-b' })],
isSuccess: true,
})
// Act
renderHook(() => useMarketplace('', ['tag-1']))
// Assert
await waitFor(() => {
expect(mockQueryPlugins).toHaveBeenCalledWith({
category: PluginCategoryEnum.tool,
query: '',
tags: ['tag-1'],
exclude: ['plugin-b'],
type: 'plugin',
})
})
})
it('should query collections and reset plugins when no filters are provided', async () => {
// Arrange
mockUseAllToolProviders.mockReturnValue({
data: [createToolProvider({ plugin_id: 'plugin-c' })],
isSuccess: true,
})
// Act
renderHook(() => useMarketplace('', []))
// Assert
await waitFor(() => {
expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({
category: PluginCategoryEnum.tool,
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
exclude: ['plugin-c'],
type: 'plugin',
})
})
expect(mockResetPlugins).toHaveBeenCalledTimes(1)
})
})
// State derived from hook inputs and loading signals.
describe('State', () => {
it('should expose combined loading state and fallback page value', () => {
// Arrange
setupHookMocks({ isLoading: true, isPluginsLoading: false, pluginsPage: undefined })
// Act
const { result } = renderHook(() => useMarketplace('', []))
// Assert
expect(result.current.isLoading).toBe(true)
expect(result.current.page).toBe(1)
})
})
// Scroll handling that triggers pagination when appropriate.
describe('Scroll', () => {
it('should fetch next page when scrolling near bottom with filters', () => {
// Arrange
setupHookMocks({ hasNextPage: true })
const { result } = renderHook(() => useMarketplace('search', []))
const event = {
target: {
scrollTop: 100,
scrollHeight: 200,
clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD,
},
} as unknown as Event
// Act
act(() => {
result.current.handleScroll(event)
})
// Assert
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
})
it('should not fetch next page when no filters are applied', () => {
// Arrange
setupHookMocks({ hasNextPage: true })
const { result } = renderHook(() => useMarketplace('', []))
const event = {
target: {
scrollTop: 100,
scrollHeight: 200,
clientHeight: 100 + SCROLL_BOTTOM_THRESHOLD,
},
} as unknown as Event
// Act
act(() => {
result.current.handleScroll(event)
})
// Assert
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
})
})