chore: add some jest tests (#29800)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2025-12-18 10:00:11 +08:00 committed by GitHub
parent c474177a16
commit 9812dc2cb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 2364 additions and 46 deletions

View File

@ -2,12 +2,6 @@ import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import EditItem, { EditItemType } from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('AddAnnotationModal/EditItem', () => {
test('should render query inputs with user avatar and placeholder strings', () => {
render(

View File

@ -0,0 +1,388 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AccessControl from './index'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
import SpecificGroupsOrMembers from './specific-groups-or-members'
import useAccessControlStore from '@/context/access-control-store'
import { useGlobalPublicStore } from '@/context/global-public-context'
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import { AccessMode, SubjectType } from '@/models/access-control'
import Toast from '../../base/toast'
import { defaultSystemFeatures } from '@/types/feature'
import type { App } from '@/types/app'
const mockUseAppWhiteListSubjects = jest.fn()
const mockUseSearchForWhiteListCandidates = jest.fn()
const mockMutateAsync = jest.fn()
const mockUseUpdateAccessMode = jest.fn(() => ({
isPending: false,
mutateAsync: mockMutateAsync,
}))
jest.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (value: { userProfile: { email: string; id?: string; name?: string; avatar?: string; avatar_url?: string; is_password_set?: boolean } }) => T) => selector({
userProfile: {
id: 'current-user',
name: 'Current User',
email: 'member@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
}),
}))
jest.mock('@/service/common', () => ({
fetchCurrentWorkspace: jest.fn(),
fetchLangGeniusVersion: jest.fn(),
fetchUserProfile: jest.fn(),
getSystemFeatures: jest.fn(),
}))
jest.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
}))
jest.mock('@headlessui/react', () => {
const DialogComponent: any = ({ children, className, ...rest }: any) => (
<div role="dialog" className={className} {...rest}>{children}</div>
)
DialogComponent.Panel = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const DialogTitle = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const DialogDescription = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const TransitionChild = ({ children }: any) => (
<>{typeof children === 'function' ? children({}) : children}</>
)
const Transition = ({ show = true, children }: any) => (
show ? <>{typeof children === 'function' ? children({}) : children}</> : null
)
Transition.Child = TransitionChild
return {
Dialog: DialogComponent,
Transition,
DialogTitle,
Description: DialogDescription,
}
})
jest.mock('ahooks', () => {
const actual = jest.requireActual('ahooks')
return {
...actual,
useDebounce: (value: unknown) => value,
}
})
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
id: 'group-1',
name: 'Group One',
groupSize: 5,
...overrides,
} as AccessControlGroup)
const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => ({
id: 'member-1',
name: 'Member One',
email: 'member@example.com',
avatar: '',
avatarUrl: '',
...overrides,
} as AccessControlAccount)
const baseGroup = createGroup()
const baseMember = createMember()
const groupSubject: Subject = {
subjectId: baseGroup.id,
subjectType: SubjectType.GROUP,
groupData: baseGroup,
} as Subject
const memberSubject: Subject = {
subjectId: baseMember.id,
subjectType: SubjectType.ACCOUNT,
accountData: baseMember,
} as Subject
const resetAccessControlStore = () => {
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
}
const resetGlobalStore = () => {
useGlobalPublicStore.setState({
systemFeatures: defaultSystemFeatures,
isGlobalPending: false,
})
}
beforeAll(() => {
class MockIntersectionObserver {
observe = jest.fn(() => undefined)
disconnect = jest.fn(() => undefined)
unobserve = jest.fn(() => undefined)
}
// @ts-expect-error jsdom does not implement IntersectionObserver
globalThis.IntersectionObserver = MockIntersectionObserver
})
beforeEach(() => {
jest.clearAllMocks()
resetAccessControlStore()
resetGlobalStore()
mockMutateAsync.mockResolvedValue(undefined)
mockUseUpdateAccessMode.mockReturnValue({
isPending: false,
mutateAsync: mockMutateAsync,
})
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
groups: [baseGroup],
members: [baseMember],
},
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: jest.fn(),
data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }] },
})
})
// AccessControlItem handles selected vs. unselected styling and click state updates
describe('AccessControlItem', () => {
it('should update current menu when selecting a different access type', () => {
useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC })
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
expect(option).toHaveClass('cursor-pointer')
fireEvent.click(option)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
})
it('should render selected styles when the current menu matches the type', () => {
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
expect(option.className).toContain('border-[1.5px]')
expect(option.className).not.toContain('cursor-pointer')
})
})
// AccessControlDialog renders a headless UI dialog with a manual close control
describe('AccessControlDialog', () => {
it('should render dialog content when visible', () => {
render(
<AccessControlDialog show className="custom-dialog">
<div>Dialog Content</div>
</AccessControlDialog>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Dialog Content')).toBeInTheDocument()
})
it('should trigger onClose when clicking the close control', async () => {
const handleClose = jest.fn()
const { container } = render(
<AccessControlDialog show onClose={handleClose}>
<div>Dialog Content</div>
</AccessControlDialog>,
)
const closeButton = container.querySelector('.absolute.right-5.top-5') as HTMLElement
fireEvent.click(closeButton)
await waitFor(() => {
expect(handleClose).toHaveBeenCalledTimes(1)
})
})
})
// SpecificGroupsOrMembers syncs store state with fetched data and supports removals
describe('SpecificGroupsOrMembers', () => {
it('should render collapsed view when not in specific selection mode', () => {
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
render(<SpecificGroupsOrMembers />)
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
})
it('should show loading state while pending', async () => {
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
const { container } = render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
})
it('should render fetched groups and members and support removal', async () => {
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
const groupItem = screen.getByText(baseGroup.name).closest('div')
const groupRemove = groupItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
fireEvent.click(groupRemove)
await waitFor(() => {
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
})
const memberItem = screen.getByText(baseMember.name).closest('div')
const memberRemove = memberItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
fireEvent.click(memberRemove)
await waitFor(() => {
expect(screen.queryByText(baseMember.name)).not.toBeInTheDocument()
})
})
})
// AddMemberOrGroupDialog renders search results and updates store selections
describe('AddMemberOrGroupDialog', () => {
it('should open search popover and display candidates', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument()
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
it('should allow selecting members and expanding groups', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')
await user.click(expandButton)
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
const memberLabel = screen.getByText(baseMember.name)
const memberCheckbox = memberLabel.parentElement?.previousElementSibling as HTMLElement
fireEvent.click(memberCheckbox)
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
})
it('should show empty state when no candidates are returned', async () => {
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: jest.fn(),
data: { pages: [] },
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument()
})
})
// AccessControl integrates dialog, selection items, and confirm flow
describe('AccessControl', () => {
it('should initialize menu from app and call update on confirm', async () => {
const onClose = jest.fn()
const onConfirm = jest.fn()
const toastSpy = jest.spyOn(Toast, 'notify').mockReturnValue({})
useAccessControlStore.setState({
specificGroups: [baseGroup],
specificMembers: [baseMember],
})
const app = {
id: 'app-id-1',
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
} as App
render(
<AccessControl
app={app}
onClose={onClose}
onConfirm={onConfirm}
/>,
)
await waitFor(() => {
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS)
})
fireEvent.click(screen.getByText('common.operation.confirm'))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
appId: app.id,
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
subjects: [
{ subjectId: baseGroup.id, subjectType: SubjectType.GROUP },
{ subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT },
],
})
expect(toastSpy).toHaveBeenCalled()
expect(onConfirm).toHaveBeenCalled()
})
})
it('should expose the external members tip when SSO is disabled', () => {
const app = {
id: 'app-id-2',
access_mode: AccessMode.PUBLIC,
} as App
render(
<AccessControl
app={app}
onClose={jest.fn()}
/>,
)
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
})
})

