From 9812dc2cb2666ed6a3db5638e88713cf624b2a79 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:00:11 +0800 Subject: [PATCH] chore: add some jest tests (#29800) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../edit-item/index.spec.tsx | 6 - .../access-control.spec.tsx | 388 ++++++++++++ .../add-member-or-group-pop.tsx | 2 +- .../config/agent/agent-setting/index.spec.tsx | 6 - .../params-config/config-content.spec.tsx | 392 ++++++++++++ .../params-config/index.spec.tsx | 242 ++++++++ .../params-config/weighted-score.spec.tsx | 81 +++ .../app/create-app-dialog/index.spec.tsx | 6 +- .../billing/annotation-full/index.spec.tsx | 6 - .../billing/annotation-full/modal.spec.tsx | 3 - .../settings/pipeline-settings/index.spec.tsx | 7 - .../process-documents/index.spec.tsx | 7 - .../documents/status-item/index.spec.tsx | 7 - .../explore/create-app-modal/index.spec.tsx | 578 ++++++++++++++++++ .../chat-variable-trigger.spec.tsx | 72 +++ .../workflow-header/features-trigger.spec.tsx | 458 ++++++++++++++ .../components/workflow-header/index.spec.tsx | 149 +++++ 17 files changed, 2364 insertions(+), 46 deletions(-) create mode 100644 web/app/components/app/app-access-control/access-control.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx create mode 100644 web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx create mode 100644 web/app/components/explore/create-app-modal/index.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx create mode 100644 web/app/components/workflow-app/components/workflow-header/index.spec.tsx diff --git a/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx index 356f813afc..f226adf22b 100644 --- a/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx @@ -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( diff --git a/web/app/components/app/app-access-control/access-control.spec.tsx b/web/app/components/app/app-access-control/access-control.spec.tsx new file mode 100644 index 0000000000..2959500a29 --- /dev/null +++ b/web/app/components/app/app-access-control/access-control.spec.tsx @@ -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: (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) => ( +
{children}
+ ) + DialogComponent.Panel = ({ children, className, ...rest }: any) => ( +
{children}
+ ) + const DialogTitle = ({ children, className, ...rest }: any) => ( +
{children}
+ ) + const DialogDescription = ({ children, className, ...rest }: any) => ( +
{children}
+ ) + 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 => ({ + id: 'group-1', + name: 'Group One', + groupSize: 5, + ...overrides, +} as AccessControlGroup) + +const createMember = (overrides: Partial = {}): 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( + + Organization Only + , + ) + + 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( + + Organization Only + , + ) + + 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( + +
Dialog Content
+
, + ) + + 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( + +
Dialog Content
+
, + ) + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument() + expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx index e9519aeedf..bb8dabbae6 100644 --- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx +++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx @@ -32,7 +32,7 @@ export default function AddMemberOrGroupDialog() { const anchorRef = useRef(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) => { diff --git a/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx index 00c0776718..2ff1034537 100644 --- a/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-setting/index.spec.tsx @@ -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 { diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx new file mode 100644 index 0000000000..a7673a7491 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx @@ -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) => ( + + ) + + return { + __esModule: true, + default: MockModelSelector, + } +}) + +jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ + __esModule: true, + default: () =>
, +})) + +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 +const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction + +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 => { + 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 => { + 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() + const datasetConfigs = createDatasetConfigs({ retrieval_model: RETRIEVE_TYPE.oneWay }) + + // Act + render() + + // 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() + 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( + , + ) + + // 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() + 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( + , + ) + + 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() + 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( + , + ) + 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() + 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( + , + ) + await user.click(screen.getByRole('switch')) + + // Assert + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'workflow.errorMsg.rerankModelRequired', + }) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + reranking_enable: true, + }), + ) + }) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx new file mode 100644 index 0000000000..3303c484a1 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx @@ -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
{children}
+ } + + 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) => ( + + ) + + return { + __esModule: true, + default: MockModelSelector, + } +}) + +jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({ + __esModule: true, + default: () =>
, +})) + +const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction +const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction +const mockToastNotify = Toast.notify as unknown as jest.Mock + +const createDatasetConfigs = (overrides: Partial = {}): 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() + const setModalOpenSpy = jest.fn() + + 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['value'] + + return ( + + {children} + + ) + } + + render( + , + { 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() + }) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx new file mode 100644 index 0000000000..e7b1eb8421 --- /dev/null +++ b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.spec.tsx @@ -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() + const value = { value: [0.3, 0.7] } + + // Act + render() + + // 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() + const value = { value: [1, 0] } + + // Act + render() + + // 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() + const value = { value: [0.5, 0.5] } + const user = userEvent.setup() + render() + + // 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() + const value = { value: [0.5, 0.5] } + const user = userEvent.setup() + render() + + // Act + await user.tab() + const slider = screen.getByRole('slider') + expect(slider).toHaveFocus() + await user.keyboard('{ArrowRight}') + + // Assert + expect(onChange).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/app/create-app-dialog/index.spec.tsx b/web/app/components/app/create-app-dialog/index.spec.tsx index a64e409b25..db4384a173 100644 --- a/web/app/components/app/create-app-dialog/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/index.spec.tsx @@ -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() @@ -259,7 +259,7 @@ describe('CreateAppTemplateDialog', () => { }) it('should handle missing optional onCreateFromBlank prop', () => { - const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps + const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps expect(() => { render() diff --git a/web/app/components/billing/annotation-full/index.spec.tsx b/web/app/components/billing/annotation-full/index.spec.tsx index 0caa6a0b57..e95900777c 100644 --- a/web/app/components/billing/annotation-full/index.spec.tsx +++ b/web/app/components/billing/annotation-full/index.spec.tsx @@ -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 (
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 ( + ), +})) + +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() + + // 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() + + // 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() + + // Assert + expect(screen.getByTestId('chat-variable-button')).toBeDisabled() + }) + }) +}) diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx new file mode 100644 index 0000000000..a3fc2c12a9 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.spec.tsx @@ -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 | 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) => unknown) => { + const state: Record = { + 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) => 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) => { + capturedAppPublisherProps = props + return ( +
+ ) + }, +})) + +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) => 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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) => unknown) => selector({ + features: { file: { image: { enabled: true } } }, + })) + mockUseNodes.mockReturnValue([ + { id: 'start', data: { type: BlockEnum.Start } }, + ]) + + // Act + render() + + // 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() + + // 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() + 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() + 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() + 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() + + const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise) | 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() + + const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise) | 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() + + const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise) | 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() + + const onPublish = capturedAppPublisherProps?.onPublish as unknown as ((params: { title: string; releaseNotes: string }) => Promise) | 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() + + const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise) | undefined + expect(onPublish).toBeDefined() + + // Act + await onPublish?.() + + // Assert + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled() + }) + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/web/app/components/workflow-app/components/workflow-header/index.spec.tsx b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx new file mode 100644 index 0000000000..4dd90610bf --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-header/index.spec.tsx @@ -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
+ }, +})) + +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() + + // 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() + + // 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() + + // 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() + + 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() + + // Assert + expect(capturedHeaderProps?.restoring?.onRestoreSettled).toBe(mockResetWorkflowVersionHistory) + }) + }) +})