mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 17:27:16 -05:00
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:
379
web/app/components/app-sidebar/dataset-info/index.spec.tsx
Normal file
379
web/app/components/app-sidebar/dataset-info/index.spec.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
167
web/app/components/app/duplicate-modal/index.spec.tsx
Normal file
167
web/app/components/app/duplicate-modal/index.spec.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
295
web/app/components/app/switch-app-modal/index.spec.tsx
Normal file
295
web/app/components/app/switch-app-modal/index.spec.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
241
web/app/components/share/text-generation/run-once/index.spec.tsx
Normal file
241
web/app/components/share/text-generation/run-once/index.spec.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -57,6 +57,8 @@ const RunOnce: FC<IRunOnceProps> = ({
|
|||||||
promptConfig.prompt_variables.forEach((item) => {
|
promptConfig.prompt_variables.forEach((item) => {
|
||||||
if (item.type === 'string' || item.type === 'paragraph')
|
if (item.type === 'string' || item.type === 'paragraph')
|
||||||
newInputs[item.key] = ''
|
newInputs[item.key] = ''
|
||||||
|
else if (item.type === 'number')
|
||||||
|
newInputs[item.key] = ''
|
||||||
else if (item.type === 'checkbox')
|
else if (item.type === 'checkbox')
|
||||||
newInputs[item.key] = false
|
newInputs[item.key] = false
|
||||||
else
|
else
|
||||||
@@ -92,7 +94,7 @@ const RunOnce: FC<IRunOnceProps> = ({
|
|||||||
else if (item.type === 'string' || item.type === 'paragraph')
|
else if (item.type === 'string' || item.type === 'paragraph')
|
||||||
newInputs[item.key] = item.default || ''
|
newInputs[item.key] = item.default || ''
|
||||||
else if (item.type === 'number')
|
else if (item.type === 'number')
|
||||||
newInputs[item.key] = item.default
|
newInputs[item.key] = item.default ?? ''
|
||||||
else if (item.type === 'checkbox')
|
else if (item.type === 'checkbox')
|
||||||
newInputs[item.key] = item.default || false
|
newInputs[item.key] = item.default || false
|
||||||
else if (item.type === 'file')
|
else if (item.type === 'file')
|
||||||
@@ -230,6 +232,7 @@ const RunOnce: FC<IRunOnceProps> = ({
|
|||||||
variant={isRunning ? 'secondary' : 'primary'}
|
variant={isRunning ? 'secondary' : 'primary'}
|
||||||
disabled={isRunning && runControl?.isStopping}
|
disabled={isRunning && runControl?.isStopping}
|
||||||
onClick={handlePrimaryClick}
|
onClick={handlePrimaryClick}
|
||||||
|
data-testid={isRunning ? 'stop-button' : 'run-button'}
|
||||||
>
|
>
|
||||||
{isRunning ? (
|
{isRunning ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
368
web/app/components/tools/marketplace/index.spec.tsx
Normal file
368
web/app/components/tools/marketplace/index.spec.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user