View File

@ -32,7 +32,7 @@ export default function AddMemberOrGroupDialog() {
const anchorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const hasMore = data?.pages?.[0].hasMore ?? false
const hasMore = data?.pages?.[0]?.hasMore ?? false
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {

View File

@ -4,12 +4,6 @@ import AgentSetting from './index'
import { MAX_ITERATIONS_NUM } from '@/config'
import type { AgentConfig } from '@/models/debug'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('ahooks', () => {
const actual = jest.requireActual('ahooks')
return {

View File

@ -0,0 +1,392 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ConfigContent from './config-content'
import type { DataSet } from '@/models/datasets'
import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
import type { DatasetConfigs } from '@/models/debug'
import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app'
import type { RetrievalConfig } from '@/types/app'
import Toast from '@/app/components/base/toast'
import type { IndexingType } from '@/app/components/datasets/create/step-two'
import {
useCurrentProviderAndModel,
useModelListAndDefaultModelAndCurrentProviderAndModel,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => {
type Props = {
defaultModel?: { provider: string; model: string }
onSelect?: (model: { provider: string; model: string }) => void
}
const MockModelSelector = ({ defaultModel, onSelect }: Props) => (
<button
type="button"
onClick={() => onSelect?.(defaultModel ?? { provider: 'mock-provider', model: 'mock-model' })}
>
Mock ModelSelector
</button>
)
return {
__esModule: true,
default: MockModelSelector,
}
})
jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: () => <div data-testid="model-parameter-modal" />,
}))
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(),
},
}))
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(),
useCurrentProviderAndModel: jest.fn(),
}))
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction<typeof useCurrentProviderAndModel>
const mockToastNotify = Toast.notify as unknown as jest.Mock
const baseRetrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: 'provider',
reranking_model_name: 'rerank-model',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: 0,
}
const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => {
const {
retrieval_model,
retrieval_model_dict,
icon_info,
...restOverrides
} = overrides
const resolvedRetrievalModelDict = {
...baseRetrievalConfig,
...retrieval_model_dict,
}
const resolvedRetrievalModel = {
...baseRetrievalConfig,
...(retrieval_model ?? retrieval_model_dict),
}
const defaultIconInfo = {
icon: '📘',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
}
const resolvedIconInfo = ('icon_info' in overrides)
? icon_info
: defaultIconInfo
return {
id: 'dataset-id',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: resolvedIconInfo as DataSet['icon_info'],
description: 'A test dataset',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: defaultIndexingTechnique,
author_name: 'author',
created_by: 'creator',
updated_by: 'updater',
updated_at: 0,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 0,
total_document_count: 0,
total_available_documents: 0,
word_count: 0,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: resolvedRetrievalModelDict,
retrieval_model: resolvedRetrievalModel,
tags: [],
external_knowledge_info: {
external_knowledge_id: 'external-id',
external_knowledge_api_id: 'api-id',
external_knowledge_api_name: 'api-name',
external_knowledge_api_endpoint: 'https://endpoint',
},
external_retrieval_model: {
top_k: 2,
score_threshold: 0.5,
score_threshold_enabled: true,
},
built_in_field_enabled: true,
doc_metadata: [],
keyword_number: 3,
pipeline_id: 'pipeline-id',
is_published: true,
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
...restOverrides,
}
}
const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetConfigs => {
return {
retrieval_model: RETRIEVE_TYPE.multiWay,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: 0,
datasets: {
datasets: [],
},
reranking_mode: RerankingModeEnum.WeightedScore,
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.5,
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding',
},
keyword_setting: {
keyword_weight: 0.5,
},
},
reranking_enable: false,
...overrides,
}
}
describe('ConfigContent', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
modelList: [],
defaultModel: undefined,
currentProvider: undefined,
currentModel: undefined,
})
mockedUseCurrentProviderAndModel.mockReturnValue({
currentProvider: undefined,
currentModel: undefined,
})
})
// State management
describe('Effects', () => {
it('should normalize oneWay retrieval mode to multiWay', async () => {
// Arrange
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
const datasetConfigs = createDatasetConfigs({ retrieval_model: RETRIEVE_TYPE.oneWay })
// Act
render(<ConfigContent datasetConfigs={datasetConfigs} onChange={onChange} />)
// Assert
await waitFor(() => {
expect(onChange).toHaveBeenCalled()
})
const [nextConfigs] = onChange.mock.calls[0]
expect(nextConfigs.retrieval_model).toBe(RETRIEVE_TYPE.multiWay)
})
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render weighted score panel when datasets are high-quality and consistent', () => {
// Arrange
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
const datasetConfigs = createDatasetConfigs({
reranking_mode: RerankingModeEnum.WeightedScore,
})
const selectedDatasets: DataSet[] = [
createDataset({
indexing_technique: 'high_quality' as IndexingType,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
retrieval_model_dict: {
...baseRetrievalConfig,
search_method: RETRIEVE_METHOD.semantic,
},
}),
]
// Act
render(
<ConfigContent
datasetConfigs={datasetConfigs}
onChange={onChange}
selectedDatasets={selectedDatasets}
/>,
)
// Assert
expect(screen.getByText('dataset.weightedScore.title')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument()
expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument()
})
})
// User interactions
describe('User Interactions', () => {
it('should update weights when user changes weighted score slider', async () => {
// Arrange
const user = userEvent.setup()
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
const datasetConfigs = createDatasetConfigs({
reranking_mode: RerankingModeEnum.WeightedScore,
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.5,
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding',
},
keyword_setting: {
keyword_weight: 0.5,
},
},
})
const selectedDatasets: DataSet[] = [
createDataset({
indexing_technique: 'high_quality' as IndexingType,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
retrieval_model_dict: {
...baseRetrievalConfig,
search_method: RETRIEVE_METHOD.semantic,
},
}),
]
// Act
render(
<ConfigContent
datasetConfigs={datasetConfigs}
onChange={onChange}
selectedDatasets={selectedDatasets}
/>,
)
const weightedScoreSlider = screen.getAllByRole('slider')
.find(slider => slider.getAttribute('aria-valuemax') === '1')
expect(weightedScoreSlider).toBeDefined()
await user.click(weightedScoreSlider!)
const callsBefore = onChange.mock.calls.length
await user.keyboard('{ArrowRight}')
// Assert
expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore)
const [nextConfigs] = onChange.mock.calls.at(-1) ?? []
expect(nextConfigs?.weights?.vector_setting.vector_weight).toBeCloseTo(0.6, 5)
expect(nextConfigs?.weights?.keyword_setting.keyword_weight).toBeCloseTo(0.4, 5)
})
it('should warn when switching to rerank model mode without a valid model', async () => {
// Arrange
const user = userEvent.setup()
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
const datasetConfigs = createDatasetConfigs({
reranking_mode: RerankingModeEnum.WeightedScore,
})
const selectedDatasets: DataSet[] = [
createDataset({
indexing_technique: 'high_quality' as IndexingType,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
retrieval_model_dict: {
...baseRetrievalConfig,
search_method: RETRIEVE_METHOD.semantic,
},
}),
]
// Act
render(
<ConfigContent
datasetConfigs={datasetConfigs}
onChange={onChange}
selectedDatasets={selectedDatasets}
/>,
)
await user.click(screen.getByText('common.modelProvider.rerankModel.key'))
// Assert
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.errorMsg.rerankModelRequired',
})
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_mode: RerankingModeEnum.RerankingModel,
}),
)
})
it('should warn when enabling rerank without a valid model in manual toggle mode', async () => {
// Arrange
const user = userEvent.setup()
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
const datasetConfigs = createDatasetConfigs({
reranking_enable: false,
})
const selectedDatasets: DataSet[] = [
createDataset({
indexing_technique: 'economy' as IndexingType,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
retrieval_model_dict: {
...baseRetrievalConfig,
search_method: RETRIEVE_METHOD.semantic,
},
}),
]
// Act
render(
<ConfigContent
datasetConfigs={datasetConfigs}
onChange={onChange}
selectedDatasets={selectedDatasets}
/>,
)
await user.click(screen.getByRole('switch'))
// Assert
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.errorMsg.rerankModelRequired',
})
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_enable: true,
}),
)
})
})
})

View File

@ -0,0 +1,242 @@
import * as React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ParamsConfig from './index'
import ConfigContext from '@/context/debug-configuration'
import type { DatasetConfigs } from '@/models/debug'
import { RerankingModeEnum } from '@/models/datasets'
import { RETRIEVE_TYPE } from '@/types/app'
import Toast from '@/app/components/base/toast'
import {
useCurrentProviderAndModel,
useModelListAndDefaultModelAndCurrentProviderAndModel,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
jest.mock('@/app/components/base/modal', () => {
type Props = {
isShow: boolean
children?: React.ReactNode
}
const MockModal = ({ isShow, children }: Props) => {
if (!isShow) return null
return <div role="dialog">{children}</div>
}
return {
__esModule: true,
default: MockModal,
}
})
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(),
},
}))
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(),
useCurrentProviderAndModel: jest.fn(),
}))
jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => {
type Props = {
defaultModel?: { provider: string; model: string }
onSelect?: (model: { provider: string; model: string }) => void
}
const MockModelSelector = ({ defaultModel, onSelect }: Props) => (
<button
type="button"
onClick={() => onSelect?.(defaultModel ?? { provider: 'mock-provider', model: 'mock-model' })}
>
Mock ModelSelector
</button>
)
return {
__esModule: true,
default: MockModelSelector,
}
})
jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: () => <div data-testid="model-parameter-modal" />,
}))
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction<typeof useCurrentProviderAndModel>
const mockToastNotify = Toast.notify as unknown as jest.Mock
const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetConfigs => {
return {
retrieval_model: RETRIEVE_TYPE.multiWay,
reranking_model: {
reranking_provider_name: 'provider',
reranking_model_name: 'rerank-model',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: 0,
datasets: {
datasets: [],
},
reranking_enable: false,
reranking_mode: RerankingModeEnum.RerankingModel,
...overrides,
}
}
const renderParamsConfig = ({
datasetConfigs = createDatasetConfigs(),
initialModalOpen = false,
disabled,
}: {
datasetConfigs?: DatasetConfigs
initialModalOpen?: boolean
disabled?: boolean
} = {}) => {
const setDatasetConfigsSpy = jest.fn<void, [DatasetConfigs]>()
const setModalOpenSpy = jest.fn<void, [boolean]>()
const Wrapper = ({ children }: { children: React.ReactNode }) => {
const [datasetConfigsState, setDatasetConfigsState] = React.useState(datasetConfigs)
const [modalOpen, setModalOpen] = React.useState(initialModalOpen)
const contextValue = {
datasetConfigs: datasetConfigsState,
setDatasetConfigs: (next: DatasetConfigs) => {
setDatasetConfigsSpy(next)
setDatasetConfigsState(next)
},
rerankSettingModalOpen: modalOpen,
setRerankSettingModalOpen: (open: boolean) => {
setModalOpenSpy(open)
setModalOpen(open)
},
} as unknown as React.ComponentProps<typeof ConfigContext.Provider>['value']
return (
<ConfigContext.Provider value={contextValue}>
{children}
</ConfigContext.Provider>
)
}
render(
<ParamsConfig
disabled={disabled}
selectedDatasets={[]}
/>,
{ wrapper: Wrapper },
)
return {
setDatasetConfigsSpy,
setModalOpenSpy,
}
}
describe('dataset-config/params-config', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
modelList: [],
defaultModel: undefined,
currentProvider: undefined,
currentModel: undefined,
})
mockedUseCurrentProviderAndModel.mockReturnValue({
currentProvider: undefined,
currentModel: undefined,
})
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should disable settings trigger when disabled is true', () => {
// Arrange
renderParamsConfig({ disabled: true })
// Assert
expect(screen.getByRole('button', { name: 'dataset.retrievalSettings' })).toBeDisabled()
})
})
// User Interactions
describe('User Interactions', () => {
it('should open modal and persist changes when save is clicked', async () => {
// Arrange
const user = userEvent.setup()
const { setDatasetConfigsSpy } = renderParamsConfig()
// Act
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
await screen.findByRole('dialog')
// Change top_k via the first number input increment control.
const incrementButtons = screen.getAllByRole('button', { name: 'increment' })
await user.click(incrementButtons[0])
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 5 }))
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
it('should discard changes when cancel is clicked', async () => {
// Arrange
const user = userEvent.setup()
const { setDatasetConfigsSpy } = renderParamsConfig()
// Act
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
await screen.findByRole('dialog')
const incrementButtons = screen.getAllByRole('button', { name: 'increment' })
await user.click(incrementButtons[0])
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
// Re-open and save without changes.
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
await screen.findByRole('dialog')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert - should save original top_k rather than the canceled change.
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 }))
})
it('should prevent saving when rerank model is required but invalid', async () => {
// Arrange
const user = userEvent.setup()
const { setDatasetConfigsSpy } = renderParamsConfig({
datasetConfigs: createDatasetConfigs({
reranking_enable: true,
reranking_mode: RerankingModeEnum.RerankingModel,
}),
initialModalOpen: true,
})
// Act
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'appDebug.datasetConfig.rerankModelRequired',
})
expect(setDatasetConfigsSpy).not.toHaveBeenCalled()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import WeightedScore from './weighted-score'
describe('WeightedScore', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render semantic and keyword weights', () => {
// Arrange
const onChange = jest.fn<void, [{ value: number[] }]>()
const value = { value: [0.3, 0.7] }
// Act
render(<WeightedScore value={value} onChange={onChange} />)
// Assert
expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument()
expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument()
expect(screen.getByText('0.3')).toBeInTheDocument()
expect(screen.getByText('0.7')).toBeInTheDocument()
})
it('should format a weight of 1 as 1.0', () => {
// Arrange
const onChange = jest.fn<void, [{ value: number[] }]>()
const value = { value: [1, 0] }
// Act
render(<WeightedScore value={value} onChange={onChange} />)
// Assert
expect(screen.getByText('1.0')).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should emit complementary weights when the slider value changes', async () => {
// Arrange
const onChange = jest.fn<void, [{ value: number[] }]>()
const value = { value: [0.5, 0.5] }
const user = userEvent.setup()
render(<WeightedScore value={value} onChange={onChange} />)
// Act
await user.tab()
const slider = screen.getByRole('slider')
expect(slider).toHaveFocus()
const callsBefore = onChange.mock.calls.length
await user.keyboard('{ArrowRight}')
// Assert
expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore)
const lastCall = onChange.mock.calls.at(-1)?.[0]
expect(lastCall?.value[0]).toBeCloseTo(0.6, 5)
expect(lastCall?.value[1]).toBeCloseTo(0.4, 5)
})
it('should not call onChange when readonly is true', async () => {
// Arrange
const onChange = jest.fn<void, [{ value: number[] }]>()
const value = { value: [0.5, 0.5] }
const user = userEvent.setup()
render(<WeightedScore value={value} onChange={onChange} readonly />)
// Act
await user.tab()
const slider = screen.getByRole('slider')
expect(slider).toHaveFocus()
await user.keyboard('{ArrowRight}')
// Assert
expect(onChange).not.toHaveBeenCalled()
})
})
})

View File

@ -26,7 +26,7 @@ jest.mock('./app-list', () => {
})
jest.mock('ahooks', () => ({
useKeyPress: jest.fn((key: string, callback: () => void) => {
useKeyPress: jest.fn((_key: string, _callback: () => void) => {
// Mock implementation for testing
return jest.fn()
}),
@ -67,7 +67,7 @@ describe('CreateAppTemplateDialog', () => {
})
it('should not render create from blank button when onCreateFromBlank is not provided', () => {
const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />)
@ -259,7 +259,7 @@ describe('CreateAppTemplateDialog', () => {
})
it('should handle missing optional onCreateFromBlank prop', () => {
const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
expect(() => {
render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />)

View File

@ -1,11 +1,9 @@
import { render, screen } from '@testing-library/react'
import AnnotationFull from './index'
let mockUsageProps: { className?: string } | null = null
jest.mock('./usage', () => ({
__esModule: true,
default: (props: { className?: string }) => {
mockUsageProps = props
return (
<div data-testid='usage-component' data-classname={props.className ?? ''}>
usage
@ -14,11 +12,9 @@ jest.mock('./usage', () => ({
},
}))
let mockUpgradeBtnProps: { loc?: string } | null = null
jest.mock('../upgrade-btn', () => ({
__esModule: true,
default: (props: { loc?: string }) => {
mockUpgradeBtnProps = props
return (
<button type='button' data-testid='upgrade-btn'>
{props.loc}
@ -30,8 +26,6 @@ jest.mock('../upgrade-btn', () => ({
describe('AnnotationFull', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUsageProps = null
mockUpgradeBtnProps = null
})
// Rendering marketing copy with action button

View File

@ -1,11 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AnnotationFullModal from './modal'
let mockUsageProps: { className?: string } | null = null
jest.mock('./usage', () => ({
__esModule: true,
default: (props: { className?: string }) => {
mockUsageProps = props
return (
<div data-testid='usage-component' data-classname={props.className ?? ''}>
usage
@ -59,7 +57,6 @@ jest.mock('../../base/modal', () => ({
describe('AnnotationFullModal', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUsageProps = null
mockUpgradeBtnProps = null
mockModalProps = null
})

View File

@ -4,13 +4,6 @@ import PipelineSettings from './index'
import { DatasourceType } from '@/models/pipeline'
import type { PipelineExecutionLogResponse } from '@/models/pipeline'
// Mock i18n
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock Next.js router
const mockPush = jest.fn()
const mockBack = jest.fn()

View File

@ -4,13 +4,6 @@ import ProcessDocuments from './index'
import { PipelineInputVarType } from '@/models/pipeline'
import type { RAGPipelineVariable } from '@/models/pipeline'
// Mock i18n
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock dataset detail context - required for useInputVariables hook
const mockPipelineId = 'pipeline-123'
jest.mock('@/context/dataset-detail', () => ({

View File

@ -3,13 +3,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import StatusItem from './index'
import type { DocumentDisplayStatus } from '@/models/datasets'
// Mock i18n - required for translation
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock ToastContext - required to verify notifications
const mockNotify = jest.fn()
jest.mock('use-context-selector', () => ({

View File

@ -0,0 +1,578 @@
import React from 'react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import type { UsagePlanInfo } from '@/app/components/billing/type'
import { Plan } from '@/app/components/billing/type'
import { createMockPlan, createMockPlanTotal, createMockPlanUsage } from '@/__mocks__/provider-context'
import { AppModeEnum } from '@/types/app'
import CreateAppModal from './index'
import type { CreateAppModalProps } from './index'
let mockTranslationOverrides: Record<string, string | undefined> = {}
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const override = mockTranslationOverrides[key]
if (override !== undefined)
return override
if (options?.returnObjects)
return [`${key}-feature-1`, `${key}-feature-2`]
if (options)
return `${key}:${JSON.stringify(options)}`
return key
},
i18n: {
language: 'en',
changeLanguage: jest.fn(),
},
}),
Trans: ({ children }: { children?: React.ReactNode }) => children,
initReactI18next: {
type: '3rdParty',
init: jest.fn(),
},
}))
// ky is an ESM-only package; mock it to keep Jest (CJS) specs running.
jest.mock('ky', () => ({
__esModule: true,
default: {
create: () => ({
extend: () => async () => new Response(),
}),
},
}))
// Avoid heavy emoji dataset initialization during unit tests.
jest.mock('emoji-mart', () => ({
init: jest.fn(),
SearchIndex: { search: jest.fn().mockResolvedValue([]) },
}))
jest.mock('@emoji-mart/data', () => ({
__esModule: true,
default: {
categories: [
{ id: 'people', emojis: ['😀'] },
],
},
}))
jest.mock('next/navigation', () => ({
useParams: () => ({}),
}))
jest.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { email: 'test@example.com' },
langGeniusVersionInfo: { current_version: '0.0.0' },
}),
}))
const createPlanInfo = (buildApps: number): UsagePlanInfo => ({
vectorSpace: 0,
buildApps,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
})
let mockEnableBilling = false
let mockPlanType: Plan = Plan.team
let mockUsagePlanInfo: UsagePlanInfo = createPlanInfo(1)
let mockTotalPlanInfo: UsagePlanInfo = createPlanInfo(10)
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => {
const withPlan = createMockPlan(mockPlanType)
const withUsage = createMockPlanUsage(mockUsagePlanInfo, withPlan)
const withTotal = createMockPlanTotal(mockTotalPlanInfo, withUsage)
return { ...withTotal, enableBilling: mockEnableBilling }
},
}))
type ConfirmPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
const setup = (overrides: Partial<CreateAppModalProps> = {}) => {
const onConfirm = jest.fn<Promise<void>, [ConfirmPayload]>().mockResolvedValue(undefined)
const onHide = jest.fn<void, []>()
const props: CreateAppModalProps = {
show: true,
isEditModal: false,
appName: 'Test App',
appDescription: 'Test description',
appIconType: 'emoji',
appIcon: '🤖',
appIconBackground: '#FFEAD5',
appIconUrl: null,
appMode: AppModeEnum.CHAT,
appUseIconAsAnswerIcon: false,
max_active_requests: null,
onConfirm,
confirmDisabled: false,
onHide,
...overrides,
}
render(<CreateAppModal {...props} />)
return { onConfirm, onHide }
}
const getAppIconTrigger = (): HTMLElement => {
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
const iconRow = nameInput.parentElement?.parentElement
const iconTrigger = iconRow?.firstElementChild
if (!(iconTrigger instanceof HTMLElement))
throw new Error('Failed to locate app icon trigger')
return iconTrigger
}
describe('CreateAppModal', () => {
beforeEach(() => {
jest.clearAllMocks()
mockTranslationOverrides = {}
mockEnableBilling = false
mockPlanType = Plan.team
mockUsagePlanInfo = createPlanInfo(1)
mockTotalPlanInfo = createPlanInfo(10)
})
// The title and form sections vary based on the modal mode (create vs edit).
describe('Rendering', () => {
test('should render create title and actions when creating', () => {
setup({ appName: 'My App', isEditModal: false })
expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
test('should render edit-only fields when editing a chat app', () => {
setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 })
expect(screen.getByText('app.editAppTitle')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeInTheDocument()
expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5')
})
test.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', (mode) => {
setup({ isEditModal: true, appMode: mode })
expect(screen.getByRole('switch')).toBeInTheDocument()
})
test('should not render answer icon switch when editing a non-chat app', () => {
setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION })
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
test('should not render modal content when hidden', () => {
setup({ show: false })
expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument()
})
})
// Disabled states prevent submission and reflect parent-driven props.
describe('Props', () => {
test('should disable confirm action when confirmDisabled is true', () => {
setup({ confirmDisabled: true })
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
})
test('should disable confirm action when appName is empty', () => {
setup({ appName: ' ' })
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
})
})
// Defensive coverage for falsy input values and translation edge cases.
describe('Edge Cases', () => {
test('should default description to empty string when appDescription is empty', () => {
setup({ appDescription: '' })
expect((screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder') as HTMLTextAreaElement).value).toBe('')
})
test('should fall back to empty placeholders when translations return empty string', () => {
mockTranslationOverrides = {
'app.newApp.appNamePlaceholder': '',
'app.newApp.appDescriptionPlaceholder': '',
}
setup()
expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('')
expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('')
})
})
// The modal should close from user-initiated cancellation actions.
describe('User Interactions', () => {
test('should call onHide when cancel button is clicked', () => {
const { onConfirm, onHide } = setup()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onHide).toHaveBeenCalledTimes(1)
expect(onConfirm).not.toHaveBeenCalled()
})
test('should call onHide when pressing Escape while visible', () => {
const { onHide } = setup()
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
expect(onHide).toHaveBeenCalledTimes(1)
})
test('should not call onHide when pressing Escape while hidden', () => {
const { onHide } = setup({ show: false })
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
expect(onHide).not.toHaveBeenCalled()
})
})
// When billing limits are reached, the modal blocks app creation and shows quota guidance.
describe('Quota Gating', () => {
test('should show AppsFull and disable create when apps quota is reached', () => {
mockEnableBilling = true
mockPlanType = Plan.team
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
setup({ isEditModal: false })
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
})
test('should allow saving when apps quota is reached in edit mode', () => {
mockEnableBilling = true
mockPlanType = Plan.team
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
setup({ isEditModal: true })
expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeEnabled()
})
})
// Shortcut handlers are important for power users and must respect gating rules.
describe('Keyboard Shortcuts', () => {
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
})
test.each([
['meta+enter', { metaKey: true }],
['ctrl+enter', { ctrlKey: true }],
])('should submit when %s is pressed while visible', (_, modifier) => {
const { onConfirm, onHide } = setup()
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, ...modifier })
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
})
test('should not submit when modal is hidden', () => {
const { onConfirm, onHide } = setup({ show: false })
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
test('should not submit when apps quota is reached in create mode', () => {
mockEnableBilling = true
mockPlanType = Plan.team
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
const { onConfirm, onHide } = setup({ isEditModal: false })
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
test('should submit when apps quota is reached in edit mode', () => {
mockEnableBilling = true
mockPlanType = Plan.team
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
const { onConfirm, onHide } = setup({ isEditModal: true })
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
})
test('should not submit when name is empty', () => {
const { onConfirm, onHide } = setup({ appName: ' ' })
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
})
// The app icon picker is a key user flow for customizing metadata.
describe('App Icon Picker', () => {
test('should open and close the picker when cancel is clicked', () => {
setup({
appIconType: 'image',
appIcon: 'file-123',
appIconUrl: 'https://example.com/icon.png',
})
fireEvent.click(getAppIconTrigger())
expect(screen.getByRole('button', { name: 'app.iconPicker.cancel' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
})
test('should update icon payload when selecting emoji and confirming', () => {
jest.useFakeTimers()
try {
const { onConfirm } = setup({
appIconType: 'image',
appIcon: 'file-123',
appIconUrl: 'https://example.com/icon.png',
})
fireEvent.click(getAppIconTrigger())
const emoji = document.querySelector('em-emoji[id="😀"]')
if (!(emoji instanceof HTMLElement))
throw new Error('Failed to locate emoji option in icon picker')
fireEvent.click(emoji)
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).toHaveBeenCalledTimes(1)
const payload = onConfirm.mock.calls[0][0]
expect(payload).toMatchObject({
icon_type: 'emoji',
icon: '😀',
icon_background: '#FFEAD5',
})
}
finally {
jest.useRealTimers()
}
})
test('should reset emoji icon to initial props when picker is cancelled', () => {
setup({
appIconType: 'emoji',
appIcon: '🤖',
appIconBackground: '#FFEAD5',
})
expect(document.querySelector('em-emoji[id="🤖"]')).toBeInTheDocument()
fireEvent.click(getAppIconTrigger())
const emoji = document.querySelector('em-emoji[id="😀"]')
if (!(emoji instanceof HTMLElement))
throw new Error('Failed to locate emoji option in icon picker')
fireEvent.click(emoji)
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
expect(document.querySelector('em-emoji[id="😀"]')).toBeInTheDocument()
fireEvent.click(getAppIconTrigger())
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
expect(document.querySelector('em-emoji[id="🤖"]')).toBeInTheDocument()
})
})
// Submitting uses a debounced handler and builds a payload from current form state.
describe('Submitting', () => {
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
})
test('should call onConfirm with emoji payload and hide when create is clicked', () => {
const { onConfirm, onHide } = setup({
appName: 'My App',
appDescription: 'My description',
appIconType: 'emoji',
appIcon: '😀',
appIconBackground: '#000000',
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
const payload = onConfirm.mock.calls[0][0]
expect(payload).toMatchObject({
name: 'My App',
icon_type: 'emoji',
icon: '😀',
icon_background: '#000000',
description: 'My description',
use_icon_as_answer_icon: false,
})
expect(payload).not.toHaveProperty('max_active_requests')
})
test('should include updated description when textarea is changed before submitting', () => {
const { onConfirm } = setup({ appDescription: 'Old description' })
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onConfirm.mock.calls[0][0]).toMatchObject({ description: 'Updated description' })
})
test('should omit icon_background when submitting with image icon', () => {
const { onConfirm } = setup({
appIconType: 'image',
appIcon: 'file-123',
appIconUrl: 'https://example.com/icon.png',
appIconBackground: null,
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
act(() => {
jest.advanceTimersByTime(300)
})
const payload = onConfirm.mock.calls[0][0]
expect(payload).toMatchObject({
icon_type: 'image',
icon: 'file-123',
})
expect(payload.icon_background).toBeUndefined()
})
test('should include max_active_requests and updated answer icon when saving', () => {
const { onConfirm } = setup({
isEditModal: true,
appMode: AppModeEnum.CHAT,
appUseIconAsAnswerIcon: false,
max_active_requests: 3,
})
fireEvent.click(screen.getByRole('switch'))
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
act(() => {
jest.advanceTimersByTime(300)
})
const payload = onConfirm.mock.calls[0][0]
expect(payload).toMatchObject({
use_icon_as_answer_icon: true,
max_active_requests: 12,
})
})
test('should omit max_active_requests when input is empty', () => {
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
act(() => {
jest.advanceTimersByTime(300)
})
const payload = onConfirm.mock.calls[0][0]
expect(payload.max_active_requests).toBeUndefined()
})
test('should omit max_active_requests when input is not a number', () => {
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
act(() => {
jest.advanceTimersByTime(300)
})
const payload = onConfirm.mock.calls[0][0]
expect(payload.max_active_requests).toBeUndefined()
})
test('should show toast error and not submit when name becomes empty before debounced submit runs', () => {
const { onConfirm, onHide } = setup({ appName: 'My App' })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } })
act(() => {
jest.advanceTimersByTime(300)
})
expect(screen.getByText('explore.appCustomize.nameRequired')).toBeInTheDocument()
act(() => {
jest.advanceTimersByTime(6000)
})
expect(screen.queryByText('explore.appCustomize.nameRequired')).not.toBeInTheDocument()
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react'
import ChatVariableTrigger from './chat-variable-trigger'
const mockUseNodesReadOnly = jest.fn()
const mockUseIsChatMode = jest.fn()
jest.mock('@/app/components/workflow/hooks', () => ({
__esModule: true,
useNodesReadOnly: () => mockUseNodesReadOnly(),
}))
jest.mock('../../hooks', () => ({
__esModule: true,
useIsChatMode: () => mockUseIsChatMode(),
}))
jest.mock('@/app/components/workflow/header/chat-variable-button', () => ({
__esModule: true,
default: ({ disabled }: { disabled: boolean }) => (
<button data-testid='chat-variable-button' type='button' disabled={disabled}>
ChatVariableButton
</button>
),
}))
describe('ChatVariableTrigger', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Verifies conditional rendering when chat mode is off.
describe('Rendering', () => {
it('should not render when not in chat mode', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(false)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
// Act
render(<ChatVariableTrigger />)
// Assert
expect(screen.queryByTestId('chat-variable-button')).not.toBeInTheDocument()
})
})
// Verifies the disabled state reflects read-only nodes.
describe('Props', () => {
it('should render enabled ChatVariableButton when nodes are editable', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(true)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
// Act
render(<ChatVariableTrigger />)
// Assert
expect(screen.getByTestId('chat-variable-button')).toBeEnabled()
})
it('should render disabled ChatVariableButton when nodes are read-only', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(true)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true })
// Act
render(<ChatVariableTrigger />)
// Assert
expect(screen.getByTestId('chat-variable-button')).toBeDisabled()
})
})
})

View File

@ -0,0 +1,458 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Plan } from '@/app/components/billing/type'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import FeaturesTrigger from './features-trigger'
const mockUseIsChatMode = jest.fn()
const mockUseTheme = jest.fn()
const mockUseNodesReadOnly = jest.fn()
const mockUseChecklist = jest.fn()
const mockUseChecklistBeforePublish = jest.fn()
const mockUseNodesSyncDraft = jest.fn()
const mockUseToastContext = jest.fn()
const mockUseFeatures = jest.fn()
const mockUseProviderContext = jest.fn()
const mockUseNodes = jest.fn()
const mockUseEdges = jest.fn()
const mockUseAppStoreSelector = jest.fn()
const mockNotify = jest.fn()
const mockHandleCheckBeforePublish = jest.fn()
const mockHandleSyncWorkflowDraft = jest.fn()
const mockPublishWorkflow = jest.fn()
const mockUpdatePublishedWorkflow = jest.fn()
const mockResetWorkflowVersionHistory = jest.fn()
const mockInvalidateAppTriggers = jest.fn()
const mockFetchAppDetail = jest.fn()
const mockSetAppDetail = jest.fn()
const mockSetPublishedAt = jest.fn()
const mockSetLastPublishedHasUserInput = jest.fn()
const mockWorkflowStoreSetState = jest.fn()
const mockWorkflowStoreSetShowFeaturesPanel = jest.fn()
let workflowStoreState = {
showFeaturesPanel: false,
isRestoring: false,
setShowFeaturesPanel: mockWorkflowStoreSetShowFeaturesPanel,
setPublishedAt: mockSetPublishedAt,
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
}
const mockWorkflowStore = {
getState: () => workflowStoreState,
setState: mockWorkflowStoreSetState,
}
let capturedAppPublisherProps: Record<string, unknown> | null = null
jest.mock('@/app/components/workflow/hooks', () => ({
__esModule: true,
useChecklist: (...args: unknown[]) => mockUseChecklist(...args),
useChecklistBeforePublish: () => mockUseChecklistBeforePublish(),
useNodesReadOnly: () => mockUseNodesReadOnly(),
useNodesSyncDraft: () => mockUseNodesSyncDraft(),
useIsChatMode: () => mockUseIsChatMode(),
}))
jest.mock('@/app/components/workflow/store', () => ({
__esModule: true,
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state: Record<string, unknown> = {
publishedAt: null,
draftUpdatedAt: null,
toolPublished: false,
lastPublishedHasUserInput: false,
}
return selector(state)
},
useWorkflowStore: () => mockWorkflowStore,
}))
jest.mock('@/app/components/base/features/hooks', () => ({
__esModule: true,
useFeatures: (selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures(selector),
}))
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
useToastContext: () => mockUseToastContext(),
}))
jest.mock('@/context/provider-context', () => ({
__esModule: true,
useProviderContext: () => mockUseProviderContext(),
}))
jest.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
__esModule: true,
default: () => mockUseNodes(),
}))
jest.mock('reactflow', () => ({
__esModule: true,
useEdges: () => mockUseEdges(),
}))
jest.mock('@/app/components/app/app-publisher', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
capturedAppPublisherProps = props
return (
<div
data-testid='app-publisher'
data-disabled={String(Boolean(props.disabled))}
data-publish-disabled={String(Boolean(props.publishDisabled))}
/>
)
},
}))
jest.mock('@/service/use-workflow', () => ({
__esModule: true,
useInvalidateAppWorkflow: () => mockUpdatePublishedWorkflow,
usePublishWorkflow: () => ({ mutateAsync: mockPublishWorkflow }),
useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory,
}))
jest.mock('@/service/use-tools', () => ({
__esModule: true,
useInvalidateAppTriggers: () => mockInvalidateAppTriggers,
}))
jest.mock('@/service/apps', () => ({
__esModule: true,
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
}))
jest.mock('@/hooks/use-theme', () => ({
__esModule: true,
default: () => mockUseTheme(),
}))
jest.mock('@/app/components/app/store', () => ({
__esModule: true,
useStore: (selector: (state: { appDetail?: { id: string }; setAppDetail: typeof mockSetAppDetail }) => unknown) => mockUseAppStoreSelector(selector),
}))
const createProviderContext = ({
type = Plan.sandbox,
isFetchedPlan = true,
}: {
type?: Plan
isFetchedPlan?: boolean
}) => ({
plan: { type },
isFetchedPlan,
})
describe('FeaturesTrigger', () => {
beforeEach(() => {
jest.clearAllMocks()
capturedAppPublisherProps = null
workflowStoreState = {
showFeaturesPanel: false,
isRestoring: false,
setShowFeaturesPanel: mockWorkflowStoreSetShowFeaturesPanel,
setPublishedAt: mockSetPublishedAt,
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
}
mockUseTheme.mockReturnValue({ theme: 'light' })
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
mockUseChecklist.mockReturnValue([])
mockUseChecklistBeforePublish.mockReturnValue({ handleCheckBeforePublish: mockHandleCheckBeforePublish })
mockHandleCheckBeforePublish.mockResolvedValue(true)
mockUseNodesSyncDraft.mockReturnValue({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft })
mockUseToastContext.mockReturnValue({ notify: mockNotify })
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({ features: { file: {} } }))
mockUseProviderContext.mockReturnValue(createProviderContext({}))
mockUseNodes.mockReturnValue([])
mockUseEdges.mockReturnValue([])
mockUseAppStoreSelector.mockImplementation(selector => selector({ appDetail: { id: 'app-id' }, setAppDetail: mockSetAppDetail }))
mockFetchAppDetail.mockResolvedValue({ id: 'app-id' })
mockPublishWorkflow.mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
})
// Verifies the feature toggle button only appears in chatflow mode.
describe('Rendering', () => {
it('should not render the features button when not in chat mode', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(false)
// Act
render(<FeaturesTrigger />)
// Assert
expect(screen.queryByRole('button', { name: /workflow\.common\.features/i })).not.toBeInTheDocument()
})
it('should render the features button when in chat mode', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(true)
// Act
render(<FeaturesTrigger />)
// Assert
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toBeInTheDocument()
})
it('should apply dark theme styling when theme is dark', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(true)
mockUseTheme.mockReturnValue({ theme: 'dark' })
// Act
render(<FeaturesTrigger />)
// Assert
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toHaveClass('rounded-lg')
})
})
// Verifies user clicks toggle the features panel visibility.
describe('User Interactions', () => {
it('should toggle features panel when clicked and nodes are editable', async () => {
// Arrange
const user = userEvent.setup()
mockUseIsChatMode.mockReturnValue(true)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
render(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
// Assert
expect(mockWorkflowStoreSetShowFeaturesPanel).toHaveBeenCalledWith(true)
})
})
// Covers read-only gating that prevents toggling unless restoring.
describe('Edge Cases', () => {
it('should not toggle features panel when nodes are read-only and not restoring', async () => {
// Arrange
const user = userEvent.setup()
mockUseIsChatMode.mockReturnValue(true)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true, getNodesReadOnly: () => true })
workflowStoreState = {
...workflowStoreState,
isRestoring: false,
}
render(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
// Assert
expect(mockWorkflowStoreSetShowFeaturesPanel).not.toHaveBeenCalled()
})
})
// Verifies the publisher reflects the presence of workflow nodes.
describe('Props', () => {
it('should disable AppPublisher when there are no workflow nodes', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(false)
mockUseNodes.mockReturnValue([])
// Act
render(<FeaturesTrigger />)
// Assert
expect(capturedAppPublisherProps?.disabled).toBe(true)
expect(screen.getByTestId('app-publisher')).toHaveAttribute('data-disabled', 'true')
})
})
// Verifies derived props passed into AppPublisher (variables, limits, and triggers).
describe('Computed Props', () => {
it('should append image input when file image upload is enabled', () => {
// Arrange
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({
features: { file: { image: { enabled: true } } },
}))
mockUseNodes.mockReturnValue([
{ id: 'start', data: { type: BlockEnum.Start } },
])
// Act
render(<FeaturesTrigger />)
// Assert
const inputs = (capturedAppPublisherProps?.inputs as unknown as Array<{ type?: string; variable?: string }>) || []
expect(inputs).toContainEqual({
type: InputVarType.files,
variable: '__image',
required: false,
label: 'files',
})
})
it('should set startNodeLimitExceeded when sandbox entry limit is exceeded', () => {
// Arrange
mockUseNodes.mockReturnValue([
{ id: 'start', data: { type: BlockEnum.Start } },
{ id: 'trigger-1', data: { type: BlockEnum.TriggerWebhook } },
{ id: 'trigger-2', data: { type: BlockEnum.TriggerSchedule } },
{ id: 'end', data: { type: BlockEnum.End } },
])
// Act
render(<FeaturesTrigger />)
// Assert
expect(capturedAppPublisherProps?.startNodeLimitExceeded).toBe(true)
expect(capturedAppPublisherProps?.publishDisabled).toBe(true)
expect(capturedAppPublisherProps?.hasTriggerNode).toBe(true)
})
})
// Verifies callbacks wired from AppPublisher to stores and draft syncing.
describe('Callbacks', () => {
it('should set toolPublished when AppPublisher refreshes data', () => {
// Arrange
render(<FeaturesTrigger />)
const refresh = capturedAppPublisherProps?.onRefreshData as unknown as (() => void) | undefined
expect(refresh).toBeDefined()
// Act
refresh?.()
// Assert
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ toolPublished: true })
})
it('should sync workflow draft when AppPublisher toggles on', () => {
// Arrange
render(<FeaturesTrigger />)
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
expect(onToggle).toBeDefined()
// Act
onToggle?.(true)
// Assert
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
})
it('should not sync workflow draft when AppPublisher toggles off', () => {
// Arrange
render(<FeaturesTrigger />)
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
expect(onToggle).toBeDefined()
// Act
onToggle?.(false)
// Assert
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
})
})
// Verifies publishing behavior across warnings, validation, and success.
describe('Publishing', () => {
it('should notify error and reject publish when checklist has warning nodes', async () => {
// Arrange
mockUseChecklist.mockReturnValue([{ id: 'warning' }])
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
// Act
await expect(onPublish?.()).rejects.toThrow('Checklist has unresolved items')
// Assert
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' })
})
it('should reject publish when checklist before publish fails', async () => {
// Arrange
mockHandleCheckBeforePublish.mockResolvedValue(false)
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
// Act & Assert
await expect(onPublish?.()).rejects.toThrow('Checklist failed')
})
it('should publish workflow and update related stores when validation passes', async () => {
// Arrange
mockUseNodes.mockReturnValue([
{ id: 'start', data: { type: BlockEnum.Start } },
])
mockUseEdges.mockReturnValue([
{ source: 'start' },
])
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
// Act
await onPublish?.()
// Assert
expect(mockPublishWorkflow).toHaveBeenCalledWith({
url: '/apps/app-id/workflows/publish',
title: '',
releaseNotes: '',
})
expect(mockUpdatePublishedWorkflow).toHaveBeenCalledWith('app-id')
expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('app-id')
expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
await waitFor(() => {
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
expect(mockSetAppDetail).toHaveBeenCalled()
})
})
it('should pass publish params to workflow publish mutation', async () => {
// Arrange
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as ((params: { title: string; releaseNotes: string }) => Promise<void>) | undefined
expect(onPublish).toBeDefined()
// Act
await onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })
// Assert
expect(mockPublishWorkflow).toHaveBeenCalledWith({
url: '/apps/app-id/workflows/publish',
title: 'Test title',
releaseNotes: 'Test notes',
})
})
it('should log error when app detail refresh fails after publish', async () => {
// Arrange
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
mockFetchAppDetail.mockRejectedValue(new Error('fetch failed'))
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
// Act
await onPublish?.()
// Assert
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalled()
})
consoleErrorSpy.mockRestore()
})
})
})

View File

@ -0,0 +1,149 @@
import { render } from '@testing-library/react'
import type { App } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import type { HeaderProps } from '@/app/components/workflow/header'
import WorkflowHeader from './index'
import { fetchWorkflowRunHistory } from '@/service/workflow'
const mockUseAppStoreSelector = jest.fn()
const mockSetCurrentLogItem = jest.fn()
const mockSetShowMessageLogModal = jest.fn()
const mockResetWorkflowVersionHistory = jest.fn()
let capturedHeaderProps: HeaderProps | null = null
let appDetail: App
jest.mock('ky', () => ({
__esModule: true,
default: {
create: () => ({
extend: () => async () => ({
status: 200,
headers: new Headers(),
json: async () => ({}),
blob: async () => new Blob(),
clone: () => ({
status: 200,
json: async () => ({}),
}),
}),
}),
},
}))
jest.mock('@/app/components/app/store', () => ({
__esModule: true,
useStore: (selector: (state: { appDetail?: App; setCurrentLogItem: typeof mockSetCurrentLogItem; setShowMessageLogModal: typeof mockSetShowMessageLogModal }) => unknown) => mockUseAppStoreSelector(selector),
}))
jest.mock('@/app/components/workflow/header', () => ({
__esModule: true,
default: (props: HeaderProps) => {
capturedHeaderProps = props
return <div data-testid='workflow-header' />
},
}))
jest.mock('@/service/workflow', () => ({
__esModule: true,
fetchWorkflowRunHistory: jest.fn(),
}))
jest.mock('@/service/use-workflow', () => ({
__esModule: true,
useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory,
}))
describe('WorkflowHeader', () => {
beforeEach(() => {
jest.clearAllMocks()
capturedHeaderProps = null
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
})
// Verifies the wrapper renders the workflow header shell.
describe('Rendering', () => {
it('should render without crashing', () => {
// Act
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps).not.toBeNull()
})
})
// Verifies chat mode affects which primary action is shown in the header.
describe('Props', () => {
it('should configure preview mode when app is in advanced chat mode', () => {
// Arrange
appDetail = { id: 'app-id', mode: AppModeEnum.ADVANCED_CHAT } as unknown as App
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
// Act
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(false)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(true)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/advanced-chat/workflow-runs')
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyFetcher).toBe(fetchWorkflowRunHistory)
})
it('should configure run mode when app is not in advanced chat mode', () => {
// Arrange
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
// Act
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(true)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(false)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/workflow-runs')
})
})
// Verifies callbacks clear log state as expected.
describe('User Interactions', () => {
it('should clear log and close message modal when clearing history modal state', () => {
// Arrange
render(<WorkflowHeader />)
const clear = capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal
expect(clear).toBeDefined()
// Act
clear?.()
// Assert
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false)
})
})
// Ensures restoring callback is wired to reset version history.
describe('Edge Cases', () => {
it('should use resetWorkflowVersionHistory as restore settled handler', () => {
// Act
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps?.restoring?.onRestoreSettled).toBe(mockResetWorkflowVersionHistory)
})
})
})