diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 774d2ba8283..e7d17b20f9e 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -102,16 +102,6 @@ "count": 1 } }, - "web/__tests__/document-detail-navigation-fix.test.tsx": { - "no-console": { - "count": 10 - } - }, - "web/__tests__/document-list-sorting.test.tsx": { - "typescript/no-explicit-any": { - "count": 3 - } - }, "web/__tests__/embedded-user-id-auth.test.tsx": { "typescript/no-explicit-any": { "count": 8 @@ -127,32 +117,6 @@ "count": 3 } }, - "web/__tests__/plugin-tool-workflow-error.test.tsx": { - "typescript/no-explicit-any": { - "count": 2 - } - }, - "web/__tests__/real-browser-flicker.test.tsx": { - "eslint-react/set-state-in-effect": { - "count": 4 - }, - "no-console": { - "count": 16 - }, - "typescript/no-explicit-any": { - "count": 1 - } - }, - "web/__tests__/unified-tags-logic.test.ts": { - "typescript/no-explicit-any": { - "count": 8 - } - }, - "web/__tests__/workflow-onboarding-integration.test.tsx": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -161,14 +125,6 @@ "count": 1 } }, - "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx": { - "no-console": { - "count": 19 - }, - "typescript/no-explicit-any": { - "count": 3 - } - }, "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx": { "no-restricted-imports": { "count": 1 @@ -310,11 +266,6 @@ "count": 1 } }, - "web/app/components/app-sidebar/__tests__/sidebar-animation-issues.spec.tsx": { - "jsx_a11y/anchor-is-valid": { - "count": 1 - } - }, "web/app/components/app-sidebar/app-info/app-info-modals.tsx": { "jsx_a11y/label-has-associated-control": { "count": 1 @@ -618,22 +569,6 @@ "count": 2 } }, - "web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, - "web/app/components/app/configuration/dataset-config/context-var/__tests__/var-picker.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -866,11 +801,6 @@ "count": 2 } }, - "web/app/components/apps/__tests__/app-card.spec.tsx": { - "prefer-promise-reject-errors": { - "count": 1 - } - }, "web/app/components/apps/app-card.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -960,11 +890,6 @@ "count": 2 } }, - "web/app/components/base/auto-height-textarea/__tests__/index.spec.tsx": { - "jsx_a11y/no-autofocus": { - "count": 2 - } - }, "web/app/components/base/auto-height-textarea/index.stories.tsx": { "no-console": { "count": 2 @@ -1818,11 +1743,6 @@ "count": 1 } }, - "web/app/components/base/input/index.tsx": { - "react/only-export-components": { - "count": 1 - } - }, "web/app/components/base/logo/dify-logo.tsx": { "react/only-export-components": { "count": 2 @@ -2040,14 +1960,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/plugins/current-block/__tests__/component.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/current-block/component.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -2069,14 +1981,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/plugins/error-message-block/__tests__/component.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/error-message-block/component.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -2135,14 +2039,6 @@ "count": 1 } }, - "web/app/components/base/prompt-editor/plugins/last-run-block/__tests__/component.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/last-run-block/component.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -2384,22 +2280,6 @@ "count": 2 } }, - "web/app/components/datasets/common/image-list/__tests__/index.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, - "web/app/components/datasets/common/image-list/__tests__/more.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/datasets/common/image-uploader/__tests__/store.spec.tsx": { "eslint-react/error-boundaries": { "count": 1 @@ -2692,11 +2572,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -2820,14 +2695,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx": { "eslint-react/static-components": { "count": 2 @@ -2857,14 +2724,6 @@ "count": 2 } }, - "web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/completed/components/menu-bar.tsx": { "no-restricted-imports": { "count": 1 @@ -3492,17 +3351,6 @@ "count": 1 } }, - "web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx_a11y/no-autofocus": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/plugins/marketplace/search-box/index.tsx": { "jsx_a11y/no-autofocus": { "count": 1 @@ -3598,14 +3446,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/index.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/plugins/plugin-detail-panel/model-selector/index.tsx": { "typescript/no-explicit-any": { "count": 3 @@ -3659,14 +3499,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/index.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -3790,14 +3622,6 @@ "count": 1 } }, - "web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -3889,14 +3713,6 @@ "count": 1 } }, - "web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/index.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/rag-pipeline/components/panel/input-field/hooks.ts": { "eslint-react/set-state-in-effect": { "count": 1 @@ -6666,31 +6482,11 @@ "count": 1 } }, - "web/service/__tests__/use-tools.spec.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/access-control/__tests__/use-app-access-control.spec.tsx": { "no-restricted-imports": { "count": 1 } }, - "web/service/access-control/__tests__/use-member-roles.spec.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/service/access-control/__tests__/use-permission-catalog.spec.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/access-control/use-app-access-control.ts": { "no-restricted-imports": { "count": 1 @@ -6948,11 +6744,6 @@ "count": 1 } }, - "web/service/utils.spec.ts": { - "typescript/no-explicit-any": { - "count": 2 - } - }, "web/service/webapp-auth.ts": { "no-restricted-globals": { "count": 6 @@ -7004,24 +6795,11 @@ "count": 1 } }, - "web/utils/get-icon.spec.ts": { - "typescript/no-explicit-any": { - "count": 2 - } - }, "web/utils/gtag.ts": { "typescript/no-explicit-any": { "count": 2 } }, - "web/utils/index.spec.ts": { - "typescript/no-explicit-any": { - "count": 8 - }, - "vitest/no-identical-title": { - "count": 2 - } - }, "web/utils/index.ts": { "typescript/no-explicit-any": { "count": 3 @@ -7032,11 +6810,6 @@ "count": 1 } }, - "web/utils/tool-call.spec.ts": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/utils/validators.ts": { "typescript/no-explicit-any": { "count": 2 diff --git a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx deleted file mode 100644 index 76b4462621e..00000000000 --- a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import type { DataSet } from '@/models/datasets' -import { fireEvent, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' -import DatasetInfo from '@/app/components/app-sidebar/dataset-info' -import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import { RETRIEVE_METHOD } from '@/types/app' -import { DatasetACLPermission } from '@/utils/permission' - -const mockReplace = vi.fn() -const mockInvalidDatasetList = vi.fn() -const mockInvalidDatasetDetail = vi.fn() -const mockExportPipeline = vi.fn() -const mockCheckIsUsedInApp = vi.fn() -const mockDeleteDataset = vi.fn() -const mockDownloadBlob = vi.fn() - -let mockDataset: DataSet -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ - replace: mockReplace, - }), -})) - -vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => - selector({ - dataset: mockDataset, - }), -})) - -vi.mock('@/hooks/use-knowledge', () => ({ - useKnowledge: () => ({ - formatIndexingTechniqueAndMethod: () => 'indexing-technique', - }), -})) - -vi.mock('@/service/knowledge/use-dataset', () => ({ - datasetDetailQueryKeyPrefix: ['dataset', 'detail'], - useInvalidDatasetList: () => mockInvalidDatasetList, -})) - -vi.mock('@/service/use-base', () => ({ - useInvalid: () => mockInvalidDatasetDetail, -})) - -vi.mock('@/service/use-pipeline', () => ({ - useExportPipelineDSL: () => ({ - mutateAsync: mockExportPipeline, - }), -})) - -vi.mock('@/service/datasets', () => ({ - checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args), - deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args), -})) - -vi.mock('@/utils/download', () => ({ - downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), -})) - -vi.mock('@/app/components/datasets/rename-modal', () => ({ - default: ({ - show, - onClose, - onSuccess, - }: { - show: boolean - onClose: () => void - onSuccess: () => void - }) => - show ? ( -
- - -
- ) : null, -})) - -const createDataset = (overrides: Partial = {}): DataSet => ({ - id: 'dataset-1', - name: 'Dataset Name', - indexing_status: 'completed', - icon_info: { - icon: '๐Ÿ“™', - icon_background: '#FFF4ED', - icon_type: 'emoji', - icon_url: '', - }, - description: 'Dataset description', - permission: DatasetPermission.onlyMe, - data_source_type: DataSourceType.FILE, - indexing_technique: 'high_quality' as DataSet['indexing_technique'], - created_by: 'user-1', - updated_by: 'user-1', - updated_at: 1690000000, - app_count: 0, - doc_form: ChunkingMode.text, - document_count: 1, - total_document_count: 1, - word_count: 1000, - provider: 'internal', - embedding_model: 'text-embedding-3', - embedding_model_provider: 'openai', - embedding_available: true, - retrieval_model_dict: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - }, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - }, - tags: [], - external_knowledge_info: { - external_knowledge_id: '', - external_knowledge_api_id: '', - external_knowledge_api_name: '', - external_knowledge_api_endpoint: '', - }, - external_retrieval_model: { - top_k: 0, - score_threshold: 0, - score_threshold_enabled: false, - }, - built_in_field_enabled: false, - runtime_mode: 'rag_pipeline', - pipeline_id: 'pipeline-1', - enable_api: false, - is_multimodal: false, - is_published: true, - permission_keys: [ - DatasetACLPermission.Edit, - DatasetACLPermission.Delete, - DatasetACLPermission.ImportExportDSL, - ], - ...overrides, -}) - -const openDropdown = () => { - fireEvent.click(screen.getByRole('button')) -} - -describe('App Sidebar Dataset Info Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockDataset = createDataset() - mockExportPipeline.mockResolvedValue({ data: 'pipeline: demo' }) - mockCheckIsUsedInApp.mockResolvedValue({ is_using: false }) - mockDeleteDataset.mockResolvedValue({}) - }) - - it('exports the published pipeline from the dropdown menu', async () => { - render() - - expect(screen.getByText('Dataset Name')).toBeInTheDocument() - - openDropdown() - fireEvent.click(await screen.findByText('datasetPipeline.operations.exportPipeline')) - - await waitFor(() => { - expect(mockExportPipeline).toHaveBeenCalledWith({ - pipelineId: 'pipeline-1', - include: false, - }) - expect(mockDownloadBlob).toHaveBeenCalledWith( - expect.objectContaining({ - fileName: 'Dataset Name.pipeline', - }), - ) - }) - }) - - it('opens the rename modal and refreshes dataset caches after a successful rename', async () => { - render() - - openDropdown() - fireEvent.click(await screen.findByText('common.operation.edit')) - - expect(await screen.findByTestId('rename-dataset-modal')).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'rename-success' })) - - expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1) - expect(mockInvalidDatasetDetail).toHaveBeenCalledTimes(1) - }) - - it('checks app usage before deleting and redirects back to the dataset list after confirmation', async () => { - render() - - openDropdown() - fireEvent.click(await screen.findByText('common.operation.delete')) - - await waitFor(() => { - expect(mockCheckIsUsedInApp).toHaveBeenCalledWith('dataset-1') - expect(screen.getByText('dataset.deleteDatasetConfirmTitle')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - - await waitFor(() => { - expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1') - expect(mockInvalidDatasetList).toHaveBeenCalled() - expect(mockReplace).toHaveBeenCalledWith('/datasets') - }) - }) -}) diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx deleted file mode 100644 index bb624faf6ef..00000000000 --- a/web/__tests__/app/app-access-control-flow.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import { AppPublisher } from '@/app/components/app/app-publisher' -import { AccessMode } from '@/models/access-control' -import { AppModeEnum } from '@/types/app' - -const mockFetchAppDetail = vi.fn() -const mockSetAppDetail = vi.fn() -const mockRefetch = vi.fn() - -let mockAppDetail: { - id: string - name: string - mode: AppModeEnum - access_mode: AccessMode - description: string - icon: string - icon_type: string - icon_background: string - site: { - app_base_url: string - access_token: string - } -} | null = null - -const renderWithQueryClient = (ui: React.ReactElement) => - renderWithSystemFeatures(ui, { - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record) => unknown) => - selector({ - appDetail: mockAppDetail, - setAppDetail: mockSetAppDetail, - }), -})) - -vi.mock('@/hooks/use-format-time-from-now', () => ({ - useFormatTimeFromNow: () => ({ - formatTimeFromNow: (value: number) => `ago:${value}`, - }), -})) - -vi.mock('@/hooks/use-async-window-open', () => ({ - useAsyncWindowOpen: () => vi.fn(), -})) - -vi.mock('@/service/access-control/use-app-access-control', () => ({ - useGetUserCanAccessApp: () => ({ - data: { result: true }, - isLoading: false, - refetch: mockRefetch, - }), - useAppWhiteListSubjects: () => ({ - data: { groups: [], members: [] }, - isLoading: false, - }), -})) - -vi.mock('@/service/apps', () => ({ - fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args), -})) - -vi.mock('@/app/components/app/overview/embedded', () => ({ - default: () => null, -})) - -vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({ - webSocketClient: { - getSocket: vi.fn(() => null), - }, -})) - -vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ - collaborationManager: { - onAppPublishUpdate: vi.fn(() => vi.fn()), - }, -})) - -vi.mock('@/app/components/app/app-access-control', () => ({ - default: ({ onConfirm, onClose }: { onConfirm: () => Promise; onClose: () => void }) => ( -
- - -
- ), -})) - -describe('App Access Control Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAppDetail = { - id: 'app-1', - name: 'Demo App', - mode: AppModeEnum.CHAT, - access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, - description: 'Demo app description', - icon: '๐Ÿค–', - icon_type: 'emoji', - icon_background: '#FFEAD5', - site: { - app_base_url: 'https://example.com', - access_token: 'token-1', - }, - } - mockFetchAppDetail.mockResolvedValue({ - ...mockAppDetail, - access_mode: AccessMode.PUBLIC, - }) - }) - - it('refreshes app detail after confirming access control updates', async () => { - const { queryClient } = renderWithQueryClient() - const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData') - - fireEvent.click(screen.getByRole('button', { name: 'workflow.common.publish' })) - fireEvent.click(screen.getByText('app.accessControlDialog.accessItems.specific')) - - expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'confirm-access-control' })) - - await waitFor(() => { - expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) - }) - expect(setQueryDataSpy).toHaveBeenCalledWith( - ['apps', 'detail', 'app-1'], - expect.objectContaining({ - access_mode: AccessMode.PUBLIC, - }), - ) - expect(mockSetAppDetail).toHaveBeenCalledWith( - expect.objectContaining({ - access_mode: AccessMode.PUBLIC, - }), - ) - - await waitFor(() => { - expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument() - }) - }) -}) diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx deleted file mode 100644 index 52c46905d98..00000000000 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import { AppPublisher } from '@/app/components/app/app-publisher' -import { AccessMode } from '@/models/access-control' -import { AppModeEnum } from '@/types/app' - -const mockTrackEvent = vi.fn() -const mockFetchInstalledAppList = vi.fn() -const mockFetchAppDetailDirect = vi.fn() -const mockToastError = vi.fn() -const mockOpenAsyncWindow = vi.fn() -const mockSetAppDetail = vi.fn() - -let mockAppDetail: { - id: string - name: string - mode: AppModeEnum - access_mode: AccessMode - description: string - icon: string - icon_type: string - icon_background: string - site: { - app_base_url: string - access_token: string - } -} | null = null - -const renderWithQueryClient = (ui: React.ReactElement) => - renderWithSystemFeatures(ui, { - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record) => unknown) => - selector({ - appDetail: mockAppDetail, - setAppDetail: mockSetAppDetail, - }), -})) - -vi.mock('@/hooks/use-format-time-from-now', () => ({ - useFormatTimeFromNow: () => ({ - formatTimeFromNow: (value: number) => `ago:${value}`, - }), -})) - -vi.mock('@/hooks/use-async-window-open', () => ({ - useAsyncWindowOpen: () => mockOpenAsyncWindow, -})) - -vi.mock('@/service/access-control/use-app-access-control', () => ({ - useGetUserCanAccessApp: () => ({ - data: { result: true }, - isLoading: false, - }), - useAppWhiteListSubjects: () => ({ - data: { groups: [], members: [] }, - isLoading: false, - }), -})) - -vi.mock('@/service/explore', () => ({ - fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args), -})) - -vi.mock('@/service/apps', () => ({ - fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), -})) - -vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: { - error: (...args: unknown[]) => mockToastError(...args), - }, -})) - -vi.mock('@/app/components/base/amplitude', () => ({ - trackEvent: (...args: unknown[]) => mockTrackEvent(...args), -})) - -vi.mock('@/app/components/app/overview/embedded', () => ({ - default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => - isShow ? ( -
- -
- ) : null, -})) - -vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({ - webSocketClient: { - getSocket: vi.fn(() => null), - }, -})) - -vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ - collaborationManager: { - onAppPublishUpdate: vi.fn(() => vi.fn()), - }, -})) - -vi.mock('@/app/components/app/app-access-control', () => { - const MockAccessControl = () =>
- - return { - default: MockAccessControl, - AccessControl: MockAccessControl, - } -}) - -vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) - -describe('App Publisher Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAppDetail = { - id: 'app-1', - name: 'Demo App', - mode: AppModeEnum.CHAT, - access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, - description: 'Demo app description', - icon: '๐Ÿค–', - icon_type: 'emoji', - icon_background: '#FFEAD5', - site: { - app_base_url: 'https://example.com', - access_token: 'token-1', - }, - } - mockFetchInstalledAppList.mockResolvedValue({ - installed_apps: [{ id: 'installed-1' }], - }) - mockFetchAppDetailDirect.mockResolvedValue({ - id: 'app-1', - access_mode: AccessMode.PUBLIC, - }) - mockOpenAsyncWindow.mockImplementation( - async (resolver: () => Promise, options?: { onError?: (error: Error) => void }) => { - try { - return await resolver() - } catch (error) { - options?.onError?.(error as Error) - } - }, - ) - }) - - it('publishes from the summary panel and tracks the publish event', async () => { - const onPublish = vi.fn().mockResolvedValue(undefined) - - renderWithQueryClient() - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - - expect(screen.getByText(/(?:^|\.)common\.latestPublished(?=$|:)/)).toBeInTheDocument() - expect(screen.getByText(/(?:^|\.)common\.publishUpdate(?=$|:)/)).toBeInTheDocument() - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publishUpdate(?=$|:)/)) - - await waitFor(() => { - expect(onPublish).toHaveBeenCalledTimes(1) - expect(mockTrackEvent).toHaveBeenCalledWith( - 'app_published_time', - expect.objectContaining({ - action_mode: 'app', - app_id: 'app-1', - app_name: 'Demo App', - }), - ) - }) - }) - - it('opens embedded modal and resolves the installed explore target', async () => { - renderWithQueryClient() - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText(/(?:^|\.)common\.embedIntoSite(?=$|:)/)) - - expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText(/(?:^|\.)common\.openInExplore(?=$|:)/)) - - await waitFor(() => { - expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1') - expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1) - }) - }) - - it('shows a toast error when no installed explore app is available', async () => { - mockFetchInstalledAppList.mockResolvedValue({ - installed_apps: [], - }) - - renderWithQueryClient() - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText(/(?:^|\.)common\.openInExplore(?=$|:)/)) - - await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith( - expect.stringMatching(/(?:^|\.)notPublishedYet(?=$|:)/), - ) - }) - }) -}) diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx deleted file mode 100644 index b38ecb92b74..00000000000 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ /dev/null @@ -1,426 +0,0 @@ -/** - * Integration test: App Card Operations Flow - * - * Tests the end-to-end user flows for app card operations: - * - Editing app info - * - Duplicating an app - * - Deleting an app - * - Exporting app DSL - * - Navigation on card click - * - Access mode icons - */ -import type { App } from '@/types/app' -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import { AppCard } from '@/app/components/apps/app-card' -import { AccessMode } from '@/models/access-control' -import { exportAppConfig, updateAppInfo } from '@/service/apps' -import { AppModeEnum } from '@/types/app' -import { AppACLPermission } from '@/utils/permission' - -let mockSystemFeatures = { - branding: { enabled: false }, - webapp_auth: { enabled: false }, -} - -const toastMocks = vi.hoisted(() => ({ - mockNotify: vi.fn(), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), -})) -const mockRouterPush = vi.fn() - -vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: { - success: (message: string, options?: Record) => - toastMocks.mockNotify({ type: 'success', message, ...options }), - error: (message: string, options?: Record) => - toastMocks.mockNotify({ type: 'error', message, ...options }), - warning: (message: string, options?: Record) => - toastMocks.mockNotify({ type: 'warning', message, ...options }), - info: (message: string, options?: Record) => - toastMocks.mockNotify({ type: 'info', message, ...options }), - dismiss: toastMocks.dismiss, - update: toastMocks.update, - promise: toastMocks.promise, - }, -})) -const mockOnPlanInfoChanged = vi.fn() -const mockDeleteAppMutation = vi.fn().mockResolvedValue(undefined) -let mockDeleteMutationPending = false - -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ - push: mockRouterPush, - }), -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQuery: () => ({ - data: [], - }), - } -}) - -vi.mock('@/next/dynamic', () => ({ - default: (loader: () => Promise) => { - let Component: React.ComponentType> | null = null - loader() - .then((mod) => { - Component = (typeof mod === 'function' ? mod : mod.default) as React.ComponentType< - Record - > - }) - .catch(() => {}) - const Wrapper = (props: Record) => { - if (Component) return - return null - } - Wrapper.displayName = 'DynamicWrapper' - return Wrapper - }, -})) - -vi.mock('@/context/provider-context', () => ({ - useProviderContext: () => ({ - onPlanInfoChanged: mockOnPlanInfoChanged, - }), -})) - -vi.mock('@/service/tag', () => ({ - fetchTagList: vi.fn().mockResolvedValue([]), -})) - -vi.mock('@/service/use-apps', () => ({ - useDeleteAppMutation: () => ({ - mutateAsync: mockDeleteAppMutation, - isPending: mockDeleteMutationPending, - }), - useToggleAppStarMutation: () => ({ - mutateAsync: vi.fn(), - isPending: false, - }), -})) - -vi.mock('@/service/apps', () => ({ - deleteApp: vi.fn().mockResolvedValue({}), - updateAppInfo: vi.fn().mockResolvedValue({}), - copyApp: vi.fn().mockResolvedValue({ id: 'new-app-id', mode: 'chat' }), - exportAppConfig: vi.fn().mockResolvedValue({ data: 'yaml-content' }), -})) - -vi.mock('@/service/explore', () => ({ - fetchInstalledAppList: vi.fn().mockResolvedValue({ installed_apps: [] }), -})) - -vi.mock('@/service/workflow', () => ({ - fetchWorkflowDraft: vi.fn().mockResolvedValue({ environment_variables: [] }), -})) - -vi.mock('@/service/access-control/use-app-access-control', () => ({ - useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false }), -})) - -vi.mock('@/hooks/use-async-window-open', () => ({ - useAsyncWindowOpen: () => vi.fn(), -})) - -// Mock modals loaded via next/dynamic -vi.mock('@/app/components/explore/create-app-modal', () => ({ - default: ({ show, onConfirm, onHide, appName }: Record) => { - if (!show) return null - return ( -
- {appName as string} - - -
- ) - }, -})) - -vi.mock('@/app/components/app/duplicate-modal', () => ({ - default: ({ show, onConfirm, onHide }: Record) => { - if (!show) return null - return ( -
- - -
- ) - }, -})) - -vi.mock('@/app/components/app/switch-app-modal', () => ({ - default: ({ show, onClose, onSuccess }: Record) => { - if (!show) return null - return ( -
- - -
- ) - }, -})) - -vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ - default: ({ onConfirm, onClose }: Record) => ( -
- - -
- ), -})) - -vi.mock('@/app/components/app/app-access-control', () => { - const MockAccessControl = ({ onConfirm, onClose }: Record) => ( -
- - -
- ) - - return { - default: MockAccessControl, - AccessControl: MockAccessControl, - } -}) - -const createMockApp = (overrides: Partial = {}): App => ({ - id: overrides.id ?? 'app-1', - name: overrides.name ?? 'Test Chat App', - description: overrides.description ?? 'A chat application', - author_name: overrides.author_name ?? 'Test Author', - icon_type: overrides.icon_type ?? 'emoji', - icon: overrides.icon ?? '๐Ÿค–', - icon_background: overrides.icon_background ?? '#FFEAD5', - icon_url: overrides.icon_url ?? null, - use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, - mode: overrides.mode ?? AppModeEnum.CHAT, - enable_site: overrides.enable_site ?? true, - enable_api: overrides.enable_api ?? true, - api_rpm: overrides.api_rpm ?? 60, - api_rph: overrides.api_rph ?? 3600, - is_demo: overrides.is_demo ?? false, - model_config: overrides.model_config ?? ({} as App['model_config']), - app_model_config: overrides.app_model_config ?? ({} as App['app_model_config']), - created_at: overrides.created_at ?? 1700000000, - updated_at: overrides.updated_at ?? 1700001000, - site: overrides.site ?? ({} as App['site']), - api_base_url: overrides.api_base_url ?? 'https://api.example.com', - tags: overrides.tags ?? [], - access_mode: overrides.access_mode ?? AccessMode.PUBLIC, - max_active_requests: overrides.max_active_requests ?? null, - created_by: overrides.created_by ?? 'user-1', - permission_keys: overrides.permission_keys ?? [ - AppACLPermission.Edit, - AppACLPermission.ImportExportDSL, - AppACLPermission.Delete, - AppACLPermission.ReleaseAndVersion, - AppACLPermission.AccessConfig, - ], -}) - -const mockOnRefresh = vi.fn() - -const renderAppCard = (app?: Partial) => { - return renderWithSystemFeatures(, { - systemFeatures: mockSystemFeatures, - }) -} - -const openOperationsMenu = async (appName = 'Test Chat App') => { - const user = userEvent.setup() - await user.click( - screen.getByRole('button', { - name: `common.operation.moreActionsFor:{"name":"${appName}"}`, - }), - ) - return user -} - -describe('App Card Operations Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockDeleteMutationPending = false - mockSystemFeatures = { - branding: { enabled: false }, - webapp_auth: { enabled: false }, - } - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe('Card Rendering', () => { - it('should render app name and description', () => { - renderAppCard({ name: 'My AI Bot', description: 'An intelligent assistant' }) - - expect(screen.getByText('My AI Bot')).toBeInTheDocument() - expect(screen.getByText('An intelligent assistant')).toBeInTheDocument() - }) - - it('should render author name', () => { - renderAppCard({ author_name: 'John Doe' }) - - expect(screen.getByText('John Doe')).toBeInTheDocument() - }) - - it('should navigate to app config page when card is clicked', () => { - renderAppCard({ id: 'app-123', mode: AppModeEnum.CHAT }) - - expect(screen.getByRole('link', { name: 'Test Chat App' })).toHaveAttribute( - 'href', - '/app/app-123/configuration', - ) - }) - - it('should navigate to workflow page for workflow apps', () => { - renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' }) - - expect(screen.getByRole('link', { name: 'WF App' })).toHaveAttribute( - 'href', - '/app/app-wf/workflow', - ) - }) - }) - - // -- Delete flow -- - describe('Delete App Flow', () => { - it('should show delete confirmation and call API on confirm', async () => { - renderAppCard({ id: 'app-to-delete', name: 'Deletable App' }) - - const user = await openOperationsMenu('Deletable App') - await user.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) - - await waitFor(() => { - expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument() - }) - - await user.type(screen.getByRole('textbox'), 'Deletable App') - await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - - await waitFor(() => { - expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete') - }) - }) - }) - - // -- Edit flow -- - describe('Edit App Flow', () => { - it('should open edit modal and call updateAppInfo on confirm', async () => { - renderAppCard({ id: 'app-edit', name: 'Editable App' }) - - const user = await openOperationsMenu('Editable App') - await user.click(await screen.findByRole('menuitem', { name: 'app.editApp' })) - await user.click(await screen.findByRole('button', { name: 'Confirm' })) - - await waitFor(() => { - expect(updateAppInfo).toHaveBeenCalledWith( - expect.objectContaining({ - appID: 'app-edit', - name: 'Updated App Name', - }), - ) - }) - }) - }) - - // -- Export flow -- - describe('Export App Flow', () => { - it('should call exportAppConfig for completion apps', async () => { - renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' }) - - const user = await openOperationsMenu('Export App') - await user.click(await screen.findByRole('menuitem', { name: 'app.export' })) - - await waitFor(() => { - expect(exportAppConfig).toHaveBeenCalledWith( - expect.objectContaining({ appID: 'app-export' }), - ) - }) - }) - }) - - // -- Access mode display -- - describe('Access Mode Display', () => { - it('should not render operations menu when user has no app permissions', () => { - renderAppCard({ name: 'Readonly App', created_by: 'another-user', permission_keys: [] }) - - expect( - screen.queryByRole('button', { - name: /common\.operation\.moreActionsFor/, - }), - ).not.toBeInTheDocument() - }) - }) - - // -- Switch mode (only for CHAT/COMPLETION) -- - describe('Switch App Mode', () => { - it('should show switch option for chat mode apps', async () => { - renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT }) - - await openOperationsMenu() - expect(await screen.findByRole('menuitem', { name: 'app.switch' })).toBeVisible() - }) - - it('should not show switch option for workflow apps', async () => { - renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' }) - - await openOperationsMenu('WF App') - expect(await screen.findByRole('menu')).toBeVisible() - expect(screen.queryByRole('menuitem', { name: 'app.switch' })).not.toBeInTheDocument() - }) - }) -}) diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx deleted file mode 100644 index 502b466b3e9..00000000000 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import type { ReactElement, ReactNode } from 'react' -/** - * Integration test: App List Browsing Flow - * - * Tests the end-to-end user flow of browsing, filtering, searching, - * and tab switching in the apps list page. - * - * Covers: List, Empty, Footer, AppCardSkeleton, useAppsQueryState, NewAppCard - */ -import type { AppListResponse } from '@/models/app' -import type { App } from '@/types/app' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' -import List from '@/app/components/apps/list' -import { AccessMode } from '@/models/access-control' -import { createNuqsTestWrapper } from '@/test/nuqs-testing' -import { AppModeEnum } from '@/types/app' - -let mockIsLoadingCurrentWorkspace = false -let mockWorkspacePermissionKeys: string[] = ['app.create_and_management'] - -let mockSystemFeatures = { - branding: { enabled: false }, - webapp_auth: { enabled: false }, -} - -let mockPages: AppListResponse[] = [] -let mockIsLoading = false -let mockIsFetching = false -let mockIsFetchingNextPage = false -let mockHasNextPage = false -let mockError: Error | null = null -const mockRefetch = vi.fn() -const mockFetchNextPage = vi.fn() - -let mockShowTagManagementModal = false - -const mockRouterPush = vi.fn() -const mockRouterReplace = vi.fn() - -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ - push: mockRouterPush, - replace: mockRouterReplace, - }), - usePathname: () => '/apps', - useSearchParams: () => new URLSearchParams(), -})) - -vi.mock('@/next/dynamic', () => ({ - default: (_loader: () => Promise<{ default: React.ComponentType }>) => { - const LazyComponent = (props: Record) => { - return
- } - LazyComponent.displayName = 'DynamicComponent' - return LazyComponent - }, -})) - -vi.mock('@/context/account-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) -vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) -vi.mock('@/context/permission-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) -vi.mock('@/context/version-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) -vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) - -vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = - await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateJotaiMock(importOriginal) -}) - -vi.mock('@/context/provider-context', () => ({ - useProviderContext: () => ({ - onPlanInfoChanged: vi.fn(), - }), -})) - -vi.mock('@/app/components/base/tag-management/store', () => ({ - useStore: (selector: (state: Record) => unknown) => { - const state = { - tagList: [], - showTagManagementModal: mockShowTagManagementModal, - setTagList: vi.fn(), - setShowTagManagementModal: vi.fn(), - } - return selector(state) - }, -})) - -vi.mock('@/service/tag', () => ({ - fetchTagList: vi.fn().mockResolvedValue([]), -})) - -vi.mock('@/service/use-common', () => ({ - useMembers: () => ({ - data: { - accounts: [ - { id: 'member-1', name: 'Alice', avatar_url: null, status: 'active' }, - { id: 'member-2', name: 'Bob', avatar_url: null, status: 'active' }, - ], - }, - }), -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQuery: () => ({ - data: [], - }), - useInfiniteQuery: () => ({ - data: { pages: mockPages }, - isLoading: mockIsLoading, - isFetching: mockIsFetching, - isFetchingNextPage: mockIsFetchingNextPage, - fetchNextPage: mockFetchNextPage, - hasNextPage: mockHasNextPage, - error: mockError, - refetch: mockRefetch, - }), - } -}) - -vi.mock('@/service/use-apps', () => ({ - normalizeAppPagination: (response: T) => response, - useDeleteAppMutation: () => ({ - mutateAsync: vi.fn(), - isPending: false, - }), - useToggleAppStarMutation: () => ({ - mutateAsync: vi.fn(), - isPending: false, - }), -})) - -vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({ - useWorkflowOnlineUsers: () => ({ - onlineUsersMap: {}, - }), -})) - -vi.mock('@/hooks/use-pay', () => ({ - CheckModal: () => null, -})) - -vi.mock('ahooks', async () => { - const actual = await vi.importActual('ahooks') - const React = await vi.importActual('react') - return { - ...actual, - useDebounceFn: (fn: (...args: unknown[]) => void) => { - const fnRef = React.useRef(fn) - fnRef.current = fn - return { - run: (...args: unknown[]) => fnRef.current(...args), - } - }, - } -}) - -const createMockApp = (overrides: Partial = {}): App => ({ - id: overrides.id ?? 'app-1', - name: overrides.name ?? 'My Chat App', - description: overrides.description ?? 'A chat application', - author_name: overrides.author_name ?? 'Test Author', - icon_type: overrides.icon_type ?? 'emoji', - icon: overrides.icon ?? '๐Ÿค–', - icon_background: overrides.icon_background ?? '#FFEAD5', - icon_url: overrides.icon_url ?? null, - use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, - mode: overrides.mode ?? AppModeEnum.CHAT, - enable_site: overrides.enable_site ?? true, - enable_api: overrides.enable_api ?? true, - api_rpm: overrides.api_rpm ?? 60, - api_rph: overrides.api_rph ?? 3600, - is_demo: overrides.is_demo ?? false, - model_config: overrides.model_config ?? ({} as App['model_config']), - app_model_config: overrides.app_model_config ?? ({} as App['app_model_config']), - created_at: overrides.created_at ?? 1700000000, - updated_at: overrides.updated_at ?? 1700001000, - site: overrides.site ?? ({} as App['site']), - api_base_url: overrides.api_base_url ?? 'https://api.example.com', - tags: overrides.tags ?? [], - access_mode: overrides.access_mode ?? AccessMode.PUBLIC, - max_active_requests: overrides.max_active_requests ?? null, -}) - -const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse => ({ - data: apps, - has_more: hasMore, - limit: 30, - page, - total: apps.length, -}) - -const renderListUI = (ui: ReactElement, searchParams?: Record) => { - const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ - systemFeatures: mockSystemFeatures, - }) - const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) - const Wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - - ) - return { ...render(ui, { wrapper: Wrapper }), onUrlUpdate } -} - -const renderList = (searchParams?: Record) => { - return renderListUI(, searchParams) -} - -describe('App List Browsing Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsLoadingCurrentWorkspace = false - mockWorkspacePermissionKeys = ['app.create_and_management'] - mockSystemFeatures = { - branding: { enabled: false }, - webapp_auth: { enabled: false }, - } - mockPages = [] - mockIsLoading = false - mockIsFetching = false - mockIsFetchingNextPage = false - mockHasNextPage = false - mockError = null - mockShowTagManagementModal = false - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe('Loading and Empty States', () => { - it('should show skeleton cards during initial loading', () => { - mockIsLoading = true - renderList() - - const skeletonCards = document.querySelectorAll('.animate-pulse') - expect(skeletonCards.length).toBeGreaterThan(0) - }) - - it('should show empty state when no apps exist', () => { - mockPages = [createPage([])] - renderList() - - expect(screen.getByText('app.firstEmpty.title')).toBeInTheDocument() - }) - - it('should transition from loading to content when data loads', () => { - mockIsLoading = true - const { rerender } = renderListUI() - - const skeletonCards = document.querySelectorAll('.animate-pulse') - expect(skeletonCards.length).toBeGreaterThan(0) - - // Data loads - mockIsLoading = false - mockPages = [createPage([createMockApp({ id: 'app-1', name: 'Loaded App' })])] - - rerender() - - expect(screen.getByText('Loaded App')).toBeInTheDocument() - }) - }) - - // -- Rendering apps -- - describe('App List Rendering', () => { - it('should render all app cards from the data', () => { - mockPages = [ - createPage([ - createMockApp({ id: 'app-1', name: 'Chat Bot' }), - createMockApp({ id: 'app-2', name: 'Workflow Engine', mode: AppModeEnum.WORKFLOW }), - createMockApp({ id: 'app-3', name: 'Completion Tool', mode: AppModeEnum.COMPLETION }), - ]), - ] - - renderList() - - expect(screen.getByText('Chat Bot')).toBeInTheDocument() - expect(screen.getByText('Workflow Engine')).toBeInTheDocument() - expect(screen.getByText('Completion Tool')).toBeInTheDocument() - }) - - it('should display app descriptions', () => { - mockPages = [ - createPage([createMockApp({ name: 'My App', description: 'A powerful AI assistant' })]), - ] - - renderList() - - expect(screen.getByText('A powerful AI assistant')).toBeInTheDocument() - }) - - it('should show the create menu for workspace editors', () => { - mockPages = [createPage([createMockApp({ name: 'Test App' })])] - - renderList() - - expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument() - }) - - it('should hide the create menu when user lacks app creation permission', () => { - mockWorkspacePermissionKeys = [] - mockPages = [createPage([createMockApp({ name: 'Test App' })])] - - renderList() - - expect( - screen.queryByRole('button', { name: 'common.operation.create' }), - ).not.toBeInTheDocument() - expect( - screen.queryByRole('button', { name: 'app.newApp.startFromBlank' }), - ).not.toBeInTheDocument() - expect( - screen.queryByRole('button', { name: 'app.newApp.startFromTemplate' }), - ).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'app.importDSL' })).not.toBeInTheDocument() - }) - }) - - // -- Legacy footer removal -- - describe('Legacy Footer', () => { - it('should not show the legacy footer when branding is disabled', () => { - mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: false } } - mockPages = [createPage([createMockApp()])] - - renderList() - - expect(screen.queryByText('app.join')).not.toBeInTheDocument() - expect(screen.queryByText('app.communityIntro')).not.toBeInTheDocument() - }) - - it('should hide footer when branding is enabled', () => { - mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: true } } - mockPages = [createPage([createMockApp()])] - - renderList() - - expect(screen.queryByText('app.join')).not.toBeInTheDocument() - }) - }) - - // -- DSL drag-drop hint -- - describe('DSL Drag-Drop Hint', () => { - it('should show drag-drop hint for workspace editors', () => { - mockPages = [createPage([createMockApp()])] - renderList() - - expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument() - }) - - it('should hide drag-drop hint without app creation permission', () => { - mockWorkspacePermissionKeys = [] - mockPages = [createPage([createMockApp()])] - renderList() - - expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument() - }) - }) - - // -- Tab navigation -- - describe('Tab Navigation', () => { - it('should render all category options', async () => { - mockPages = [createPage([createMockApp()])] - renderList() - - fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.types' })) - - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.all' }), - ).toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.workflow' }), - ).toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.advanced' }), - ).toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.chatbot' }), - ).toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.types.agent' }), - ).toBeInTheDocument() - expect( - await screen.findByRole('menuitemradio', { name: 'app.newApp.completeApp' }), - ).toBeInTheDocument() - }) - }) - - // -- Search -- - describe('Search Filtering', () => { - it('should render search input', () => { - mockPages = [createPage([createMockApp()])] - renderList() - - const input = document.querySelector('input') - expect(input).toBeInTheDocument() - }) - - it('should update search query when typing in search input', async () => { - mockPages = [createPage([createMockApp()])] - const { onUrlUpdate } = renderList() - - const input = screen.getByPlaceholderText('common.operation.search') - fireEvent.change(input, { target: { value: 'test search' } }) - - await waitFor(() => { - expect(onUrlUpdate).toHaveBeenCalled() - }) - const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(lastCall.searchParams.get('keywords')).toBe('test search') - }) - }) - - // -- Creators filter -- - describe('Creators Filter', () => { - it('should render the creators filter', () => { - mockPages = [createPage([createMockApp()])] - renderList() - - expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument() - }) - - it('should open the creators filter menu', () => { - mockPages = [createPage([createMockApp()])] - renderList() - - fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.creators' })) - - expect(screen.getByRole('button', { name: /Bob/ })).toBeInTheDocument() - }) - }) - - // -- Fetching next page skeleton -- - describe('Pagination Loading', () => { - it('should show skeleton when fetching next page', () => { - mockPages = [createPage([createMockApp()])] - mockIsFetchingNextPage = true - - renderList() - - const skeletonCards = document.querySelectorAll('.animate-pulse') - expect(skeletonCards.length).toBeGreaterThan(0) - }) - }) - - // -- Dataset operator behavior -- - describe('Dataset Operator Behavior', () => { - it('should not redirect at list component level for dataset operators', () => { - renderList() - - expect(mockRouterReplace).not.toHaveBeenCalled() - }) - }) - - // -- Multiple pages of data -- - describe('Multi-page Data', () => { - it('should render apps from multiple pages', () => { - mockPages = [ - createPage([createMockApp({ id: 'app-1', name: 'Page One App' })], true, 1), - createPage([createMockApp({ id: 'app-2', name: 'Page Two App' })], false, 2), - ] - - renderList() - - expect(screen.getByText('Page One App')).toBeInTheDocument() - expect(screen.getByText('Page Two App')).toBeInTheDocument() - }) - }) - - // -- controlRefreshList triggers refetch -- - describe('Refresh List', () => { - it('should call refetch when controlRefreshList increments', () => { - mockPages = [createPage([createMockApp()])] - - const { rerender } = renderListUI() - - rerender() - - expect(mockRefetch).toHaveBeenCalled() - }) - }) -}) diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx deleted file mode 100644 index 14a4a69a7af..00000000000 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ /dev/null @@ -1,569 +0,0 @@ -import type { ReactNode } from 'react' -/** - * Integration test: Create App Flow - * - * Tests the end-to-end user flows for creating new apps: - * - Creating from blank via NewAppCard - * - Creating from template via NewAppCard - * - Creating from DSL import via NewAppCard - * - Apps page top-level state management - */ -import type { AppListResponse } from '@/models/app' -import type { App } from '@/types/app' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' -import List from '@/app/components/apps/list' -import { AccessMode } from '@/models/access-control' -import { createNuqsTestWrapper } from '@/test/nuqs-testing' -import { AppModeEnum } from '@/types/app' - -let mockIsLoadingCurrentWorkspace = false -let mockWorkspacePermissionKeys: string[] = ['app.create_and_management'] -let mockSystemFeatures = { - branding: { enabled: false }, - webapp_auth: { enabled: false }, -} - -let mockPages: AppListResponse[] = [] -let mockIsLoading = false -let mockIsFetching = false -const mockRefetch = vi.fn() -const mockFetchNextPage = vi.fn() -let mockShowTagManagementModal = false - -const mockRouterPush = vi.fn() -const mockRouterReplace = vi.fn() -const mockOnPlanInfoChanged = vi.fn() - -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ - push: mockRouterPush, - replace: mockRouterReplace, - }), - usePathname: () => '/apps', - useSearchParams: () => new URLSearchParams(), -})) - -vi.mock('@/context/account-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) -vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) -vi.mock('@/context/permission-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) -vi.mock('@/context/version-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) -vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, - isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) - -vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = - await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateJotaiMock(importOriginal) -}) - -vi.mock('@/context/provider-context', () => ({ - useProviderContext: () => ({ - onPlanInfoChanged: mockOnPlanInfoChanged, - }), -})) - -vi.mock('@/app/components/base/tag-management/store', () => ({ - useStore: (selector: (state: Record) => unknown) => { - const state = { - tagList: [], - showTagManagementModal: mockShowTagManagementModal, - setTagList: vi.fn(), - setShowTagManagementModal: vi.fn(), - } - return selector(state) - }, -})) - -vi.mock('@/service/tag', () => ({ - fetchTagList: vi.fn().mockResolvedValue([]), -})) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQuery: () => ({ - data: [], - }), - useInfiniteQuery: () => ({ - data: { pages: mockPages }, - isLoading: mockIsLoading, - isFetching: mockIsFetching, - isFetchingNextPage: false, - fetchNextPage: mockFetchNextPage, - hasNextPage: false, - error: null, - refetch: mockRefetch, - }), - } -}) - -vi.mock('@/service/use-apps', () => ({ - normalizeAppPagination: (response: T) => response, - useDeleteAppMutation: () => ({ - mutateAsync: vi.fn(), - isPending: false, - }), - useToggleAppStarMutation: () => ({ - mutateAsync: vi.fn(), - isPending: false, - }), -})) - -vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({ - useWorkflowOnlineUsers: () => ({ - onlineUsersMap: {}, - }), -})) - -vi.mock('@/hooks/use-pay', () => ({ - CheckModal: () => null, -})) - -vi.mock('ahooks', async () => { - const actual = await vi.importActual('ahooks') - const React = await vi.importActual('react') - return { - ...actual, - useDebounceFn: (fn: (...args: unknown[]) => void) => { - const fnRef = React.useRef(fn) - fnRef.current = fn - return { - run: (...args: unknown[]) => fnRef.current(...args), - } - }, - } -}) - -// Mock dynamically loaded modals with test stubs -vi.mock('@/next/dynamic', () => ({ - default: (loader: () => Promise<{ default: React.ComponentType }>) => { - let Component: React.ComponentType> | null = null - loader() - .then((mod) => { - Component = mod.default as React.ComponentType> - }) - .catch(() => {}) - const Wrapper = (props: Record) => { - if (Component) return - return null - } - Wrapper.displayName = 'DynamicWrapper' - return Wrapper - }, -})) - -vi.mock('@/app/components/app/create-app-modal', () => ({ - default: ({ show, onClose, onSuccess, onCreateFromTemplate }: Record) => { - if (!show) return null - return ( -
- - {!!onCreateFromTemplate && ( - - )} - -
- ) - }, -})) - -vi.mock('@/app/components/app/create-app-dialog', () => ({ - default: ({ show, onClose, onSuccess, onCreateFromBlank }: Record) => { - if (!show) return null - return ( -
- - {!!onCreateFromBlank && ( - - )} - -
- ) - }, -})) - -vi.mock('@/app/components/app/create-from-dsl-modal', () => ({ - default: ({ show, onClose, onSuccess }: Record) => { - if (!show) return null - return ( -
- - -
- ) - }, - CreateFromDSLModalTab: { - FROM_URL: 'from-url', - FROM_FILE: 'from-file', - }, -})) - -const createMockApp = (overrides: Partial = {}): App => ({ - id: overrides.id ?? 'app-1', - name: overrides.name ?? 'Test App', - description: overrides.description ?? 'A test app', - author_name: overrides.author_name ?? 'Author', - icon_type: overrides.icon_type ?? 'emoji', - icon: overrides.icon ?? '๐Ÿค–', - icon_background: overrides.icon_background ?? '#FFEAD5', - icon_url: overrides.icon_url ?? null, - use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, - mode: overrides.mode ?? AppModeEnum.CHAT, - enable_site: overrides.enable_site ?? true, - enable_api: overrides.enable_api ?? true, - api_rpm: overrides.api_rpm ?? 60, - api_rph: overrides.api_rph ?? 3600, - is_demo: overrides.is_demo ?? false, - model_config: overrides.model_config ?? ({} as App['model_config']), - app_model_config: overrides.app_model_config ?? ({} as App['app_model_config']), - created_at: overrides.created_at ?? 1700000000, - updated_at: overrides.updated_at ?? 1700001000, - site: overrides.site ?? ({} as App['site']), - api_base_url: overrides.api_base_url ?? 'https://api.example.com', - tags: overrides.tags ?? [], - access_mode: overrides.access_mode ?? AccessMode.PUBLIC, - max_active_requests: overrides.max_active_requests ?? null, -}) - -const createPage = (apps: App[]): AppListResponse => ({ - data: apps, - has_more: false, - limit: 30, - page: 1, - total: apps.length, -}) - -const renderList = () => { - const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ - systemFeatures: mockSystemFeatures, - }) - const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper() - const Wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - - ) - return { ...render(, { wrapper: Wrapper }), onUrlUpdate } -} - -const openCreateMenu = () => { - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) -} - -const clickCreateMenuItem = (label: string) => { - openCreateMenu() - fireEvent.click(screen.getByText(label)) -} - -describe('Create App Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsLoadingCurrentWorkspace = false - mockWorkspacePermissionKeys = ['app.create_and_management'] - mockSystemFeatures = { - branding: { enabled: false }, - webapp_auth: { enabled: false }, - } - mockPages = [createPage([createMockApp()])] - mockIsLoading = false - mockIsFetching = false - mockShowTagManagementModal = false - }) - - describe('NewAppCard Rendering', () => { - it('should render the create menu with all options', () => { - renderList() - - expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument() - openCreateMenu() - expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument() - expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument() - expect(screen.getByText('app.importDSL')).toBeInTheDocument() - }) - - it('should render disabled the create menu when user lacks app creation permission', () => { - mockWorkspacePermissionKeys = [] - renderList() - - expect( - screen.queryByRole('button', { name: 'common.operation.create' }), - ).not.toBeInTheDocument() - }) - - it('should keep the create menu available while workspace state is loading', () => { - mockIsLoadingCurrentWorkspace = true - renderList() - - expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument() - }) - }) - - // -- Create from blank -- - describe('Create from Blank Flow', () => { - it('should open the create app modal when "Start from Blank" is clicked', async () => { - renderList() - - clickCreateMenuItem('app.newApp.startFromBlank') - - await waitFor(() => { - expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() - }) - }) - - it('should close the create app modal on cancel', async () => { - renderList() - - clickCreateMenuItem('app.newApp.startFromBlank') - await waitFor(() => { - expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('create-blank-cancel')) - await waitFor(() => { - expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() - }) - }) - - it('should call onPlanInfoChanged and refetch on successful creation', async () => { - renderList() - - clickCreateMenuItem('app.newApp.startFromBlank') - await waitFor(() => { - expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('create-blank-confirm')) - await waitFor(() => { - expect(mockOnPlanInfoChanged).toHaveBeenCalled() - expect(mockRefetch).toHaveBeenCalled() - }) - }) - }) - - // -- Create from template -- - describe('Create from Template Flow', () => { - it('should open template dialog when "Start from Template" is clicked', async () => { - renderList() - - clickCreateMenuItem('app.newApp.startFromTemplate') - - await waitFor(() => { - expect(screen.getByTestId('template-dialog')).toBeInTheDocument() - }) - }) - - it('should allow switching from template to blank modal', async () => { - renderList() - - clickCreateMenuItem('app.newApp.startFromTemplate') - await waitFor(() => { - expect(screen.getByTestId('template-dialog')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('switch-to-blank')) - await waitFor(() => { - expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() - expect(screen.queryByTestId('template-dialog')).not.toBeInTheDocument() - }) - }) - - it('should allow switching from blank to template dialog', async () => { - renderList() - - clickCreateMenuItem('app.newApp.startFromBlank') - await waitFor(() => { - expect(screen.getByTestId('create-app-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('switch-to-template')) - await waitFor(() => { - expect(screen.getByTestId('template-dialog')).toBeInTheDocument() - expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() - }) - }) - }) - - // -- Create from DSL import (via NewAppCard button) -- - describe('Create from DSL Import Flow', () => { - it('should open DSL import modal when "Import DSL" is clicked', async () => { - renderList() - - clickCreateMenuItem('app.importDSL') - - await waitFor(() => { - expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() - }) - }) - - it('should close DSL import modal on cancel', async () => { - renderList() - - clickCreateMenuItem('app.importDSL') - await waitFor(() => { - expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('dsl-import-cancel')) - await waitFor(() => { - expect(screen.queryByTestId('create-from-dsl-modal')).not.toBeInTheDocument() - }) - }) - - it('should call onPlanInfoChanged and refetch on successful DSL import', async () => { - renderList() - - clickCreateMenuItem('app.importDSL') - await waitFor(() => { - expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('dsl-import-confirm')) - await waitFor(() => { - expect(mockOnPlanInfoChanged).toHaveBeenCalled() - expect(mockRefetch).toHaveBeenCalled() - }) - }) - }) - - // -- DSL drag-and-drop flow (via List component) -- - describe('DSL Drag-Drop Flow', () => { - it('should show drag-drop hint in the list', () => { - renderList() - - expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument() - }) - - it('should open create-from-DSL modal when DSL file is dropped', async () => { - const { act } = await import('@testing-library/react') - renderList() - - const container = document.querySelector('[class*="overflow-y-auto"]') - if (container) { - const yamlFile = new File(['app: test'], 'app.yaml', { type: 'application/yaml' }) - - // Simulate the full drag-drop sequence wrapped in act - await act(async () => { - const dragEnterEvent = new Event('dragenter', { bubbles: true }) - Object.defineProperty(dragEnterEvent, 'dataTransfer', { - value: { types: ['Files'], files: [] }, - }) - Object.defineProperty(dragEnterEvent, 'preventDefault', { value: vi.fn() }) - Object.defineProperty(dragEnterEvent, 'stopPropagation', { value: vi.fn() }) - container.dispatchEvent(dragEnterEvent) - - const dropEvent = new Event('drop', { bubbles: true }) - Object.defineProperty(dropEvent, 'dataTransfer', { - value: { files: [yamlFile], types: ['Files'] }, - }) - Object.defineProperty(dropEvent, 'preventDefault', { value: vi.fn() }) - Object.defineProperty(dropEvent, 'stopPropagation', { value: vi.fn() }) - container.dispatchEvent(dropEvent) - }) - - await waitFor(() => { - const modal = screen.queryByTestId('create-from-dsl-modal') - if (modal) expect(modal).toBeInTheDocument() - }) - } - }) - }) - - // -- Edge cases -- - describe('Edge Cases', () => { - it('should not show create options when no data and user is editor', () => { - mockPages = [createPage([])] - renderList() - - expect(screen.getByText('app.firstEmpty.title')).toBeInTheDocument() - expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument() - expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument() - expect(screen.getByText('app.importDSL')).toBeInTheDocument() - }) - - it('should handle multiple rapid clicks on create buttons without crashing', async () => { - renderList() - - clickCreateMenuItem('app.newApp.startFromBlank') - clickCreateMenuItem('app.newApp.startFromTemplate') - clickCreateMenuItem('app.importDSL') - - // Should not crash, and some modal should be present - await waitFor(() => { - const anyModal = - screen.queryByTestId('create-app-modal') || - screen.queryByTestId('template-dialog') || - screen.queryByTestId('create-from-dsl-modal') - expect(anyModal).toBeTruthy() - }) - }) - }) -}) diff --git a/web/__tests__/base/notion-page-selector-flow.test.tsx b/web/__tests__/base/notion-page-selector-flow.test.tsx index e55956311bd..524093c437f 100644 --- a/web/__tests__/base/notion-page-selector-flow.test.tsx +++ b/web/__tests__/base/notion-page-selector-flow.test.tsx @@ -152,11 +152,14 @@ describe('Base Notion Page Selector Flow', () => { ]), ) - await user.type(screen.getByTestId('notion-search-input'), 'missing-page') + await user.type( + screen.getByPlaceholderText('common.dataSource.notion.selector.searchPages'), + 'missing-page', + ) expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) - expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument() + expect(screen.getByText('Root 1')).toBeInTheDocument() await user.click(screen.getByTestId('notion-page-preview-root-1')) expect(onPreview).toHaveBeenCalledWith( @@ -181,7 +184,7 @@ describe('Base Notion Page Selector Flow', () => { expect(onSelectCredential).toHaveBeenCalledWith('c1') await user.click(screen.getByRole('combobox', { name: /Workspace 1/ })) - await user.click(screen.getByTestId('notion-credential-item-c2')) + await user.click(screen.getByRole('option', { name: /Workspace 2/ })) expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-1', @@ -191,7 +194,7 @@ describe('Base Notion Page Selector Flow', () => { await waitFor(() => { expect(onSelectCredential).toHaveBeenLastCalledWith('c2') - expect(screen.getByTestId('notion-page-name-external-1')).toBeInTheDocument() + expect(screen.getByText('External 1')).toBeInTheDocument() }) await user.click( diff --git a/web/__tests__/custom/custom-page-flow.test.tsx b/web/__tests__/custom/custom-page-flow.test.tsx deleted file mode 100644 index 78b4e0f2797..00000000000 --- a/web/__tests__/custom/custom-page-flow.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createMockProviderContextValue } from '@/__mocks__/provider-context' -import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config' -import { Plan } from '@/app/components/billing/type' -import CustomPage from '@/app/components/custom/custom-page' -import useWebAppBrand from '@/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand' - -const mockSetShowPricingModal = vi.fn() - -vi.mock('@/config', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - IS_CLOUD_EDITION: true, - } -}) -vi.mock('@/context/provider-context', () => ({ - useProviderContext: vi.fn(), -})) - -vi.mock('@/context/modal-context', () => ({ - useModalContext: () => ({ - setShowPricingModal: mockSetShowPricingModal, - }), -})) - -vi.mock('@/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand', () => ({ - __esModule: true, - default: vi.fn(), -})) - -const { useProviderContext } = await import('@/context/provider-context') - -const mockUseProviderContext = vi.mocked(useProviderContext) -const mockUseWebAppBrand = vi.mocked(useWebAppBrand) - -const createBrandState = ( - overrides: Partial> = {}, -): ReturnType => ({ - fileId: '', - imgKey: 1, - uploadProgress: 0, - uploading: false, - webappLogo: 'https://example.com/logo.png', - webappBrandRemoved: false, - uploadDisabled: false, - workspaceLogo: 'https://example.com/workspace-logo.png', - canManageCustomBrand: true, - isSandbox: false, - handleApply: vi.fn(), - handleCancel: vi.fn(), - handleChange: vi.fn(), - handleRestore: vi.fn(), - handleSwitch: vi.fn(), - ...overrides, -}) - -const setProviderPlan = (planType: Plan, enableBilling = true) => { - mockUseProviderContext.mockReturnValue( - createMockProviderContextValue({ - enableBilling, - plan: { - ...defaultPlan, - type: planType, - }, - }), - ) -} - -describe('Custom Page Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - setProviderPlan(Plan.professional) - mockUseWebAppBrand.mockReturnValue(createBrandState()) - }) - - it('shows the billing upgrade banner for sandbox workspaces and opens pricing modal', () => { - setProviderPlan(Plan.sandbox) - - render() - - expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() - expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() - - fireEvent.click(screen.getByText('billing.upgradeBtn.encourageShort')) - - expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) - }) - - it('renders the branding controls and the sales contact footer for paid workspaces', () => { - const hookState = createBrandState({ - fileId: 'pending-logo', - }) - mockUseWebAppBrand.mockReturnValue(hookState) - - render() - - const contactLink = screen.getByText('custom.customize.contactUs').closest('a') - expect(contactLink).toHaveAttribute('href', contactSalesUrl) - - fireEvent.click(screen.getByRole('switch')) - fireEvent.click(screen.getByRole('button', { name: 'custom.restore' })) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - fireEvent.click(screen.getByRole('button', { name: 'custom.apply' })) - - expect(hookState.handleSwitch).toHaveBeenCalledWith(true) - expect(hookState.handleRestore).toHaveBeenCalledTimes(1) - expect(hookState.handleCancel).toHaveBeenCalledTimes(1) - expect(hookState.handleApply).toHaveBeenCalledTimes(1) - }) -}) diff --git a/web/__tests__/datasets/create-dataset-flow.test.tsx b/web/__tests__/datasets/create-dataset-flow.test.tsx deleted file mode 100644 index 94cce5dda66..00000000000 --- a/web/__tests__/datasets/create-dataset-flow.test.tsx +++ /dev/null @@ -1,312 +0,0 @@ -/** - * Integration Test: Create Dataset Flow - * - * Tests cross-module data flow: step-one data โ†’ step-two hooks โ†’ creation params โ†’ API call - * Validates data contracts between steps. - */ - -import type { CustomFile } from '@/models/datasets' -import type { RetrievalConfig } from '@/types/app' -import { act, renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' -import { RETRIEVE_METHOD } from '@/types/app' - -const mockCreateFirstDocument = vi.fn() -const mockCreateDocument = vi.fn() -vi.mock('@/service/knowledge/use-create-dataset', () => ({ - useCreateFirstDocument: () => ({ mutateAsync: mockCreateFirstDocument, isPending: false }), - useCreateDocument: () => ({ mutateAsync: mockCreateDocument, isPending: false }), - getNotionInfo: (pages: { page_id: string }[], credentialId: string) => ({ - workspace_id: 'ws-1', - pages: pages.map((p) => p.page_id), - notion_credential_id: credentialId, - }), - getWebsiteInfo: (opts: { websitePages: { url: string }[]; websiteCrawlProvider: string }) => ({ - urls: opts.websitePages.map((p) => p.url), - only_main_content: true, - provider: opts.websiteCrawlProvider, - }), -})) - -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: () => vi.fn(), -})) - -vi.mock('@langgenius/dify-ui/toast', () => ({ - default: { notify: vi.fn() }, - toast: { - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), - info: vi.fn(), - }, -})) - -vi.mock('@/app/components/base/amplitude', () => ({ - trackEvent: vi.fn(), -})) - -// Import hooks after mocks -const { - useSegmentationState, - DEFAULT_SEGMENT_IDENTIFIER, - DEFAULT_MAXIMUM_CHUNK_LENGTH, - DEFAULT_OVERLAP, -} = await import('@/app/components/datasets/create/step-two/hooks') -const { useDocumentCreation, IndexingType } = - await import('@/app/components/datasets/create/step-two/hooks') - -const createMockFile = (overrides?: Partial): CustomFile => - ({ - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 1024, - extension: '.txt', - mime_type: 'text/plain', - created_at: 0, - created_by: '', - ...overrides, - }) as CustomFile - -describe('Create Dataset Flow - Cross-Step Data Contract', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Step-One โ†’ Step-Two: Segmentation Defaults', () => { - it('should initialise with correct default segmentation values', () => { - const { result } = renderHook(() => useSegmentationState()) - expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) - expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) - expect(result.current.overlap).toBe(DEFAULT_OVERLAP) - expect(result.current.segmentationType).toBe(ProcessMode.general) - }) - - it('should produce valid process rule for general chunking', () => { - const { result } = renderHook(() => useSegmentationState()) - const processRule = result.current.getProcessRule(ChunkingMode.text) - - // mode should be segmentationType = ProcessMode.general = 'custom' - expect(processRule.mode).toBe('custom') - expect(processRule.rules.segmentation).toEqual({ - separator: '\n\n', // unescaped from \\n\\n - max_tokens: DEFAULT_MAXIMUM_CHUNK_LENGTH, - chunk_overlap: DEFAULT_OVERLAP, - }) - // rules is empty initially since no default config loaded - expect(processRule.rules.pre_processing_rules).toEqual([]) - }) - - it('should produce valid process rule for parent-child chunking', () => { - const { result } = renderHook(() => useSegmentationState()) - const processRule = result.current.getProcessRule(ChunkingMode.parentChild) - - expect(processRule.mode).toBe('hierarchical') - expect(processRule.rules.parent_mode).toBe('paragraph') - expect(processRule.rules.segmentation).toEqual({ - separator: '\n\n', - max_tokens: 1024, - }) - expect(processRule.rules.subchunk_segmentation).toEqual({ - separator: '\n', - max_tokens: 512, - }) - }) - }) - - describe('Step-Two โ†’ Creation API: Params Building', () => { - it('should build valid creation params for file upload workflow', () => { - const files = [createMockFile()] - const { result: segResult } = renderHook(() => useSegmentationState()) - const { result: creationResult } = renderHook(() => - useDocumentCreation({ - dataSourceType: DataSourceType.FILE, - files, - notionPages: [], - notionCredentialId: '', - websitePages: [], - }), - ) - - const processRule = segResult.current.getProcessRule(ChunkingMode.text) - const retrievalConfig: RetrievalConfig = { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0, - } - - const params = creationResult.current.buildCreationParams( - ChunkingMode.text, - 'English', - processRule, - retrievalConfig, - { provider: 'openai', model: 'text-embedding-ada-002' }, - IndexingType.QUALIFIED, - ) - - expect(params).not.toBeNull() - // File IDs come from file.id (not file.file.id) - expect(params!.data_source.type).toBe(DataSourceType.FILE) - expect(params!.data_source.info_list.file_info_list?.file_ids).toContain('file-1') - - expect(params!.indexing_technique).toBe(IndexingType.QUALIFIED) - expect(params!.doc_form).toBe(ChunkingMode.text) - expect(params!.doc_language).toBe('English') - expect(params!.embedding_model).toBe('text-embedding-ada-002') - expect(params!.embedding_model_provider).toBe('openai') - expect(params!.process_rule.mode).toBe('custom') - }) - - it('should validate params: overlap must not exceed maxChunkLength', () => { - const { result } = renderHook(() => - useDocumentCreation({ - dataSourceType: DataSourceType.FILE, - files: [createMockFile()], - notionPages: [], - notionCredentialId: '', - websitePages: [], - }), - ) - - // validateParams returns false (invalid) when overlap > maxChunkLength for general mode - const isValid = result.current.validateParams({ - segmentationType: 'general', - maxChunkLength: 100, - limitMaxChunkLength: 4000, - overlap: 200, // overlap > maxChunkLength - indexType: IndexingType.QUALIFIED, - embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, - rerankModelList: [], - retrievalConfig: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0, - }, - }) - expect(isValid).toBe(false) - }) - - it('should validate params: maxChunkLength must not exceed limit', () => { - const { result } = renderHook(() => - useDocumentCreation({ - dataSourceType: DataSourceType.FILE, - files: [createMockFile()], - notionPages: [], - notionCredentialId: '', - websitePages: [], - }), - ) - - const isValid = result.current.validateParams({ - segmentationType: 'general', - maxChunkLength: 5000, - limitMaxChunkLength: 4000, // limit < maxChunkLength - overlap: 50, - indexType: IndexingType.QUALIFIED, - embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, - rerankModelList: [], - retrievalConfig: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0, - }, - }) - expect(isValid).toBe(false) - }) - }) - - describe('Full Flow: Segmentation State โ†’ Process Rule โ†’ Creation Params Consistency', () => { - it('should keep segmentation values consistent across getProcessRule and buildCreationParams', () => { - const files = [createMockFile()] - const { result: segResult } = renderHook(() => useSegmentationState()) - const { result: creationResult } = renderHook(() => - useDocumentCreation({ - dataSourceType: DataSourceType.FILE, - files, - notionPages: [], - notionCredentialId: '', - websitePages: [], - }), - ) - - // Change segmentation settings - act(() => { - segResult.current.setMaxChunkLength(2048) - segResult.current.setOverlap(100) - }) - - const processRule = segResult.current.getProcessRule(ChunkingMode.text) - expect(processRule.rules.segmentation.max_tokens).toBe(2048) - expect(processRule.rules.segmentation.chunk_overlap).toBe(100) - - const params = creationResult.current.buildCreationParams( - ChunkingMode.text, - 'Chinese', - processRule, - { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0, - }, - { provider: 'openai', model: 'text-embedding-ada-002' }, - IndexingType.QUALIFIED, - ) - - expect(params).not.toBeNull() - expect(params!.process_rule.rules.segmentation.max_tokens).toBe(2048) - expect(params!.process_rule.rules.segmentation.chunk_overlap).toBe(100) - expect(params!.doc_language).toBe('Chinese') - }) - - it('should support parent-child mode through the full pipeline', () => { - const files = [createMockFile()] - const { result: segResult } = renderHook(() => useSegmentationState()) - const { result: creationResult } = renderHook(() => - useDocumentCreation({ - dataSourceType: DataSourceType.FILE, - files, - notionPages: [], - notionCredentialId: '', - websitePages: [], - }), - ) - - const processRule = segResult.current.getProcessRule(ChunkingMode.parentChild) - const params = creationResult.current.buildCreationParams( - ChunkingMode.parentChild, - 'English', - processRule, - { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0, - }, - { provider: 'openai', model: 'text-embedding-ada-002' }, - IndexingType.QUALIFIED, - ) - - expect(params).not.toBeNull() - expect(params!.doc_form).toBe(ChunkingMode.parentChild) - expect(params!.process_rule.mode).toBe('hierarchical') - expect(params!.process_rule.rules.parent_mode).toBe('paragraph') - expect(params!.process_rule.rules.subchunk_segmentation).toBeDefined() - }) - }) -}) diff --git a/web/__tests__/datasets/dataset-settings-flow.test.tsx b/web/__tests__/datasets/dataset-settings-flow.test.tsx deleted file mode 100644 index b2a711985a2..00000000000 --- a/web/__tests__/datasets/dataset-settings-flow.test.tsx +++ /dev/null @@ -1,488 +0,0 @@ -/** - * Integration Test: Dataset Settings Flow - * - * Tests cross-module data contracts in the dataset settings form: - * useFormState hook โ†” index method config โ†” retrieval config โ†” permission state. - * - * The unit-level use-form-state.spec.ts validates the hook in isolation. - * This integration test verifies that changing one configuration dimension - * correctly cascades to dependent parts (index method โ†’ retrieval config, - * permission โ†’ member list visibility, embedding model โ†’ embedding available state). - */ - -import type { DataSet } from '@/models/datasets' -import type { RetrievalConfig } from '@/types/app' -import { act, renderHook, waitFor } from '@testing-library/react' -import { IndexingType } from '@/app/components/datasets/create/step-two' -import { - ChunkingMode, - DatasetPermission, - DataSourceType, - WeightedScoreEnum, -} from '@/models/datasets' -import { RETRIEVE_METHOD } from '@/types/app' -import { DatasetACLPermission } from '@/utils/permission' - -// --- Mocks --- - -const { mockToastError } = vi.hoisted(() => ({ - mockToastError: vi.fn(), -})) - -const mockMutateDatasets = vi.fn() -const mockInvalidDatasetList = vi.fn() -const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({}) - -vi.mock('@/service/datasets', () => ({ - updateDatasetSetting: (...args: unknown[]) => mockUpdateDatasetSetting(...args), -})) - -vi.mock('@/service/knowledge/use-dataset', () => ({ - useInvalidDatasetList: () => mockInvalidDatasetList, -})) - -vi.mock('@/service/use-common', () => ({ - useMembers: () => ({ - data: { - accounts: [ - { - id: 'user-1', - name: 'Alice', - email: 'alice@example.com', - role: 'owner', - avatar: '', - avatar_url: '', - last_login_at: '', - created_at: '', - status: 'active', - }, - { - id: 'user-2', - name: 'Bob', - email: 'bob@example.com', - role: 'admin', - avatar: '', - avatar_url: '', - last_login_at: '', - created_at: '', - status: 'active', - }, - { - id: 'user-3', - name: 'Charlie', - email: 'charlie@example.com', - role: 'normal', - avatar: '', - avatar_url: '', - last_login_at: '', - created_at: '', - status: 'active', - }, - ], - }, - }), -})) - -vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ - useModelList: () => ({ data: [] }), -})) - -vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ - isReRankModelSelected: () => true, -})) - -vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: { - error: mockToastError, - success: vi.fn(), - }, -})) - -// --- Dataset factory --- - -const createMockDataset = (overrides?: Partial): DataSet => - ({ - id: 'ds-settings-1', - name: 'Settings Test Dataset', - description: 'Integration test dataset', - permission: DatasetPermission.onlyMe, - icon_info: { - icon_type: 'emoji', - icon: '๐Ÿ“™', - icon_background: '#FFF4ED', - icon_url: '', - }, - indexing_technique: 'high_quality', - indexing_status: 'completed', - data_source_type: DataSourceType.FILE, - doc_form: ChunkingMode.text, - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - embedding_available: true, - app_count: 2, - document_count: 10, - total_document_count: 10, - word_count: 5000, - provider: 'vendor', - tags: [], - partial_member_list: [], - external_knowledge_info: { - external_knowledge_id: '', - external_knowledge_api_id: '', - external_knowledge_api_name: '', - external_knowledge_api_endpoint: '', - }, - external_retrieval_model: { - top_k: 2, - score_threshold: 0.5, - score_threshold_enabled: false, - }, - retrieval_model_dict: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0, - } as RetrievalConfig, - retrieval_model: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0, - } as RetrievalConfig, - built_in_field_enabled: false, - keyword_number: 10, - created_by: 'user-1', - updated_by: 'user-1', - updated_at: Date.now(), - runtime_mode: 'general', - enable_api: true, - is_multimodal: false, - permission_keys: [DatasetACLPermission.Edit], - ...overrides, - }) as DataSet - -let mockDataset: DataSet = createMockDataset() - -vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: ( - selector: (state: { dataset: DataSet | null; mutateDatasetRes: () => void }) => unknown, - ) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }), -})) - -// Import after mocks are registered -const { useFormState } = - await import('@/app/components/datasets/settings/form/hooks/use-form-state') - -describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUpdateDatasetSetting.mockResolvedValue({}) - mockDataset = createMockDataset() - }) - - describe('Form State Initialization from Dataset โ†’ Index Method โ†’ Retrieval Config Chain', () => { - it('should initialise all form dimensions from a QUALIFIED dataset', () => { - const { result } = renderHook(() => useFormState()) - - expect(result.current.name).toBe('Settings Test Dataset') - expect(result.current.description).toBe('Integration test dataset') - expect(result.current.indexMethod).toBe('high_quality') - expect(result.current.embeddingModel).toEqual({ - provider: 'openai', - model: 'text-embedding-ada-002', - }) - expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic) - }) - - it('should initialise from an ECONOMICAL dataset with keyword retrieval', () => { - mockDataset = createMockDataset({ - indexing_technique: IndexingType.ECONOMICAL, - embedding_model: '', - embedding_model_provider: '', - retrieval_model_dict: { - search_method: RETRIEVE_METHOD.keywordSearch, - reranking_enable: false, - reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, - top_k: 5, - score_threshold_enabled: false, - score_threshold: 0, - } as RetrievalConfig, - }) - - const { result } = renderHook(() => useFormState()) - - expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) - expect(result.current.embeddingModel).toEqual({ provider: '', model: '' }) - expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch) - }) - }) - - describe('Index Method Change โ†’ Retrieval Config Sync', () => { - it('should allow switching index method from QUALIFIED to ECONOMICAL', () => { - const { result } = renderHook(() => useFormState()) - - expect(result.current.indexMethod).toBe('high_quality') - - act(() => { - result.current.setIndexMethod(IndexingType.ECONOMICAL) - }) - - expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) - }) - - it('should allow updating retrieval config after index method switch', () => { - const { result } = renderHook(() => useFormState()) - - act(() => { - result.current.setIndexMethod(IndexingType.ECONOMICAL) - }) - - act(() => { - result.current.setRetrievalConfig({ - ...result.current.retrievalConfig, - search_method: RETRIEVE_METHOD.keywordSearch, - reranking_enable: false, - }) - }) - - expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) - expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch) - expect(result.current.retrievalConfig.reranking_enable).toBe(false) - }) - - it('should preserve retrieval config when switching back to QUALIFIED', () => { - const { result } = renderHook(() => useFormState()) - - const originalConfig = { ...result.current.retrievalConfig } - - act(() => { - result.current.setIndexMethod(IndexingType.ECONOMICAL) - }) - act(() => { - result.current.setIndexMethod(IndexingType.QUALIFIED) - }) - - expect(result.current.indexMethod).toBe('high_quality') - expect(result.current.retrievalConfig.search_method).toBe(originalConfig.search_method) - }) - }) - - describe('Permission Change โ†’ Member List Visibility Logic', () => { - it('should start with onlyMe permission and empty member selection', () => { - const { result } = renderHook(() => useFormState()) - - expect(result.current.permission).toBe(DatasetPermission.onlyMe) - expect(result.current.selectedMemberIDs).toEqual([]) - }) - - it('should enable member selection when switching to partialMembers', () => { - const { result } = renderHook(() => useFormState()) - - act(() => { - result.current.setPermission(DatasetPermission.partialMembers) - }) - - expect(result.current.permission).toBe(DatasetPermission.partialMembers) - expect(result.current.memberList).toHaveLength(3) - expect(result.current.memberList.map((m) => m.id)).toEqual(['user-1', 'user-2', 'user-3']) - }) - - it('should persist member selection through permission toggle', () => { - const { result } = renderHook(() => useFormState()) - - act(() => { - result.current.setPermission(DatasetPermission.partialMembers) - result.current.setSelectedMemberIDs(['user-1', 'user-3']) - }) - - act(() => { - result.current.setPermission(DatasetPermission.allTeamMembers) - }) - - act(() => { - result.current.setPermission(DatasetPermission.partialMembers) - }) - - expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-3']) - }) - - it('should include partial_member_list in save payload only for partialMembers', async () => { - const { result } = renderHook(() => useFormState()) - - act(() => { - result.current.setPermission(DatasetPermission.partialMembers) - result.current.setSelectedMemberIDs(['user-2']) - }) - - await act(async () => { - await result.current.handleSave() - }) - - expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ - datasetId: 'ds-settings-1', - body: expect.objectContaining({ - permission: DatasetPermission.partialMembers, - partial_member_list: [expect.objectContaining({ user_id: 'user-2', role: 'admin' })], - }), - }) - }) - - it('should not include partial_member_list for allTeamMembers permission', async () => { - const { result } = renderHook(() => useFormState()) - - act(() => { - result.current.setPermission(DatasetPermission.allTeamMembers) - }) - - await act(async () => { - await result.current.handleSave() - }) - - const savedBody = mockUpdateDatasetSetting.mock.calls[0]![0].body as Record - expect(savedBody).not.toHaveProperty('partial_member_list') - }) - }) - - describe('Form Submission Validation โ†’ All Fields Together', () => { - it('should reject empty name on save', async () => { - const { toast } = await import('@langgenius/dify-ui/toast') - const { result } = renderHook(() => useFormState()) - - act(() => { - result.current.setName('') - }) - - await act(async () => { - await result.current.handleSave() - }) - - expect(toast.error).toHaveBeenCalledWith(expect.any(String)) - expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() - }) - - it('should include all configuration dimensions in a successful save', async () => { - const { result } = renderHook(() => useFormState()) - - act(() => { - result.current.setName('Updated Name') - result.current.setDescription('Updated Description') - result.current.setIndexMethod(IndexingType.ECONOMICAL) - result.current.setKeywordNumber(15) - }) - - await act(async () => { - await result.current.handleSave() - }) - - expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ - datasetId: 'ds-settings-1', - body: expect.objectContaining({ - name: 'Updated Name', - description: 'Updated Description', - indexing_technique: 'economy', - keyword_number: 15, - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - }), - }) - }) - - it('should call mutateDatasets and invalidDatasetList after successful save', async () => { - const { result } = renderHook(() => useFormState()) - - await act(async () => { - await result.current.handleSave() - }) - - await waitFor(() => { - expect(mockMutateDatasets).toHaveBeenCalled() - expect(mockInvalidDatasetList).toHaveBeenCalled() - }) - }) - }) - - describe('Embedding Model Change โ†’ Retrieval Config Cascade', () => { - it('should update embedding model independently of retrieval config', () => { - const { result } = renderHook(() => useFormState()) - - const originalRetrievalConfig = { ...result.current.retrievalConfig } - - act(() => { - result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-english-v3.0' }) - }) - - expect(result.current.embeddingModel).toEqual({ - provider: 'cohere', - model: 'embed-english-v3.0', - }) - expect(result.current.retrievalConfig.search_method).toBe( - originalRetrievalConfig.search_method, - ) - }) - - it('should propagate embedding model into weighted retrieval config on save', async () => { - const { result } = renderHook(() => useFormState()) - - act(() => { - result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' }) - result.current.setRetrievalConfig({ - ...result.current.retrievalConfig, - search_method: RETRIEVE_METHOD.hybrid, - weights: { - weight_type: WeightedScoreEnum.Customized, - vector_setting: { - vector_weight: 0.6, - embedding_provider_name: '', - embedding_model_name: '', - }, - keyword_setting: { keyword_weight: 0.4 }, - }, - }) - }) - - await act(async () => { - await result.current.handleSave() - }) - - expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ - datasetId: 'ds-settings-1', - body: expect.objectContaining({ - embedding_model: 'embed-v3', - embedding_model_provider: 'cohere', - retrieval_model: expect.objectContaining({ - weights: expect.objectContaining({ - vector_setting: expect.objectContaining({ - embedding_provider_name: 'cohere', - embedding_model_name: 'embed-v3', - }), - }), - }), - }), - }) - }) - - it('should handle switching from semantic to hybrid search with embedding model', () => { - const { result } = renderHook(() => useFormState()) - - act(() => { - result.current.setRetrievalConfig({ - ...result.current.retrievalConfig, - search_method: RETRIEVE_METHOD.hybrid, - reranking_enable: true, - reranking_model: { - reranking_provider_name: 'cohere', - reranking_model_name: 'rerank-english-v3.0', - }, - }) - }) - - expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid) - expect(result.current.retrievalConfig.reranking_enable).toBe(true) - expect(result.current.embeddingModel.model).toBe('text-embedding-ada-002') - }) - }) -}) diff --git a/web/__tests__/datasets/document-management.test.tsx b/web/__tests__/datasets/document-management.test.tsx deleted file mode 100644 index 655607141e3..00000000000 --- a/web/__tests__/datasets/document-management.test.tsx +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Integration Test: Document Management Flow - * - * Tests cross-module interactions: query state (URL-based) โ†’ document list sorting โ†’ - * document selection โ†’ status filter utilities. - * Validates the data contract between documents page hooks and list component hooks. - */ - -import type { SimpleDocumentDetail } from '@/models/datasets' -import { act, renderHook, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { DataSourceType } from '@/models/datasets' -import { renderHookWithNuqs } from '@/test/nuqs-testing' - -const mockPush = vi.fn() -vi.mock('@/next/navigation', () => ({ - useSearchParams: () => new URLSearchParams(''), - useRouter: () => ({ push: mockPush }), - usePathname: () => '/datasets/ds-1/documents', -})) - -const { sanitizeStatusValue, normalizeStatusForQuery } = - await import('@/app/components/datasets/documents/status-filter') - -const { useDocumentSort } = - await import('@/app/components/datasets/documents/components/document-list/hooks/use-document-sort') -const { useDocumentSelection } = - await import('@/app/components/datasets/documents/components/document-list/hooks/use-document-selection') -const { useDocumentListQueryState } = - await import('@/app/components/datasets/documents/hooks/use-document-list-query-state') - -type LocalDoc = SimpleDocumentDetail & { percent?: number } - -const renderQueryStateHook = (searchParams = '') => { - return renderHookWithNuqs(() => useDocumentListQueryState(), { searchParams }) -} - -const createDoc = (overrides?: Partial): LocalDoc => - ({ - id: `doc-${Math.random().toString(36).slice(2, 8)}`, - name: 'test-doc.txt', - word_count: 500, - hit_count: 10, - created_at: Date.now() / 1000, - data_source_type: DataSourceType.FILE, - display_status: 'available', - indexing_status: 'completed', - enabled: true, - archived: false, - doc_type: null, - doc_metadata: null, - position: 1, - dataset_process_rule_id: 'rule-1', - ...overrides, - }) as LocalDoc - -describe('Document Management Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Status Filter Utilities', () => { - it('should sanitize valid status values', () => { - expect(sanitizeStatusValue('all')).toBe('all') - expect(sanitizeStatusValue('available')).toBe('available') - expect(sanitizeStatusValue('error')).toBe('error') - }) - - it('should fallback to "all" for invalid values', () => { - expect(sanitizeStatusValue(null)).toBe('all') - expect(sanitizeStatusValue(undefined)).toBe('all') - expect(sanitizeStatusValue('')).toBe('all') - expect(sanitizeStatusValue('nonexistent')).toBe('all') - }) - - it('should handle URL aliases', () => { - // 'active' is aliased to 'available' - expect(sanitizeStatusValue('active')).toBe('available') - }) - - it('should normalize status for API query', () => { - expect(normalizeStatusForQuery('all')).toBe('all') - // 'enabled' normalized to 'available' for query - expect(normalizeStatusForQuery('enabled')).toBe('available') - }) - }) - - describe('URL-based Query State', () => { - it('should parse default query from empty URL params', () => { - const { result } = renderQueryStateHook() - - expect(result.current.query).toEqual({ - page: 1, - limit: 10, - keyword: '', - status: 'all', - sort: '-created_at', - }) - }) - - it('should update keyword query with replace history', async () => { - const { result, onUrlUpdate } = renderQueryStateHook() - - act(() => { - result.current.updateQuery({ keyword: 'test', page: 2 }) - }) - - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(update.options.history).toBe('replace') - expect(update.searchParams.get('keyword')).toBe('test') - expect(update.searchParams.get('page')).toBe('2') - }) - - it('should reset query to defaults', async () => { - const { result, onUrlUpdate } = renderQueryStateHook() - - act(() => { - result.current.resetQuery() - }) - - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(update.options.history).toBe('replace') - expect(update.searchParams.toString()).toBe('') - }) - }) - - describe('Document Sort Integration', () => { - it('should derive sort field and order from remote sort value', () => { - const { result } = renderHook(() => - useDocumentSort({ - remoteSortValue: '-created_at', - onRemoteSortChange: vi.fn(), - }), - ) - - expect(result.current.sortField).toBe('created_at') - expect(result.current.sortOrder).toBe('desc') - }) - - it('should call remote sort change with descending sort for a new field', () => { - const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => - useDocumentSort({ - remoteSortValue: '-created_at', - onRemoteSortChange, - }), - ) - - act(() => { - result.current.handleSort('hit_count') - }) - - expect(onRemoteSortChange).toHaveBeenCalledWith('-hit_count') - }) - - it('should toggle descending to ascending when clicking active field', () => { - const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => - useDocumentSort({ - remoteSortValue: '-hit_count', - onRemoteSortChange, - }), - ) - - act(() => { - result.current.handleSort('hit_count') - }) - - expect(onRemoteSortChange).toHaveBeenCalledWith('hit_count') - }) - - it('should ignore null sort field updates', () => { - const onRemoteSortChange = vi.fn() - const { result } = renderHook(() => - useDocumentSort({ - remoteSortValue: '-created_at', - onRemoteSortChange, - }), - ) - - act(() => { - result.current.handleSort(null) - }) - - expect(onRemoteSortChange).not.toHaveBeenCalled() - }) - }) - - describe('Document Selection Integration', () => { - it('should keep checkbox selection state owned outside the hook', () => { - const docs = [ - createDoc({ id: 'doc-1' }), - createDoc({ id: 'doc-2' }), - createDoc({ id: 'doc-3' }), - ] - const onSelectedIdChange = vi.fn() - - const { result } = renderHook(() => - useDocumentSelection({ - documents: docs, - selectedIds: [], - onSelectedIdChange, - }), - ) - - expect(result.current.downloadableSelectedIds).toEqual([]) - expect(result.current.hasErrorDocumentsSelected).toBe(false) - expect(onSelectedIdChange).not.toHaveBeenCalled() - }) - - it('should identify downloadable selected documents (FILE type only)', () => { - const docs = [ - createDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }), - createDoc({ id: 'doc-2', data_source_type: DataSourceType.NOTION }), - ] - - const { result } = renderHook(() => - useDocumentSelection({ - documents: docs, - selectedIds: ['doc-1', 'doc-2'], - onSelectedIdChange: vi.fn(), - }), - ) - - expect(result.current.downloadableSelectedIds).toEqual(['doc-1']) - }) - - it('should clear selection', () => { - const onSelectedIdChange = vi.fn() - const docs = [createDoc({ id: 'doc-1' })] - - const { result } = renderHook(() => - useDocumentSelection({ - documents: docs, - selectedIds: ['doc-1'], - onSelectedIdChange, - }), - ) - - act(() => { - result.current.clearSelection() - }) - - expect(onSelectedIdChange).toHaveBeenCalledWith([]) - }) - }) - - describe('Cross-Module: Query State โ†’ Sort โ†’ Selection Pipeline', () => { - it('should maintain consistent default state across all hooks', () => { - const docs = [createDoc({ id: 'doc-1' })] - const { result: queryResult } = renderQueryStateHook() - const { result: sortResult } = renderHook(() => - useDocumentSort({ - remoteSortValue: queryResult.current.query.sort, - onRemoteSortChange: vi.fn(), - }), - ) - const { result: selResult } = renderHook(() => - useDocumentSelection({ - documents: docs, - selectedIds: [], - onSelectedIdChange: vi.fn(), - }), - ) - - // Query defaults - expect(queryResult.current.query.sort).toBe('-created_at') - expect(queryResult.current.query.status).toBe('all') - - // Sort state is derived from URL default sort. - expect(sortResult.current.sortField).toBe('created_at') - expect(sortResult.current.sortOrder).toBe('desc') - - // Selection-derived batch metadata starts empty. - expect(selResult.current.downloadableSelectedIds).toEqual([]) - expect(selResult.current.hasErrorDocumentsSelected).toBe(false) - }) - }) -}) diff --git a/web/__tests__/datasets/external-knowledge-base.test.tsx b/web/__tests__/datasets/external-knowledge-base.test.tsx deleted file mode 100644 index 03bd9b6ee70..00000000000 --- a/web/__tests__/datasets/external-knowledge-base.test.tsx +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Integration Test: External Knowledge Base Creation Flow - * - * Tests the data contract, validation logic, and API interaction - * for external knowledge base creation. - */ - -import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations' -import { describe, expect, it } from 'vitest' - -// --- Factory --- -const createFormData = (overrides?: Partial): CreateKnowledgeBaseReq => ({ - name: 'My External KB', - description: 'A test external knowledge base', - external_knowledge_api_id: 'api-1', - external_knowledge_id: 'ext-kb-123', - external_retrieval_model: { - top_k: 4, - score_threshold: 0.5, - score_threshold_enabled: false, - }, - provider: 'external', - ...overrides, -}) - -describe('External Knowledge Base Creation Flow', () => { - describe('Data Contract: CreateKnowledgeBaseReq', () => { - it('should define a complete form structure', () => { - const form = createFormData() - - expect(form).toHaveProperty('name') - expect(form).toHaveProperty('external_knowledge_api_id') - expect(form).toHaveProperty('external_knowledge_id') - expect(form).toHaveProperty('external_retrieval_model') - expect(form).toHaveProperty('provider') - expect(form.provider).toBe('external') - }) - - it('should include retrieval model settings', () => { - const form = createFormData() - - expect(form.external_retrieval_model).toEqual({ - top_k: 4, - score_threshold: 0.5, - score_threshold_enabled: false, - }) - }) - - it('should allow partial overrides', () => { - const form = createFormData({ - name: 'Custom Name', - external_retrieval_model: { - top_k: 10, - score_threshold: 0.8, - score_threshold_enabled: true, - }, - }) - - expect(form.name).toBe('Custom Name') - expect(form.external_retrieval_model.top_k).toBe(10) - expect(form.external_retrieval_model.score_threshold_enabled).toBe(true) - }) - }) - - describe('Form Validation Logic', () => { - const isFormValid = (form: CreateKnowledgeBaseReq): boolean => { - return ( - form.name.trim() !== '' && - form.external_knowledge_api_id !== '' && - form.external_knowledge_id !== '' && - form.external_retrieval_model.top_k !== undefined && - form.external_retrieval_model.score_threshold !== undefined - ) - } - - it('should validate a complete form', () => { - const form = createFormData() - expect(isFormValid(form)).toBe(true) - }) - - it('should reject empty name', () => { - const form = createFormData({ name: '' }) - expect(isFormValid(form)).toBe(false) - }) - - it('should reject whitespace-only name', () => { - const form = createFormData({ name: ' ' }) - expect(isFormValid(form)).toBe(false) - }) - - it('should reject empty external_knowledge_api_id', () => { - const form = createFormData({ external_knowledge_api_id: '' }) - expect(isFormValid(form)).toBe(false) - }) - - it('should reject empty external_knowledge_id', () => { - const form = createFormData({ external_knowledge_id: '' }) - expect(isFormValid(form)).toBe(false) - }) - }) - - describe('Form State Transitions', () => { - it('should start with empty default state', () => { - const defaultForm: CreateKnowledgeBaseReq = { - name: '', - description: '', - external_knowledge_api_id: '', - external_knowledge_id: '', - external_retrieval_model: { - top_k: 4, - score_threshold: 0.5, - score_threshold_enabled: false, - }, - provider: 'external', - } - - // Verify default state matches component's initial useState - expect(defaultForm.name).toBe('') - expect(defaultForm.external_knowledge_api_id).toBe('') - expect(defaultForm.external_knowledge_id).toBe('') - expect(defaultForm.provider).toBe('external') - }) - - it('should support immutable form updates', () => { - const form = createFormData({ name: '' }) - const updated = { ...form, name: 'Updated Name' } - - expect(form.name).toBe('') - expect(updated.name).toBe('Updated Name') - // Other fields should remain unchanged - expect(updated.external_knowledge_api_id).toBe(form.external_knowledge_api_id) - }) - - it('should support retrieval model updates', () => { - const form = createFormData() - const updated = { - ...form, - external_retrieval_model: { - ...form.external_retrieval_model, - top_k: 10, - score_threshold_enabled: true, - }, - } - - expect(updated.external_retrieval_model.top_k).toBe(10) - expect(updated.external_retrieval_model.score_threshold_enabled).toBe(true) - // Unchanged field - expect(updated.external_retrieval_model.score_threshold).toBe(0.5) - }) - }) - - describe('API Call Data Contract', () => { - it('should produce a valid API payload from form data', () => { - const form = createFormData() - - // The API expects the full CreateKnowledgeBaseReq - expect(form.name).toBeTruthy() - expect(form.external_knowledge_api_id).toBeTruthy() - expect(form.external_knowledge_id).toBeTruthy() - expect(form.provider).toBe('external') - expect(typeof form.external_retrieval_model.top_k).toBe('number') - expect(typeof form.external_retrieval_model.score_threshold).toBe('number') - expect(typeof form.external_retrieval_model.score_threshold_enabled).toBe('boolean') - }) - - it('should support optional description', () => { - const formWithDesc = createFormData({ description: 'Some description' }) - const formWithoutDesc = createFormData({ description: '' }) - - expect(formWithDesc.description).toBe('Some description') - expect(formWithoutDesc.description).toBe('') - }) - - it('should validate retrieval model bounds', () => { - const form = createFormData({ - external_retrieval_model: { - top_k: 0, - score_threshold: 0, - score_threshold_enabled: false, - }, - }) - - expect(form.external_retrieval_model.top_k).toBe(0) - expect(form.external_retrieval_model.score_threshold).toBe(0) - }) - }) - - describe('External API List Integration', () => { - it('should validate API item structure', () => { - const apiItem = { - id: 'api-1', - name: 'Production API', - settings: { - endpoint: 'https://api.example.com', - api_key: 'key-123', - }, - } - - expect(apiItem).toHaveProperty('id') - expect(apiItem).toHaveProperty('name') - expect(apiItem).toHaveProperty('settings') - expect(apiItem.settings).toHaveProperty('endpoint') - expect(apiItem.settings).toHaveProperty('api_key') - }) - - it('should link API selection to form data', () => { - const selectedApi = { id: 'api-2', name: 'Staging API' } - const form = createFormData({ - external_knowledge_api_id: selectedApi.id, - }) - - expect(form.external_knowledge_api_id).toBe('api-2') - }) - }) -}) diff --git a/web/__tests__/datasets/hit-testing-flow.test.tsx b/web/__tests__/datasets/hit-testing-flow.test.tsx deleted file mode 100644 index d2439304c6d..00000000000 --- a/web/__tests__/datasets/hit-testing-flow.test.tsx +++ /dev/null @@ -1,433 +0,0 @@ -/** - * Integration Test: Hit Testing Flow - * - * Tests the query submission โ†’ API response โ†’ callback chain flow - * by rendering the actual QueryInput component and triggering user interactions. - * Validates that the production onSubmit logic correctly constructs payloads - * and invokes callbacks on success/failure. - */ - -import type { HitTestingResponse, Query } from '@/models/datasets' -import type { RetrievalConfig } from '@/types/app' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import QueryInput from '@/app/components/datasets/hit-testing/components/query-input' -import { RETRIEVE_METHOD } from '@/types/app' - -// --- Mocks --- - -vi.mock('@/context/dataset-detail', () => ({ - default: {}, - useDatasetDetailContext: vi.fn(() => ({ dataset: undefined })), - useDatasetDetailContextWithSelector: vi.fn(() => false), -})) - -vi.mock('use-context-selector', () => ({ - useContext: vi.fn(() => ({})), - useContextSelector: vi.fn(() => false), - createContext: vi.fn(() => ({})), -})) - -vi.mock( - '@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', - () => ({ - default: ({ - textArea, - actionButton, - }: { - textArea: React.ReactNode - actionButton: React.ReactNode - }) => ( -
- {textArea} - {actionButton} -
- ), - }), -) - -// --- Factories --- - -const createRetrievalConfig = (overrides = {}): RetrievalConfig => - ({ - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_mode: undefined, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - weights: undefined, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - ...overrides, - }) as RetrievalConfig - -const createHitTestingResponse = (numResults: number): HitTestingResponse => ({ - query: { - content: 'What is Dify?', - tsne_position: { x: 0, y: 0 }, - }, - records: Array.from({ length: numResults }, (_, i) => ({ - segment: { - id: `seg-${i}`, - document: { - id: `doc-${i}`, - data_source_type: 'upload_file', - name: `document-${i}.txt`, - doc_type: null as unknown as import('@/models/datasets').DocType, - }, - content: `Result content ${i}`, - sign_content: `Result content ${i}`, - position: i + 1, - word_count: 100 + i * 50, - tokens: 50 + i * 25, - keywords: ['test', 'dify'], - hit_count: i * 5, - index_node_hash: `hash-${i}`, - answer: '', - }, - content: { - id: `seg-${i}`, - document: { - id: `doc-${i}`, - data_source_type: 'upload_file', - name: `document-${i}.txt`, - doc_type: null as unknown as import('@/models/datasets').DocType, - }, - content: `Result content ${i}`, - sign_content: `Result content ${i}`, - position: i + 1, - word_count: 100 + i * 50, - tokens: 50 + i * 25, - keywords: ['test', 'dify'], - hit_count: i * 5, - index_node_hash: `hash-${i}`, - answer: '', - }, - score: 0.95 - i * 0.1, - tsne_position: { x: 0, y: 0 }, - child_chunks: null, - files: [], - })), -}) - -const createTextQuery = (content: string): Query[] => [ - { content, content_type: 'text_query', file_info: null }, -] - -// --- Helpers --- - -const findSubmitButton = () => { - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find((btn) => btn.classList.contains('w-[88px]')) - expect(submitButton).toBeTruthy() - return submitButton! -} - -// --- Tests --- - -describe('Hit Testing Flow', () => { - const mockHitTestingMutation = vi.fn() - const mockExternalMutation = vi.fn() - const mockSetHitResult = vi.fn() - const mockSetExternalHitResult = vi.fn() - const mockOnUpdateList = vi.fn() - const mockSetQueries = vi.fn() - const mockOnClickRetrievalMethod = vi.fn() - const mockOnSubmit = vi.fn() - - const createDefaultProps = (overrides: Record = {}) => ({ - onUpdateList: mockOnUpdateList, - setHitResult: mockSetHitResult, - setExternalHitResult: mockSetExternalHitResult, - loading: false, - queries: [] as Query[], - setQueries: mockSetQueries, - isExternal: false, - onClickRetrievalMethod: mockOnClickRetrievalMethod, - retrievalConfig: createRetrievalConfig(), - isEconomy: false, - onSubmit: mockOnSubmit, - hitTestingMutation: mockHitTestingMutation, - externalKnowledgeBaseHitTestingMutation: mockExternalMutation, - ...overrides, - }) - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Query Submission โ†’ API Call', () => { - it('should call hitTestingMutation with correct payload including retrieval model', async () => { - const retrievalConfig = createRetrievalConfig({ - search_method: RETRIEVE_METHOD.semantic, - top_k: 3, - score_threshold_enabled: false, - }) - mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(3)) - - render( - , - ) - - fireEvent.click(findSubmitButton()) - - await waitFor(() => { - expect(mockHitTestingMutation).toHaveBeenCalledWith( - expect.objectContaining({ - query: 'How does RAG work?', - attachment_ids: [], - retrieval_model: expect.objectContaining({ - search_method: RETRIEVE_METHOD.semantic, - top_k: 3, - score_threshold_enabled: false, - }), - }), - expect.objectContaining({ - onSuccess: expect.any(Function), - }), - ) - }) - }) - - it('should override search_method to keywordSearch when isEconomy is true', async () => { - const retrievalConfig = createRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }) - mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(1)) - - render( - , - ) - - fireEvent.click(findSubmitButton()) - - await waitFor(() => { - expect(mockHitTestingMutation).toHaveBeenCalledWith( - expect.objectContaining({ - retrieval_model: expect.objectContaining({ - search_method: RETRIEVE_METHOD.keywordSearch, - }), - }), - expect.anything(), - ) - }) - }) - - it('should handle empty results by calling setHitResult with empty records', async () => { - const emptyResponse = createHitTestingResponse(0) - mockHitTestingMutation.mockImplementation( - async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { - options?.onSuccess?.(emptyResponse) - return emptyResponse - }, - ) - - render( - , - ) - - fireEvent.click(findSubmitButton()) - - await waitFor(() => { - expect(mockSetHitResult).toHaveBeenCalledWith(expect.objectContaining({ records: [] })) - }) - }) - - it('should not call success callbacks when mutation resolves without onSuccess', async () => { - // Simulate a mutation that resolves but does not invoke the onSuccess callback - mockHitTestingMutation.mockResolvedValue(undefined) - - render( - , - ) - - fireEvent.click(findSubmitButton()) - - await waitFor(() => { - expect(mockHitTestingMutation).toHaveBeenCalled() - }) - // Success callbacks should not fire when onSuccess is not invoked - expect(mockSetHitResult).not.toHaveBeenCalled() - expect(mockOnUpdateList).not.toHaveBeenCalled() - expect(mockOnSubmit).not.toHaveBeenCalled() - }) - }) - - describe('API Response โ†’ Results Data Contract', () => { - it('should produce results with required segment fields for rendering', () => { - const response = createHitTestingResponse(3) - - // Validate each result has the fields needed by ResultItem component - response.records.forEach((record) => { - expect(record.segment).toHaveProperty('id') - expect(record.segment).toHaveProperty('content') - expect(record.segment).toHaveProperty('position') - expect(record.segment).toHaveProperty('word_count') - expect(record.segment).toHaveProperty('document') - expect(record.segment.document).toHaveProperty('name') - expect(record.score).toBeGreaterThanOrEqual(0) - expect(record.score).toBeLessThanOrEqual(1) - }) - }) - - it('should maintain correct score ordering', () => { - const response = createHitTestingResponse(5) - - for (let i = 1; i < response.records.length; i++) { - expect(response.records[i - 1]!.score).toBeGreaterThanOrEqual(response.records[i]!.score) - } - }) - - it('should include document metadata for result item display', () => { - const response = createHitTestingResponse(1) - const record = response.records[0] - - expect(record!.segment.document.name).toBeTruthy() - expect(record!.segment.document.data_source_type).toBeTruthy() - }) - }) - - describe('Successful Submission โ†’ Callback Chain', () => { - it('should call setHitResult, onUpdateList, and onSubmit after successful submission', async () => { - const response = createHitTestingResponse(3) - mockHitTestingMutation.mockImplementation( - async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { - options?.onSuccess?.(response) - return response - }, - ) - - render( - , - ) - - fireEvent.click(findSubmitButton()) - - await waitFor(() => { - expect(mockSetHitResult).toHaveBeenCalledWith(response) - expect(mockOnUpdateList).toHaveBeenCalledTimes(1) - expect(mockOnSubmit).toHaveBeenCalledTimes(1) - }) - }) - - it('should trigger records list refresh via onUpdateList after query', async () => { - const response = createHitTestingResponse(1) - mockHitTestingMutation.mockImplementation( - async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { - options?.onSuccess?.(response) - return response - }, - ) - - render( - , - ) - - fireEvent.click(findSubmitButton()) - - await waitFor(() => { - expect(mockOnUpdateList).toHaveBeenCalledTimes(1) - }) - }) - }) - - describe('External KB Hit Testing', () => { - it('should use external mutation with correct payload for external datasets', async () => { - mockExternalMutation.mockImplementation( - async ( - _params: unknown, - options?: { onSuccess?: (data: { records: never[] }) => void }, - ) => { - const response = { records: [] } - options?.onSuccess?.(response) - return response - }, - ) - - render( - , - ) - - fireEvent.click(findSubmitButton()) - - await waitFor(() => { - expect(mockExternalMutation).toHaveBeenCalledWith( - expect.objectContaining({ - query: 'test', - external_retrieval_model: expect.objectContaining({ - top_k: 4, - score_threshold: 0.5, - score_threshold_enabled: false, - }), - }), - expect.objectContaining({ - onSuccess: expect.any(Function), - }), - ) - // Internal mutation should NOT be called - expect(mockHitTestingMutation).not.toHaveBeenCalled() - }) - }) - - it('should call setExternalHitResult and onUpdateList on successful external submission', async () => { - const externalResponse = { records: [] } - mockExternalMutation.mockImplementation( - async ( - _params: unknown, - options?: { onSuccess?: (data: { records: never[] }) => void }, - ) => { - options?.onSuccess?.(externalResponse) - return externalResponse - }, - ) - - render( - , - ) - - fireEvent.click(findSubmitButton()) - - await waitFor(() => { - expect(mockSetExternalHitResult).toHaveBeenCalledWith(externalResponse) - expect(mockOnUpdateList).toHaveBeenCalledTimes(1) - }) - }) - }) -}) diff --git a/web/__tests__/datasets/metadata-management-flow.test.tsx b/web/__tests__/datasets/metadata-management-flow.test.tsx deleted file mode 100644 index 2490592e899..00000000000 --- a/web/__tests__/datasets/metadata-management-flow.test.tsx +++ /dev/null @@ -1,320 +0,0 @@ -/** - * Integration Test: Metadata Management Flow - * - * Tests the cross-module composition of metadata name validation, type constraints, - * and duplicate detection across the metadata management hooks. - * - * The unit-level use-check-metadata-name.spec.ts tests the validation hook alone. - * This integration test verifies: - * - Name validation combined with existing metadata list (duplicate detection) - * - Metadata type enum constraints matching expected data model - * - Full add/rename workflow: validate name โ†’ check duplicates โ†’ allow or reject - * - Name uniqueness logic: existing metadata keeps its own name, cannot take another's - */ - -import type { MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types' -import { renderHook } from '@testing-library/react' -import { DataType } from '@/app/components/datasets/metadata/types' - -const { default: useCheckMetadataName } = - await import('@/app/components/datasets/metadata/hooks/use-check-metadata-name') - -// --- Factory functions --- - -const createMetadataItem = ( - id: string, - name: string, - type = DataType.string, - count = 0, -): MetadataItemWithValueLength => ({ - id, - name, - type, - count, -}) - -const createMetadataList = (): MetadataItemWithValueLength[] => [ - createMetadataItem('meta-1', 'author', DataType.string, 5), - createMetadataItem('meta-2', 'created_date', DataType.time, 10), - createMetadataItem('meta-3', 'page_count', DataType.number, 3), - createMetadataItem('meta-4', 'source_url', DataType.string, 8), - createMetadataItem('meta-5', 'version', DataType.number, 2), -] - -describe('Metadata Management Flow - Cross-Module Validation Composition', () => { - describe('Name Validation Flow: Format Rules', () => { - it('should accept valid lowercase names with underscores', () => { - const { result } = renderHook(() => useCheckMetadataName()) - - expect(result.current.checkName('valid_name').errorMsg).toBe('') - expect(result.current.checkName('author').errorMsg).toBe('') - expect(result.current.checkName('page_count').errorMsg).toBe('') - expect(result.current.checkName('v2_field').errorMsg).toBe('') - }) - - it('should reject empty names', () => { - const { result } = renderHook(() => useCheckMetadataName()) - - expect(result.current.checkName('').errorMsg).toBeTruthy() - }) - - it('should reject names with invalid characters', () => { - const { result } = renderHook(() => useCheckMetadataName()) - - expect(result.current.checkName('Author').errorMsg).toBeTruthy() - expect(result.current.checkName('my-field').errorMsg).toBeTruthy() - expect(result.current.checkName('field name').errorMsg).toBeTruthy() - expect(result.current.checkName('1field').errorMsg).toBeTruthy() - expect(result.current.checkName('_private').errorMsg).toBeTruthy() - }) - - it('should reject names exceeding 255 characters', () => { - const { result } = renderHook(() => useCheckMetadataName()) - - const longName = 'a'.repeat(256) - expect(result.current.checkName(longName).errorMsg).toBeTruthy() - - const maxName = 'a'.repeat(255) - expect(result.current.checkName(maxName).errorMsg).toBe('') - }) - }) - - describe('Metadata Type Constraints: Enum Values Match Expected Set', () => { - it('should define exactly three data types', () => { - const typeValues = Object.values(DataType) - expect(typeValues).toHaveLength(3) - }) - - it('should include string, number, and time types', () => { - expect(DataType.string).toBe('string') - expect(DataType.number).toBe('number') - expect(DataType.time).toBe('time') - }) - - it('should use consistent types in metadata items', () => { - const metadataList = createMetadataList() - - const stringItems = metadataList.filter((m) => m.type === DataType.string) - const numberItems = metadataList.filter((m) => m.type === DataType.number) - const timeItems = metadataList.filter((m) => m.type === DataType.time) - - expect(stringItems).toHaveLength(2) - expect(numberItems).toHaveLength(2) - expect(timeItems).toHaveLength(1) - }) - - it('should enforce type-safe metadata item construction', () => { - const item = createMetadataItem('test-1', 'test_field', DataType.number, 0) - - expect(item.id).toBe('test-1') - expect(item.name).toBe('test_field') - expect(item.type).toBe(DataType.number) - expect(item.count).toBe(0) - }) - }) - - describe('Duplicate Name Detection: Add Metadata โ†’ Check Name โ†’ Detect Duplicates', () => { - it('should detect duplicate names against an existing metadata list', () => { - const { result } = renderHook(() => useCheckMetadataName()) - const existingMetadata = createMetadataList() - - const checkDuplicate = (newName: string): boolean => { - const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) return false - return existingMetadata.some((m) => m.name === newName) - } - - expect(checkDuplicate('author')).toBe(true) - expect(checkDuplicate('created_date')).toBe(true) - expect(checkDuplicate('page_count')).toBe(true) - }) - - it('should allow names that do not conflict with existing metadata', () => { - const { result } = renderHook(() => useCheckMetadataName()) - const existingMetadata = createMetadataList() - - const isNameAvailable = (newName: string): boolean => { - const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) return false - return !existingMetadata.some((m) => m.name === newName) - } - - expect(isNameAvailable('category')).toBe(true) - expect(isNameAvailable('file_size')).toBe(true) - expect(isNameAvailable('language')).toBe(true) - }) - - it('should reject names that fail format validation before duplicate check', () => { - const { result } = renderHook(() => useCheckMetadataName()) - - const validateAndCheckDuplicate = (newName: string): { valid: boolean; reason: string } => { - const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) return { valid: false, reason: 'format' } - return { valid: true, reason: '' } - } - - expect(validateAndCheckDuplicate('Author').reason).toBe('format') - expect(validateAndCheckDuplicate('').reason).toBe('format') - expect(validateAndCheckDuplicate('valid_name').valid).toBe(true) - }) - }) - - describe('Name Uniqueness Across Edits: Rename Workflow', () => { - it('should allow an existing metadata item to keep its own name', () => { - const { result } = renderHook(() => useCheckMetadataName()) - const existingMetadata = createMetadataList() - - const isRenameValid = (itemId: string, newName: string): boolean => { - const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) return false - // Allow keeping the same name (skip self in duplicate check) - return !existingMetadata.some((m) => m.name === newName && m.id !== itemId) - } - - // Author keeping its own name should be valid - expect(isRenameValid('meta-1', 'author')).toBe(true) - // page_count keeping its own name should be valid - expect(isRenameValid('meta-3', 'page_count')).toBe(true) - }) - - it('should reject renaming to another existing metadata name', () => { - const { result } = renderHook(() => useCheckMetadataName()) - const existingMetadata = createMetadataList() - - const isRenameValid = (itemId: string, newName: string): boolean => { - const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) return false - return !existingMetadata.some((m) => m.name === newName && m.id !== itemId) - } - - // Author trying to rename to "page_count" (taken by meta-3) - expect(isRenameValid('meta-1', 'page_count')).toBe(false) - // version trying to rename to "source_url" (taken by meta-4) - expect(isRenameValid('meta-5', 'source_url')).toBe(false) - }) - - it('should allow renaming to a completely new valid name', () => { - const { result } = renderHook(() => useCheckMetadataName()) - const existingMetadata = createMetadataList() - - const isRenameValid = (itemId: string, newName: string): boolean => { - const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) return false - return !existingMetadata.some((m) => m.name === newName && m.id !== itemId) - } - - expect(isRenameValid('meta-1', 'document_author')).toBe(true) - expect(isRenameValid('meta-2', 'publish_date')).toBe(true) - expect(isRenameValid('meta-3', 'total_pages')).toBe(true) - }) - - it('should reject renaming with an invalid format even if name is unique', () => { - const { result } = renderHook(() => useCheckMetadataName()) - const existingMetadata = createMetadataList() - - const isRenameValid = (itemId: string, newName: string): boolean => { - const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) return false - return !existingMetadata.some((m) => m.name === newName && m.id !== itemId) - } - - expect(isRenameValid('meta-1', 'New Author')).toBe(false) - expect(isRenameValid('meta-2', '2024_date')).toBe(false) - expect(isRenameValid('meta-3', '')).toBe(false) - }) - }) - - describe('Full Metadata Management Workflow', () => { - it('should support a complete add-validate-check-duplicate cycle', () => { - const { result } = renderHook(() => useCheckMetadataName()) - const existingMetadata = createMetadataList() - - const addMetadataField = ( - name: string, - type: DataType, - ): { success: boolean; error?: string } => { - const formatCheck = result.current.checkName(name) - if (formatCheck.errorMsg) return { success: false, error: 'invalid_format' } - - if (existingMetadata.some((m) => m.name === name)) - return { success: false, error: 'duplicate_name' } - - existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type)) - return { success: true } - } - - // Add a valid new field - const result1 = addMetadataField('department', DataType.string) - expect(result1.success).toBe(true) - expect(existingMetadata).toHaveLength(6) - - // Try to add a duplicate - const result2 = addMetadataField('author', DataType.string) - expect(result2.success).toBe(false) - expect(result2.error).toBe('duplicate_name') - expect(existingMetadata).toHaveLength(6) - - // Try to add an invalid name - const result3 = addMetadataField('Invalid Name', DataType.string) - expect(result3.success).toBe(false) - expect(result3.error).toBe('invalid_format') - expect(existingMetadata).toHaveLength(6) - - // Add another valid field - const result4 = addMetadataField('priority_level', DataType.number) - expect(result4.success).toBe(true) - expect(existingMetadata).toHaveLength(7) - }) - - it('should support a complete rename workflow with validation chain', () => { - const { result } = renderHook(() => useCheckMetadataName()) - const existingMetadata = createMetadataList() - - const renameMetadataField = ( - itemId: string, - newName: string, - ): { success: boolean; error?: string } => { - const formatCheck = result.current.checkName(newName) - if (formatCheck.errorMsg) return { success: false, error: 'invalid_format' } - - if (existingMetadata.some((m) => m.name === newName && m.id !== itemId)) - return { success: false, error: 'duplicate_name' } - - const item = existingMetadata.find((m) => m.id === itemId) - if (!item) return { success: false, error: 'not_found' } - - // Simulate the rename in-place - const index = existingMetadata.indexOf(item) - existingMetadata[index] = { ...item, name: newName } - return { success: true } - } - - // Rename author to document_author - expect(renameMetadataField('meta-1', 'document_author').success).toBe(true) - expect(existingMetadata.find((m) => m.id === 'meta-1')?.name).toBe('document_author') - - // Try renaming created_date to page_count (already taken) - expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name') - - // Rename to invalid format - expect(renameMetadataField('meta-3', 'Page Count').error).toBe('invalid_format') - - // Rename non-existent item - expect(renameMetadataField('meta-999', 'something').error).toBe('not_found') - }) - - it('should maintain validation consistency across multiple operations', () => { - const { result } = renderHook(() => useCheckMetadataName()) - - // Validate the same name multiple times for consistency - const name = 'consistent_field' - const results = Array.from({ length: 5 }, () => result.current.checkName(name)) - - expect(results.every((r) => r.errorMsg === '')).toBe(true) - - // Validate an invalid name multiple times - const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid')) - expect(invalidResults.every((r) => r.errorMsg !== '')).toBe(true) - }) - }) -}) diff --git a/web/__tests__/datasets/pipeline-datasource-flow.test.tsx b/web/__tests__/datasets/pipeline-datasource-flow.test.tsx deleted file mode 100644 index be3ef01fb52..00000000000 --- a/web/__tests__/datasets/pipeline-datasource-flow.test.tsx +++ /dev/null @@ -1,485 +0,0 @@ -/** - * Integration Test: Pipeline Data Source Store Composition - * - * Tests cross-slice interactions in the pipeline data source Zustand store. - * The unit-level slice specs test each slice in isolation. - * This integration test verifies: - * - Store initialization produces correct defaults across all slices - * - Cross-slice coordination (e.g. credential shared across slices) - * - State isolation: changes in one slice do not affect others - * - Full workflow simulation through credential โ†’ source โ†’ data path - */ - -import type { NotionPage } from '@/models/common' -import type { CrawlResultItem, FileItem } from '@/models/datasets' -import type { OnlineDriveFile } from '@/models/pipeline' -import { createDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' -import { CrawlStep } from '@/models/datasets' -import { OnlineDriveFileType } from '@/models/pipeline' - -// --- Factory functions --- - -const createFileItem = (id: string): FileItem => ({ - fileID: id, - file: { id, name: `${id}.txt`, size: 1024 } as FileItem['file'], - progress: 100, -}) - -const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => ({ - title: title ?? `Page: ${url}`, - markdown: `# ${title ?? url}\n\nContent for ${url}`, - description: `Description for ${url}`, - source_url: url, -}) - -const createOnlineDriveFile = ( - id: string, - name: string, - type = OnlineDriveFileType.file, -): OnlineDriveFile => ({ - id, - name, - size: 2048, - type, -}) - -const createNotionPage = (pageId: string): NotionPage => ({ - page_id: pageId, - page_name: `Page ${pageId}`, - page_icon: null, - is_bound: true, - parent_id: 'parent-1', - type: 'page', - workspace_id: 'ws-1', -}) - -describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () => { - describe('Store Initialization โ†’ All Slices Have Correct Defaults', () => { - it('should create a store with all five slices combined', () => { - const store = createDataSourceStore() - const state = store.getState() - - // Common slice defaults - expect(state.currentCredentialId).toBe('') - expect(state.currentNodeIdRef.current).toBe('') - - // Local file slice defaults - expect(state.localFileList).toEqual([]) - expect(state.currentLocalFile).toBeUndefined() - - // Online document slice defaults - expect(state.documentsData).toEqual([]) - expect(state.onlineDocuments).toEqual([]) - expect(state.searchValue).toBe('') - expect(state.selectedPagesId).toEqual(new Set()) - - // Website crawl slice defaults - expect(state.websitePages).toEqual([]) - expect(state.step).toBe(CrawlStep.init) - expect(state.previewIndex).toBe(-1) - - // Online drive slice defaults - expect(state.breadcrumbs).toEqual([]) - expect(state.prefix).toEqual([]) - expect(state.keywords).toBe('') - expect(state.selectedFileIds).toEqual([]) - expect(state.onlineDriveFileList).toEqual([]) - expect(state.bucket).toBe('') - expect(state.hasBucket).toBe(false) - }) - }) - - describe('Cross-Slice Coordination: Shared Credential', () => { - it('should set credential that is accessible from the common slice', () => { - const store = createDataSourceStore() - - store.getState().setCurrentCredentialId('cred-abc-123') - - expect(store.getState().currentCredentialId).toBe('cred-abc-123') - }) - - it('should allow credential update independently of all other slices', () => { - const store = createDataSourceStore() - - store.getState().setLocalFileList([createFileItem('f1')]) - store.getState().setCurrentCredentialId('cred-xyz') - - expect(store.getState().currentCredentialId).toBe('cred-xyz') - expect(store.getState().localFileList).toHaveLength(1) - }) - }) - - describe('Local File Workflow: Set Files โ†’ Verify List โ†’ Clear', () => { - it('should set and retrieve local file list', () => { - const store = createDataSourceStore() - const files = [createFileItem('f1'), createFileItem('f2'), createFileItem('f3')] - - store.getState().setLocalFileList(files) - - expect(store.getState().localFileList).toHaveLength(3) - expect(store.getState().localFileList[0]!.fileID).toBe('f1') - expect(store.getState().localFileList[2]!.fileID).toBe('f3') - }) - - it('should update preview ref when setting file list', () => { - const store = createDataSourceStore() - const files = [createFileItem('f-preview')] - - store.getState().setLocalFileList(files) - - expect(store.getState().previewLocalFileRef.current).toBeDefined() - }) - - it('should clear files by setting empty list', () => { - const store = createDataSourceStore() - - store.getState().setLocalFileList([createFileItem('f1')]) - expect(store.getState().localFileList).toHaveLength(1) - - store.getState().setLocalFileList([]) - expect(store.getState().localFileList).toHaveLength(0) - }) - - it('should set and clear current local file selection', () => { - const store = createDataSourceStore() - const file = { id: 'current-file', name: 'current.txt' } as FileItem['file'] - - store.getState().setCurrentLocalFile(file) - expect(store.getState().currentLocalFile).toBeDefined() - expect(store.getState().currentLocalFile?.id).toBe('current-file') - - store.getState().setCurrentLocalFile(undefined) - expect(store.getState().currentLocalFile).toBeUndefined() - }) - }) - - describe('Online Document Workflow: Set Documents โ†’ Select Pages โ†’ Verify', () => { - it('should set documents data and online documents', () => { - const store = createDataSourceStore() - const pages = [createNotionPage('page-1'), createNotionPage('page-2')] - - store.getState().setOnlineDocuments(pages) - - expect(store.getState().onlineDocuments).toHaveLength(2) - expect(store.getState().onlineDocuments[0]!.page_id).toBe('page-1') - }) - - it('should update preview ref when setting online documents', () => { - const store = createDataSourceStore() - const pages = [createNotionPage('page-preview')] - - store.getState().setOnlineDocuments(pages) - - expect(store.getState().previewOnlineDocumentRef.current).toBeDefined() - expect(store.getState().previewOnlineDocumentRef.current?.page_id).toBe('page-preview') - }) - - it('should track selected page IDs', () => { - const store = createDataSourceStore() - const pages = [createNotionPage('p1'), createNotionPage('p2'), createNotionPage('p3')] - - store.getState().setOnlineDocuments(pages) - store.getState().setSelectedPagesId(new Set(['p1', 'p3'])) - - expect(store.getState().selectedPagesId.size).toBe(2) - expect(store.getState().selectedPagesId.has('p1')).toBe(true) - expect(store.getState().selectedPagesId.has('p2')).toBe(false) - expect(store.getState().selectedPagesId.has('p3')).toBe(true) - }) - - it('should manage search value for filtering documents', () => { - const store = createDataSourceStore() - - store.getState().setSearchValue('meeting notes') - - expect(store.getState().searchValue).toBe('meeting notes') - }) - - it('should set and clear current document selection', () => { - const store = createDataSourceStore() - const page = createNotionPage('current-page') - - store.getState().setCurrentDocument(page) - expect(store.getState().currentDocument?.page_id).toBe('current-page') - - store.getState().setCurrentDocument(undefined) - expect(store.getState().currentDocument).toBeUndefined() - }) - }) - - describe('Website Crawl Workflow: Set Pages โ†’ Track Step โ†’ Preview', () => { - it('should set website pages and update preview ref', () => { - const store = createDataSourceStore() - const pages = [ - createCrawlResultItem('https://example.com'), - createCrawlResultItem('https://example.com/about'), - ] - - store.getState().setWebsitePages(pages) - - expect(store.getState().websitePages).toHaveLength(2) - expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://example.com') - }) - - it('should manage crawl step transitions', () => { - const store = createDataSourceStore() - - expect(store.getState().step).toBe(CrawlStep.init) - - store.getState().setStep(CrawlStep.running) - expect(store.getState().step).toBe(CrawlStep.running) - - store.getState().setStep(CrawlStep.finished) - expect(store.getState().step).toBe(CrawlStep.finished) - }) - - it('should set crawl result with data and timing', () => { - const store = createDataSourceStore() - const result = { - data: [createCrawlResultItem('https://test.com')], - time_consuming: 3.5, - } - - store.getState().setCrawlResult(result) - - expect(store.getState().crawlResult?.data).toHaveLength(1) - expect(store.getState().crawlResult?.time_consuming).toBe(3.5) - }) - - it('should manage preview index for page navigation', () => { - const store = createDataSourceStore() - - store.getState().setPreviewIndex(2) - expect(store.getState().previewIndex).toBe(2) - - store.getState().setPreviewIndex(-1) - expect(store.getState().previewIndex).toBe(-1) - }) - - it('should set and clear current website selection', () => { - const store = createDataSourceStore() - const page = createCrawlResultItem('https://current.com') - - store.getState().setCurrentWebsite(page) - expect(store.getState().currentWebsite?.source_url).toBe('https://current.com') - - store.getState().setCurrentWebsite(undefined) - expect(store.getState().currentWebsite).toBeUndefined() - }) - }) - - describe('Online Drive Workflow: Breadcrumbs โ†’ File Selection โ†’ Navigation', () => { - it('should manage breadcrumb navigation', () => { - const store = createDataSourceStore() - - store.getState().setBreadcrumbs(['root', 'folder-a', 'subfolder']) - - expect(store.getState().breadcrumbs).toEqual(['root', 'folder-a', 'subfolder']) - }) - - it('should support breadcrumb push/pop pattern', () => { - const store = createDataSourceStore() - - store.getState().setBreadcrumbs(['root']) - store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-1']) - store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-2']) - - expect(store.getState().breadcrumbs).toEqual(['root', 'level-1', 'level-2']) - - // Pop back one level - store.getState().setBreadcrumbs(store.getState().breadcrumbs.slice(0, -1)) - expect(store.getState().breadcrumbs).toEqual(['root', 'level-1']) - }) - - it('should manage file list and selection', () => { - const store = createDataSourceStore() - const files = [ - createOnlineDriveFile('drive-1', 'report.pdf'), - createOnlineDriveFile('drive-2', 'data.csv'), - createOnlineDriveFile('drive-3', 'images', OnlineDriveFileType.folder), - ] - - store.getState().setOnlineDriveFileList(files) - expect(store.getState().onlineDriveFileList).toHaveLength(3) - - store.getState().setSelectedFileIds(['drive-1', 'drive-2']) - expect(store.getState().selectedFileIds).toEqual(['drive-1', 'drive-2']) - }) - - it('should update preview ref when selecting files', () => { - const store = createDataSourceStore() - const files = [ - createOnlineDriveFile('drive-a', 'file-a.txt'), - createOnlineDriveFile('drive-b', 'file-b.txt'), - ] - - store.getState().setOnlineDriveFileList(files) - store.getState().setSelectedFileIds(['drive-b']) - - expect(store.getState().previewOnlineDriveFileRef.current?.id).toBe('drive-b') - }) - - it('should manage bucket and prefix for S3-like navigation', () => { - const store = createDataSourceStore() - - store.getState().setBucket('my-data-bucket') - store.getState().setPrefix(['data', '2024']) - store.getState().setHasBucket(true) - - expect(store.getState().bucket).toBe('my-data-bucket') - expect(store.getState().prefix).toEqual(['data', '2024']) - expect(store.getState().hasBucket).toBe(true) - }) - - it('should manage keywords for search filtering', () => { - const store = createDataSourceStore() - - store.getState().setKeywords('quarterly report') - expect(store.getState().keywords).toBe('quarterly report') - }) - }) - - describe('State Isolation: Changes to One Slice Do Not Affect Others', () => { - it('should keep local file state independent from online document state', () => { - const store = createDataSourceStore() - - store.getState().setLocalFileList([createFileItem('local-1')]) - store.getState().setOnlineDocuments([createNotionPage('notion-1')]) - - expect(store.getState().localFileList).toHaveLength(1) - expect(store.getState().onlineDocuments).toHaveLength(1) - - // Clearing local files should not affect online documents - store.getState().setLocalFileList([]) - expect(store.getState().localFileList).toHaveLength(0) - expect(store.getState().onlineDocuments).toHaveLength(1) - }) - - it('should keep website crawl state independent from online drive state', () => { - const store = createDataSourceStore() - - store.getState().setWebsitePages([createCrawlResultItem('https://site.com')]) - store.getState().setOnlineDriveFileList([createOnlineDriveFile('d1', 'file.txt')]) - - expect(store.getState().websitePages).toHaveLength(1) - expect(store.getState().onlineDriveFileList).toHaveLength(1) - - // Clearing website pages should not affect drive files - store.getState().setWebsitePages([]) - expect(store.getState().websitePages).toHaveLength(0) - expect(store.getState().onlineDriveFileList).toHaveLength(1) - }) - - it('should create fully independent store instances', () => { - const storeA = createDataSourceStore() - const storeB = createDataSourceStore() - - storeA.getState().setCurrentCredentialId('cred-A') - storeA.getState().setLocalFileList([createFileItem('fa-1')]) - - expect(storeA.getState().currentCredentialId).toBe('cred-A') - expect(storeB.getState().currentCredentialId).toBe('') - expect(storeB.getState().localFileList).toEqual([]) - }) - }) - - describe('Full Workflow Simulation: Credential โ†’ Source โ†’ Data โ†’ Verify', () => { - it('should support a complete local file upload workflow', () => { - const store = createDataSourceStore() - - // Step 1: Set credential - store.getState().setCurrentCredentialId('upload-cred-1') - - // Step 2: Set file list - const files = [createFileItem('upload-1'), createFileItem('upload-2')] - store.getState().setLocalFileList(files) - - // Step 3: Select current file for preview - store.getState().setCurrentLocalFile(files[0]!.file) - - // Verify all state is consistent - expect(store.getState().currentCredentialId).toBe('upload-cred-1') - expect(store.getState().localFileList).toHaveLength(2) - expect(store.getState().currentLocalFile?.id).toBe('upload-1') - expect(store.getState().previewLocalFileRef.current).toBeDefined() - }) - - it('should support a complete website crawl workflow', () => { - const store = createDataSourceStore() - - // Step 1: Set credential - store.getState().setCurrentCredentialId('crawl-cred-1') - - // Step 2: Init crawl - store.getState().setStep(CrawlStep.running) - - // Step 3: Crawl completes with results - const crawledPages = [ - createCrawlResultItem('https://docs.example.com/guide'), - createCrawlResultItem('https://docs.example.com/api'), - createCrawlResultItem('https://docs.example.com/faq'), - ] - store.getState().setCrawlResult({ data: crawledPages, time_consuming: 12.5 }) - store.getState().setStep(CrawlStep.finished) - - // Step 4: Set website pages from results - store.getState().setWebsitePages(crawledPages) - - // Step 5: Set preview - store.getState().setPreviewIndex(1) - - // Verify all state - expect(store.getState().currentCredentialId).toBe('crawl-cred-1') - expect(store.getState().step).toBe(CrawlStep.finished) - expect(store.getState().websitePages).toHaveLength(3) - expect(store.getState().crawlResult?.time_consuming).toBe(12.5) - expect(store.getState().previewIndex).toBe(1) - expect(store.getState().previewWebsitePageRef.current?.source_url).toBe( - 'https://docs.example.com/guide', - ) - }) - - it('should support a complete online drive navigation workflow', () => { - const store = createDataSourceStore() - - // Step 1: Set credential - store.getState().setCurrentCredentialId('drive-cred-1') - - // Step 2: Set bucket - store.getState().setBucket('company-docs') - store.getState().setHasBucket(true) - - // Step 3: Navigate into folders - store.getState().setBreadcrumbs(['company-docs']) - store.getState().setPrefix(['projects']) - const folderFiles = [ - createOnlineDriveFile('proj-1', 'project-alpha', OnlineDriveFileType.folder), - createOnlineDriveFile('proj-2', 'project-beta', OnlineDriveFileType.folder), - createOnlineDriveFile('readme', 'README.md', OnlineDriveFileType.file), - ] - store.getState().setOnlineDriveFileList(folderFiles) - - // Step 4: Navigate deeper - store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'project-alpha']) - store.getState().setPrefix([...store.getState().prefix, 'project-alpha']) - - // Step 5: Select files - store - .getState() - .setOnlineDriveFileList([ - createOnlineDriveFile('doc-1', 'spec.pdf'), - createOnlineDriveFile('doc-2', 'design.fig'), - ]) - store.getState().setSelectedFileIds(['doc-1']) - - // Verify full state - expect(store.getState().currentCredentialId).toBe('drive-cred-1') - expect(store.getState().bucket).toBe('company-docs') - expect(store.getState().breadcrumbs).toEqual(['company-docs', 'project-alpha']) - expect(store.getState().prefix).toEqual(['projects', 'project-alpha']) - expect(store.getState().onlineDriveFileList).toHaveLength(2) - expect(store.getState().selectedFileIds).toEqual(['doc-1']) - expect(store.getState().previewOnlineDriveFileRef.current?.name).toBe('spec.pdf') - }) - }) -}) diff --git a/web/__tests__/datasets/segment-crud.test.tsx b/web/__tests__/datasets/segment-crud.test.tsx deleted file mode 100644 index 1b114a867af..00000000000 --- a/web/__tests__/datasets/segment-crud.test.tsx +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Integration Test: Segment CRUD Flow - * - * Tests segment selection, search/filter, and modal state management across hooks. - * Validates cross-hook data contracts in the completed segment module. - */ - -import type { SegmentDetailModel } from '@/models/datasets' -import { act, renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useModalState } from '@/app/components/datasets/documents/detail/completed/hooks/use-modal-state' -import { useSearchFilter } from '@/app/components/datasets/documents/detail/completed/hooks/use-search-filter' -import { useSegmentSelection } from '@/app/components/datasets/documents/detail/completed/hooks/use-segment-selection' - -const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel => - ({ - id, - position: 1, - document_id: 'doc-1', - content, - sign_content: content, - answer: '', - word_count: 50, - tokens: 25, - keywords: ['test'], - index_node_id: 'idx-1', - index_node_hash: 'hash-1', - hit_count: 0, - enabled: true, - disabled_at: 0, - disabled_by: '', - status: 'completed', - created_by: 'user-1', - created_at: Date.now(), - indexing_at: Date.now(), - completed_at: Date.now(), - error: null, - stopped_at: 0, - updated_at: Date.now(), - attachments: [], - }) as SegmentDetailModel - -describe('Segment CRUD Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Search and Filter โ†’ Segment List Query', () => { - it('should manage search input with debounce', () => { - vi.useFakeTimers() - const onPageChange = vi.fn() - const { result } = renderHook(() => useSearchFilter({ onPageChange })) - - act(() => { - result.current.handleInputChange('keyword') - }) - - expect(result.current.inputValue).toBe('keyword') - expect(result.current.searchValue).toBe('') - - act(() => { - vi.advanceTimersByTime(500) - }) - expect(result.current.searchValue).toBe('keyword') - expect(onPageChange).toHaveBeenCalledWith(1) - - vi.useRealTimers() - }) - - it('should manage status filter state', () => { - const onPageChange = vi.fn() - const { result } = renderHook(() => useSearchFilter({ onPageChange })) - - // status value 1 maps to !!1 = true (enabled) - act(() => { - result.current.onChangeStatus({ value: 1, name: 'enabled' }) - }) - // onChangeStatus converts: value === 'all' ? 'all' : !!value - expect(result.current.selectedStatus).toBe(true) - - act(() => { - result.current.onClearFilter() - }) - expect(result.current.selectedStatus).toBe('all') - expect(result.current.inputValue).toBe('') - }) - - it('should provide status list for filter dropdown', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() })) - expect(result.current.statusList).toBeInstanceOf(Array) - expect(result.current.statusList.length).toBe(3) // all, disabled, enabled - }) - - it('should compute selectDefaultValue based on selectedStatus', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() })) - - // Initial state: 'all' - expect(result.current.selectDefaultValue).toBe('all') - - // Set to enabled (true) - act(() => { - result.current.onChangeStatus({ value: 1, name: 'enabled' }) - }) - expect(result.current.selectDefaultValue).toBe(1) - - // Set to disabled (false) - act(() => { - result.current.onChangeStatus({ value: 0, name: 'disabled' }) - }) - expect(result.current.selectDefaultValue).toBe(0) - }) - }) - - describe('Segment Selection โ†’ Batch Operations', () => { - it('should manage individual segment selection', () => { - const { result } = renderHook(() => useSegmentSelection()) - - act(() => { - result.current.onSelectedSegmentIdsChange(['seg-1']) - }) - expect(result.current.selectedSegmentIds).toContain('seg-1') - - act(() => { - result.current.onSelectedSegmentIdsChange(['seg-1', 'seg-2']) - }) - expect(result.current.selectedSegmentIds).toContain('seg-1') - expect(result.current.selectedSegmentIds).toContain('seg-2') - expect(result.current.selectedSegmentIds).toHaveLength(2) - }) - - it('should clear selection via onCancelBatchOperation', () => { - const { result } = renderHook(() => useSegmentSelection()) - - act(() => { - result.current.onSelectedSegmentIdsChange(['seg-1', 'seg-2']) - }) - expect(result.current.selectedSegmentIds).toHaveLength(2) - - act(() => { - result.current.onCancelBatchOperation() - }) - expect(result.current.selectedSegmentIds).toHaveLength(0) - }) - }) - - describe('Modal State Management', () => { - const onNewSegmentModalChange = vi.fn() - - it('should open segment detail modal on card click', () => { - const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) - - const segment = createSegment('seg-detail-1', 'Detail content') - act(() => { - result.current.onClickCard(segment) - }) - expect(result.current.currSegment.showModal).toBe(true) - expect(result.current.currSegment.segInfo).toBeDefined() - expect(result.current.currSegment.segInfo!.id).toBe('seg-detail-1') - }) - - it('should close segment detail modal', () => { - const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) - - const segment = createSegment('seg-1') - act(() => { - result.current.onClickCard(segment) - }) - expect(result.current.currSegment.showModal).toBe(true) - - act(() => { - result.current.onCloseSegmentDetail() - }) - expect(result.current.currSegment.showModal).toBe(false) - }) - - it('should manage full screen toggle', () => { - const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) - - expect(result.current.fullScreen).toBe(false) - act(() => { - result.current.toggleFullScreen() - }) - expect(result.current.fullScreen).toBe(true) - act(() => { - result.current.toggleFullScreen() - }) - expect(result.current.fullScreen).toBe(false) - }) - - it('should manage collapsed state', () => { - const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) - - expect(result.current.isCollapsed).toBe(true) - act(() => { - result.current.toggleCollapsed() - }) - expect(result.current.isCollapsed).toBe(false) - }) - - it('should manage new child segment modal', () => { - const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) - - expect(result.current.showNewChildSegmentModal).toBe(false) - act(() => { - result.current.handleAddNewChildChunk('chunk-parent-1') - }) - expect(result.current.showNewChildSegmentModal).toBe(true) - expect(result.current.currChunkId).toBe('chunk-parent-1') - - act(() => { - result.current.onCloseNewChildChunkModal() - }) - expect(result.current.showNewChildSegmentModal).toBe(false) - }) - }) - - describe('Cross-Hook Data Flow: Search โ†’ Selection โ†’ Modal', () => { - it('should maintain independent state across all three hooks', () => { - const segments = [createSegment('seg-1'), createSegment('seg-2')] - - const { result: filterResult } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() })) - const { result: selectionResult } = renderHook(() => useSegmentSelection()) - const { result: modalResult } = renderHook(() => - useModalState({ onNewSegmentModalChange: vi.fn() }), - ) - - // Set search filter to enabled - act(() => { - filterResult.current.onChangeStatus({ value: 1, name: 'enabled' }) - }) - - // Select a segment - act(() => { - selectionResult.current.onSelectedSegmentIdsChange(['seg-1']) - }) - - // Open detail modal - act(() => { - modalResult.current.onClickCard(segments[0]!) - }) - - // All states should be independent - expect(filterResult.current.selectedStatus).toBe(true) // !!1 - expect(selectionResult.current.selectedSegmentIds).toContain('seg-1') - expect(modalResult.current.currSegment.showModal).toBe(true) - }) - }) -}) diff --git a/web/__tests__/description-validation.test.tsx b/web/__tests__/description-validation.test.tsx deleted file mode 100644 index a7c3e8ab396..00000000000 --- a/web/__tests__/description-validation.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Description Validation Test - * - * Tests for the 400-character description validation across App and Dataset - * creation and editing workflows to ensure consistent validation behavior. - */ - -describe('Description Validation Logic', () => { - // Simulate backend validation function - const validateDescriptionLength = (description?: string | null) => { - if (description && description.length > 400) - throw new Error('Description cannot exceed 400 characters.') - - return description - } - - describe('Backend Validation Function', () => { - it('allows description within 400 characters', () => { - const validDescription = 'x'.repeat(400) - expect(() => validateDescriptionLength(validDescription)).not.toThrow() - expect(validateDescriptionLength(validDescription)).toBe(validDescription) - }) - - it('allows empty description', () => { - expect(() => validateDescriptionLength('')).not.toThrow() - expect(() => validateDescriptionLength(null)).not.toThrow() - expect(() => validateDescriptionLength(undefined)).not.toThrow() - }) - - it('rejects description exceeding 400 characters', () => { - const invalidDescription = 'x'.repeat(401) - expect(() => validateDescriptionLength(invalidDescription)).toThrow( - 'Description cannot exceed 400 characters.', - ) - }) - }) - - describe('Backend Validation Consistency', () => { - it('App and Dataset have consistent validation limits', () => { - const maxLength = 400 - const validDescription = 'x'.repeat(maxLength) - const invalidDescription = 'x'.repeat(maxLength + 1) - - // Both should accept exactly 400 characters - expect(validDescription.length).toBe(400) - expect(() => validateDescriptionLength(validDescription)).not.toThrow() - - // Both should reject 401 characters - expect(invalidDescription.length).toBe(401) - expect(() => validateDescriptionLength(invalidDescription)).toThrow() - }) - - it('validation error messages are consistent', () => { - const expectedErrorMessage = 'Description cannot exceed 400 characters.' - - // This would be the error message from both App and Dataset backend validation - expect(expectedErrorMessage).toBe('Description cannot exceed 400 characters.') - - const invalidDescription = 'x'.repeat(401) - try { - validateDescriptionLength(invalidDescription) - } catch (error) { - expect((error as Error).message).toBe(expectedErrorMessage) - } - }) - }) - - describe('Character Length Edge Cases', () => { - const testCases = [ - { length: 0, shouldPass: true, description: 'empty description' }, - { length: 1, shouldPass: true, description: '1 character' }, - { length: 399, shouldPass: true, description: '399 characters' }, - { length: 400, shouldPass: true, description: '400 characters (boundary)' }, - { length: 401, shouldPass: false, description: '401 characters (over limit)' }, - { length: 500, shouldPass: false, description: '500 characters' }, - { length: 1000, shouldPass: false, description: '1000 characters' }, - ] - - testCases.forEach(({ length, shouldPass, description }) => { - it(`handles ${description} correctly`, () => { - const testDescription = length > 0 ? 'x'.repeat(length) : '' - expect(testDescription.length).toBe(length) - - if (shouldPass) { - expect(() => validateDescriptionLength(testDescription)).not.toThrow() - expect(validateDescriptionLength(testDescription)).toBe(testDescription) - } else { - expect(() => validateDescriptionLength(testDescription)).toThrow( - 'Description cannot exceed 400 characters.', - ) - } - }) - }) - }) -}) diff --git a/web/__tests__/develop/api-key-management-flow.test.tsx b/web/__tests__/develop/api-key-management-flow.test.tsx deleted file mode 100644 index 9d3c3eac255..00000000000 --- a/web/__tests__/develop/api-key-management-flow.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Integration test: API Key management flow - * - * Tests the cross-component interaction: - * ApiServer โ†’ SecretKeyButton โ†’ SecretKeyModal - * - * Renders real ApiServer, SecretKeyButton, and SecretKeyModal together - * with only service-layer mocks. Deep modal interactions (create/delete) - * are covered by unit tests in secret-key-modal.spec.tsx. - */ -import { act, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import ApiServer from '@/app/components/develop/ApiServer' - -// ---------- fake timers (modal transitions) ---------- -beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }) -}) - -afterEach(() => { - vi.runOnlyPendingTimers() - vi.useRealTimers() -}) - -async function flushUI() { - await act(async () => { - vi.runAllTimers() - }) -} - -// ---------- mocks ---------- - -vi.mock('@/hooks/use-timestamp', () => ({ - default: () => ({ - formatTime: vi.fn((val: number) => `Time:${val}`), - formatDate: vi.fn((val: string) => `Date:${val}`), - }), -})) - -vi.mock('@/service/apps', () => ({ - createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-token-1234567890abcdef' }), - delApikey: vi.fn().mockResolvedValue({}), -})) - -vi.mock('@/service/datasets', () => ({ - createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }), - delApikey: vi.fn().mockResolvedValue({}), -})) - -const mockApiKeys = vi.fn().mockReturnValue({ data: [] }) -const mockIsLoading = vi.fn().mockReturnValue(false) - -vi.mock('@/service/use-apps', () => ({ - useAppApiKeys: () => ({ - data: mockApiKeys(), - isLoading: mockIsLoading(), - }), - useInvalidateAppApiKeys: () => vi.fn(), -})) - -vi.mock('@/service/knowledge/use-dataset', () => ({ - useDatasetApiKeys: () => ({ data: null, isLoading: false }), - useInvalidateDatasetApiKeys: () => vi.fn(), -})) - -// ---------- tests ---------- - -describe('API Key management flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockApiKeys.mockReturnValue({ data: [] }) - mockIsLoading.mockReturnValue(false) - }) - - it('ApiServer renders URL, status badge, and API Key button', () => { - render() - - expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument() - expect(screen.getByText('appApi.ok')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'appApi.apiKey' })).toBeDisabled() - }) - - it('clicking API Key button opens SecretKeyModal with real modal content', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - - render() - - // Click API Key button (rendered by SecretKeyButton) - await act(async () => { - await user.click(screen.getByText('appApi.apiKey')) - }) - await flushUI() - - // SecretKeyModal should render with real modal content - await waitFor(() => { - expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() - expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument() - expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument() - }) - }) - - it('modal shows loading state when API keys are being fetched', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - mockIsLoading.mockReturnValue(true) - - render() - - await act(async () => { - await user.click(screen.getByText('appApi.apiKey')) - }) - await flushUI() - - await waitFor(() => { - expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() - }) - - // Loading indicator should be present - expect(document.body.querySelector('[role="status"]')).toBeInTheDocument() - }) - - it('modal can be closed by clicking X icon', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - - render() - - // Open modal - await act(async () => { - await user.click(screen.getByText('appApi.apiKey')) - }) - await flushUI() - - await waitFor(() => { - expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() - }) - - // Click X icon to close - const closeIcon = document.body.querySelector('.i-heroicons-x-mark-20-solid.cursor-pointer') - expect(closeIcon).toBeInTheDocument() - - await act(async () => { - await user.click(closeIcon!) - }) - await flushUI() - - // Modal should close - await waitFor(() => { - expect(screen.queryByText('appApi.apiKeyModal.apiSecretKeyTips')).not.toBeInTheDocument() - }) - }) - - it('renders correctly with different API URLs', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - - const { rerender } = render( - , - ) - - expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument() - - // Open modal and verify it works with the same appId - await act(async () => { - await user.click(screen.getByText('appApi.apiKey')) - }) - await flushUI() - - await waitFor(() => { - expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() - }) - - // Close modal, update URL and re-verify - const xIcon = document.body.querySelector('.i-heroicons-x-mark-20-solid.cursor-pointer') - await act(async () => { - await user.click(xIcon!) - }) - await flushUI() - - rerender( - , - ) - - expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument() - }) -}) diff --git a/web/__tests__/develop/develop-page-flow.test.tsx b/web/__tests__/develop/develop-page-flow.test.tsx deleted file mode 100644 index 09235c15327..00000000000 --- a/web/__tests__/develop/develop-page-flow.test.tsx +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Integration test: DevelopMain page flow - * - * Tests the full page lifecycle: - * Loading state โ†’ App loaded โ†’ Header (ApiServer) + Content (Doc) rendered - * - * Uses real DevelopMain, ApiServer, and Doc components with minimal mocks. - */ -import { act, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import DevelopMain from '@/app/components/develop' -import { AppModeEnum, Theme } from '@/types/app' - -beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }) -}) - -afterEach(() => { - vi.runOnlyPendingTimers() - vi.useRealTimers() -}) - -async function flushUI() { - await act(async () => { - vi.runAllTimers() - }) -} - -let storeAppDetail: unknown - -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: Record) => unknown) => { - return selector({ appDetail: storeAppDetail }) - }, -})) -vi.mock('@/hooks/use-theme', () => ({ - default: () => ({ theme: Theme.light }), -})) - -vi.mock('@/i18n-config/language', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - } -}) - -vi.mock('@/hooks/use-timestamp', () => ({ - default: () => ({ - formatTime: vi.fn((val: number) => `Time:${val}`), - formatDate: vi.fn((val: string) => `Date:${val}`), - }), -})) - -vi.mock('@/service/apps', () => ({ - createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-1234567890' }), - delApikey: vi.fn().mockResolvedValue({}), -})) - -vi.mock('@/service/datasets', () => ({ - createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }), - delApikey: vi.fn().mockResolvedValue({}), -})) - -vi.mock('@/service/use-apps', () => ({ - useAppApiKeys: () => ({ data: { data: [] }, isLoading: false }), - useInvalidateAppApiKeys: () => vi.fn(), -})) - -vi.mock('@/service/knowledge/use-dataset', () => ({ - useDatasetApiKeys: () => ({ data: null, isLoading: false }), - useInvalidateDatasetApiKeys: () => vi.fn(), -})) - -// ---------- tests ---------- - -describe('DevelopMain page flow', () => { - beforeEach(() => { - vi.clearAllMocks() - storeAppDetail = undefined - }) - - it('should show loading indicator when appDetail is not available', () => { - storeAppDetail = undefined - render() - - expect(screen.getByRole('status')).toBeInTheDocument() - // No content should be visible - expect(screen.queryByText('appApi.apiServer')).not.toBeInTheDocument() - }) - - it('should render full page when appDetail is loaded', () => { - storeAppDetail = { - id: 'app-1', - name: 'Test App', - api_base_url: 'https://api.test.com/v1', - mode: AppModeEnum.CHAT, - permission_keys: ['app.acl.edit'], - } - - render() - - // ApiServer section should be visible - expect(screen.getByText('appApi.apiServer')).toBeInTheDocument() - expect(screen.getByText('https://api.test.com/v1')).toBeInTheDocument() - expect(screen.getByText('appApi.ok')).toBeInTheDocument() - expect(screen.getByText('appApi.apiKey')).toBeInTheDocument() - - // Loading should NOT be visible - expect(screen.queryByRole('status')).not.toBeInTheDocument() - }) - - it('should render Doc component with correct app mode template', () => { - storeAppDetail = { - id: 'app-1', - name: 'Chat App', - api_base_url: 'https://api.test.com/v1', - mode: AppModeEnum.CHAT, - permission_keys: ['app.acl.edit'], - } - - const { container } = render() - - // Doc renders an article element with prose classes - const article = container.querySelector('article') - expect(article).toBeInTheDocument() - expect(article?.className).toContain('prose') - }) - - it('should transition from loading to content when appDetail becomes available', () => { - // Start with no data - storeAppDetail = undefined - const { rerender } = render() - expect(screen.getByRole('status')).toBeInTheDocument() - - // Simulate store update - storeAppDetail = { - id: 'app-1', - name: 'My App', - api_base_url: 'https://api.example.com/v1', - mode: AppModeEnum.COMPLETION, - permission_keys: ['app.acl.edit'], - } - rerender() - - // Content should now be visible - expect(screen.queryByRole('status')).not.toBeInTheDocument() - expect(screen.getByText('https://api.example.com/v1')).toBeInTheDocument() - }) - - it('should open API key modal from the page', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - - storeAppDetail = { - id: 'app-1', - name: 'Test App', - api_base_url: 'https://api.test.com/v1', - mode: AppModeEnum.WORKFLOW, - permission_keys: ['app.acl.edit'], - } - - render() - - // Click API Key button in the header - await act(async () => { - await user.click(screen.getByText('appApi.apiKey')) - }) - await flushUI() - - // SecretKeyModal should open - await waitFor(() => { - expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument() - }) - }) - - it('should render correctly for different app modes', () => { - const modes = [ - AppModeEnum.CHAT, - AppModeEnum.COMPLETION, - AppModeEnum.ADVANCED_CHAT, - AppModeEnum.WORKFLOW, - ] - - for (const mode of modes) { - storeAppDetail = { - id: 'app-1', - name: `${mode} App`, - api_base_url: 'https://api.test.com/v1', - mode, - permission_keys: ['app.acl.edit'], - } - - const { container, unmount } = render() - - // ApiServer should always be present - expect(screen.getByText('appApi.apiServer')).toBeInTheDocument() - - // Doc should render an article - expect(container.querySelector('article')).toBeInTheDocument() - - unmount() - } - }) - - it('should have correct page layout structure', () => { - storeAppDetail = { - id: 'app-1', - name: 'Test App', - api_base_url: 'https://api.test.com/v1', - mode: AppModeEnum.CHAT, - permission_keys: ['app.acl.edit'], - } - - render() - - // Main container: flex column with full height - const mainDiv = screen.getByTestId('develop-main') - expect(mainDiv.className).toContain('flex') - expect(mainDiv.className).toContain('flex-col') - expect(mainDiv.className).toContain('h-full') - - // Header section with border - const header = mainDiv.querySelector('.border-b') - expect(header).toBeInTheDocument() - - // Content section with overflow scroll - const content = mainDiv.querySelector('.overflow-auto') - expect(content).toBeInTheDocument() - }) -}) diff --git a/web/__tests__/document-detail-navigation-fix.test.tsx b/web/__tests__/document-detail-navigation-fix.test.tsx deleted file mode 100644 index 81636f8618b..00000000000 --- a/web/__tests__/document-detail-navigation-fix.test.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import type { Mock } from 'vitest' -/** - * Document Detail Navigation Fix Verification Test - * - * This test specifically validates that the backToPrev function in the document detail - * component correctly preserves pagination and filter states. - */ -import { fireEvent, render, screen } from '@testing-library/react' -import { useRouter } from '@/next/navigation' -import { useDocumentDetail, useDocumentMetadata } from '@/service/knowledge/use-document' - -// Mock Next.js router -const mockPush = vi.fn() -vi.mock('@/next/navigation', () => ({ - useRouter: vi.fn(() => ({ - push: mockPush, - })), -})) - -// Mock the document service hooks -vi.mock('@/service/knowledge/use-document', () => ({ - useDocumentDetail: vi.fn(), - useDocumentMetadata: vi.fn(), - useInvalidDocumentList: vi.fn(() => vi.fn()), -})) - -// Mock other dependencies -vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContext: vi.fn(() => [null]), -})) - -vi.mock('@/service/use-base', () => ({ - useInvalid: vi.fn(() => vi.fn()), -})) - -vi.mock('@/service/knowledge/use-segment', () => ({ - useSegmentListKey: vi.fn(), - useChildSegmentListKey: vi.fn(), -})) - -// Create a minimal version of the DocumentDetail component that includes our fix -const DocumentDetailWithFix = ({ - datasetId, - documentId, -}: { - datasetId: string - documentId: string -}) => { - const router = useRouter() - - // This is the FIXED implementation from detail/index.tsx - const backToPrev = () => { - // Preserve pagination and filter states when navigating back - const searchParams = new URLSearchParams(window.location.search) - const queryString = searchParams.toString() - const separator = queryString ? '?' : '' - const backPath = `/datasets/${datasetId}/documents${separator}${queryString}` - router.push(backPath) - } - - return ( -
- -
- Dataset: {datasetId}, Document: {documentId} -
-
- ) -} - -describe('Document Detail Navigation Fix Verification', () => { - beforeEach(() => { - vi.clearAllMocks() - - // Mock successful API responses - ;(useDocumentDetail as Mock).mockReturnValue({ - data: { - id: 'doc-123', - name: 'Test Document', - display_status: 'available', - enabled: true, - archived: false, - }, - error: null, - }) - - ;(useDocumentMetadata as Mock).mockReturnValue({ - data: null, - error: null, - }) - }) - - describe('Query Parameter Preservation', () => { - it('preserves pagination state (page 3, limit 25)', () => { - // Simulate user coming from page 3 with 25 items per page - Object.defineProperty(window, 'location', { - value: { - search: '?page=3&limit=25', - }, - writable: true, - }) - - render() - - // User clicks back button - fireEvent.click(screen.getByTestId('back-button-fixed')) - - // Should preserve the pagination state - expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents?page=3&limit=25') - - console.log('โœ… Pagination state preserved: page=3&limit=25') - }) - - it('preserves search keyword and filters', () => { - // Simulate user with search and filters applied - Object.defineProperty(window, 'location', { - value: { - search: '?page=2&limit=10&keyword=API%20documentation&status=active', - }, - writable: true, - }) - - render() - - fireEvent.click(screen.getByTestId('back-button-fixed')) - - // Should preserve all query parameters - expect(mockPush).toHaveBeenCalledWith( - '/datasets/dataset-123/documents?page=2&limit=10&keyword=API+documentation&status=active', - ) - - console.log('โœ… Search and filters preserved') - }) - - it('handles complex query parameters with special characters', () => { - // Test with complex query string including encoded characters - Object.defineProperty(window, 'location', { - value: { - search: - '?page=1&limit=50&keyword=test%20%26%20debug&sort=name&order=desc&filter=%7B%22type%22%3A%22pdf%22%7D', - }, - writable: true, - }) - - render() - - fireEvent.click(screen.getByTestId('back-button-fixed')) - - // URLSearchParams will normalize the encoding, but preserve all parameters - const expectedCall = mockPush.mock.calls[0]![0] - expect(expectedCall).toMatch(/^\/datasets\/dataset-123\/documents\?/) - expect(expectedCall).toMatch(/page=1/) - expect(expectedCall).toMatch(/limit=50/) - expect(expectedCall).toMatch(/keyword=test/) - expect(expectedCall).toMatch(/sort=name/) - expect(expectedCall).toMatch(/order=desc/) - - console.log('โœ… Complex query parameters handled:', expectedCall) - }) - - it('handles empty query parameters gracefully', () => { - // No query parameters in URL - Object.defineProperty(window, 'location', { - value: { - search: '', - }, - writable: true, - }) - - render() - - fireEvent.click(screen.getByTestId('back-button-fixed')) - - // Should navigate to clean documents URL - expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents') - - console.log('โœ… Empty parameters handled gracefully') - }) - }) - - describe('Different Dataset IDs', () => { - it('works with different dataset identifiers', () => { - Object.defineProperty(window, 'location', { - value: { - search: '?page=5&limit=10', - }, - writable: true, - }) - - // Test with different dataset ID format - render() - - fireEvent.click(screen.getByTestId('back-button-fixed')) - - expect(mockPush).toHaveBeenCalledWith('/datasets/ds-prod-2024-001/documents?page=5&limit=10') - - console.log('โœ… Works with different dataset ID formats') - }) - }) - - describe('Real User Scenarios', () => { - it('scenario: user searches, goes to page 3, views document, clicks back', () => { - // User searched for "API" and navigated to page 3 - Object.defineProperty(window, 'location', { - value: { - search: '?keyword=API&page=3&limit=10', - }, - writable: true, - }) - - render() - - // User decides to go back to continue browsing - fireEvent.click(screen.getByTestId('back-button-fixed')) - - // Should return to page 3 of API search results - expect(mockPush).toHaveBeenCalledWith( - '/datasets/main-dataset/documents?keyword=API&page=3&limit=10', - ) - - console.log('โœ… Real user scenario: search + pagination preserved') - }) - - it('scenario: user applies multiple filters, goes to document, returns', () => { - // User has applied multiple filters and is on page 2 - Object.defineProperty(window, 'location', { - value: { - search: '?page=2&limit=25&status=active&type=pdf&sort=created_at&order=desc', - }, - writable: true, - }) - - render() - - fireEvent.click(screen.getByTestId('back-button-fixed')) - - // All filters should be preserved - expect(mockPush).toHaveBeenCalledWith( - '/datasets/filtered-dataset/documents?page=2&limit=25&status=active&type=pdf&sort=created_at&order=desc', - ) - - console.log('โœ… Complex filtering scenario preserved') - }) - }) - - describe('Error Handling and Edge Cases', () => { - it('handles malformed query parameters gracefully', () => { - // Test with potentially problematic query string - Object.defineProperty(window, 'location', { - value: { - search: '?page=invalid&limit=&keyword=test&=emptykey&malformed', - }, - writable: true, - }) - - render() - - // Should not throw errors - expect(() => { - fireEvent.click(screen.getByTestId('back-button-fixed')) - }).not.toThrow() - - // Should still attempt navigation (URLSearchParams will clean up the parameters) - expect(mockPush).toHaveBeenCalled() - const navigationPath = mockPush.mock.calls[0]![0] - expect(navigationPath).toMatch(/^\/datasets\/dataset-123\/documents/) - - console.log('โœ… Malformed parameters handled gracefully:', navigationPath) - }) - - it('handles very long query strings', () => { - // Test with a very long query string - const longKeyword = 'a'.repeat(1000) - Object.defineProperty(window, 'location', { - value: { - search: `?page=1&keyword=${longKeyword}`, - }, - writable: true, - }) - - render() - - expect(() => { - fireEvent.click(screen.getByTestId('back-button-fixed')) - }).not.toThrow() - - expect(mockPush).toHaveBeenCalled() - - console.log('โœ… Long query strings handled') - }) - }) - - describe('Performance Verification', () => { - it('navigation function executes quickly', () => { - Object.defineProperty(window, 'location', { - value: { - search: '?page=1&limit=10&keyword=test', - }, - writable: true, - }) - - render() - - const startTime = performance.now() - fireEvent.click(screen.getByTestId('back-button-fixed')) - const endTime = performance.now() - - const executionTime = endTime - startTime - - // Should execute in less than 10ms - expect(executionTime).toBeLessThan(10) - - console.log(`โšก Navigation execution time: ${executionTime.toFixed(2)}ms`) - }) - }) -}) diff --git a/web/__tests__/document-list-sorting.test.tsx b/web/__tests__/document-list-sorting.test.tsx deleted file mode 100644 index bfef01be6ba..00000000000 --- a/web/__tests__/document-list-sorting.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Document List Sorting Tests - */ - -describe('Document List Sorting', () => { - const mockDocuments = [ - { id: '1', name: 'Beta.pdf', word_count: 500, hit_count: 10, created_at: 1699123456 }, - { id: '2', name: 'Alpha.txt', word_count: 200, hit_count: 25, created_at: 1699123400 }, - { id: '3', name: 'Gamma.docx', word_count: 800, hit_count: 5, created_at: 1699123500 }, - ] - - const sortDocuments = (docs: any[], field: string, order: 'asc' | 'desc') => { - return [...docs].sort((a, b) => { - let aValue: any - let bValue: any - - switch (field) { - case 'name': - aValue = a.name?.toLowerCase() || '' - bValue = b.name?.toLowerCase() || '' - break - case 'word_count': - aValue = a.word_count || 0 - bValue = b.word_count || 0 - break - case 'hit_count': - aValue = a.hit_count || 0 - bValue = b.hit_count || 0 - break - case 'created_at': - aValue = a.created_at - bValue = b.created_at - break - default: - return 0 - } - - if (field === 'name') { - const result = aValue.localeCompare(bValue) - return order === 'asc' ? result : -result - } else { - const result = aValue - bValue - return order === 'asc' ? result : -result - } - }) - } - - it('sorts by name descending (default for UI consistency)', () => { - const sorted = sortDocuments(mockDocuments, 'name', 'desc') - expect(sorted.map((doc) => doc.name)).toEqual(['Gamma.docx', 'Beta.pdf', 'Alpha.txt']) - }) - - it('sorts by name ascending (after toggle)', () => { - const sorted = sortDocuments(mockDocuments, 'name', 'asc') - expect(sorted.map((doc) => doc.name)).toEqual(['Alpha.txt', 'Beta.pdf', 'Gamma.docx']) - }) - - it('sorts by word_count descending', () => { - const sorted = sortDocuments(mockDocuments, 'word_count', 'desc') - expect(sorted.map((doc) => doc.word_count)).toEqual([800, 500, 200]) - }) - - it('sorts by hit_count descending', () => { - const sorted = sortDocuments(mockDocuments, 'hit_count', 'desc') - expect(sorted.map((doc) => doc.hit_count)).toEqual([25, 10, 5]) - }) - - it('sorts by created_at descending (newest first)', () => { - const sorted = sortDocuments(mockDocuments, 'created_at', 'desc') - expect(sorted.map((doc) => doc.created_at)).toEqual([1699123500, 1699123456, 1699123400]) - }) - - it('handles empty values correctly', () => { - const docsWithEmpty = [ - { id: '1', name: 'Test', word_count: 100, hit_count: 5, created_at: 1699123456 }, - { id: '2', name: 'Empty', word_count: 0, hit_count: 0, created_at: 1699123400 }, - ] - - const sorted = sortDocuments(docsWithEmpty, 'word_count', 'desc') - expect(sorted.map((doc) => doc.word_count)).toEqual([100, 0]) - }) -}) diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx deleted file mode 100644 index 05e45475ef6..00000000000 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ /dev/null @@ -1,459 +0,0 @@ -/** - * Integration test: Explore App List Flow - * - * Tests the end-to-end user flow of browsing, filtering, searching, - * and adding apps to workspace from the explore page. - */ -import type { Mock } from 'vitest' -import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' -import type { App } from '@/models/explore' -import { fireEvent, screen, waitFor } from '@testing-library/react' -import { - createTestQueryClient, - renderWithSystemFeatures as render, -} from '@/__tests__/utils/mock-system-features' -import AppList from '@/app/components/explore/app-list' -import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore' -import { useMembers } from '@/service/use-common' -import { AppModeEnum } from '@/types/app' - -type MockAppContext = { - userProfile: { id: string } - workspacePermissionKeys: string[] -} - -const mockUseAppContext = vi.hoisted(() => vi.fn<() => MockAppContext>()) - -const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en-US"}' -let mockTabValue = allCategoriesEn -const mockSetTab = vi.fn() -let mockExploreData: { categories: string[]; allList: App[] } | undefined -let mockIsLoading = false -const mockHandleImportDSL = vi.fn() -const mockHandleImportDSLConfirm = vi.fn() - -vi.mock('nuqs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQueryState: () => [mockTabValue, mockSetTab], - } -}) - -vi.mock('ahooks', async () => { - const actual = await vi.importActual('ahooks') - const React = await vi.importActual('react') - return { - ...actual, - useDebounceFn: (fn: (...args: unknown[]) => void) => { - const fnRef = React.useRef(fn) - fnRef.current = fn - return { - run: () => setTimeout(() => fnRef.current(), 0), - } - }, - } -}) - -vi.mock('@/service/use-explore', () => ({ - useLearnDifyAppList: () => ({ - data: [], - isLoading: false, - isError: false, - }), -})) - -vi.mock('@/service/explore', () => ({ - fetchAppDetail: vi.fn(), - fetchAppList: vi.fn(), - fetchBanners: vi.fn(), -})) - -vi.mock('@/service/client', () => ({ - consoleClient: {}, - consoleQuery: { - systemFeatures: { - get: { - queryKey: () => ['console', 'systemFeatures'], - }, - }, - apps: { - get: { - queryOptions: (options: { - input?: { query?: { limit?: number } } - select?: (response: { - data: [] - has_more: boolean - limit: number - page: number - total: number - }) => unknown - }) => { - const limit = options.input?.query?.limit ?? 0 - const response = { - data: [], - has_more: false, - limit, - page: 1, - total: 0, - } - return { - queryKey: ['console', 'apps', 'get', options.input], - queryFn: () => Promise.resolve(response), - initialData: response, - select: options.select, - } - }, - }, - }, - explore: { - apps: { - get: { - queryKey: ({ input }: { input?: unknown } = {}) => [ - 'console', - 'explore', - 'apps', - 'get', - input, - ], - }, - }, - banners: { - get: { - queryKey: ({ input }: { input?: unknown } = {}) => [ - 'console', - 'explore', - 'banners', - 'get', - input, - ], - }, - }, - }, - }, -})) - -vi.mock('@/context/account-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext()) -}) -vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext()) -}) -vi.mock('@/context/permission-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext()) -}) -vi.mock('@/context/version-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext()) -}) -vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext()) -}) - -vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = - await import('@/__tests__/utils/mock-app-context-state') - - return createAppContextStateJotaiMock(importOriginal) -}) - -vi.mock('@/service/use-common', () => ({ - useMembers: vi.fn(), -})) - -vi.mock('@/hooks/use-import-dsl', () => ({ - useImportDSL: () => ({ - handleImportDSL: mockHandleImportDSL, - handleImportDSLConfirm: mockHandleImportDSLConfirm, - versions: ['v1'], - isFetching: false, - }), -})) - -vi.mock('@/app/components/explore/create-app-modal', () => ({ - default: (props: CreateAppModalProps) => { - if (!props.show) return null - return ( -
- - -
- ) - }, -})) - -vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({ - default: ({ onConfirm, onCancel }: { onConfirm: () => void; onCancel: () => void }) => ( -
- - -
- ), -})) - -const createApp = (overrides: Partial = {}): App => ({ - app: { - id: overrides.app?.id ?? 'app-id', - mode: overrides.app?.mode ?? AppModeEnum.CHAT, - icon_type: overrides.app?.icon_type ?? 'emoji', - icon: overrides.app?.icon ?? '๐Ÿ˜€', - icon_background: overrides.app?.icon_background ?? '#fff', - icon_url: overrides.app?.icon_url ?? '', - name: overrides.app?.name ?? 'Alpha', - description: overrides.app?.description ?? 'Alpha description', - use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, - }, - can_trial: true, - app_id: overrides.app_id ?? 'app-1', - description: overrides.description ?? 'Alpha description', - copyright: overrides.copyright ?? '', - privacy_policy: overrides.privacy_policy ?? null, - custom_disclaimer: overrides.custom_disclaimer ?? null, - categories: overrides.categories ?? ['Writing'], - position: overrides.position ?? 1, - is_listed: overrides.is_listed ?? true, - install_count: overrides.install_count ?? 0, - installed: overrides.installed ?? false, - editable: overrides.editable ?? false, - is_agent: overrides.is_agent ?? false, -}) - -const mockMemberRole = (hasEditPermission: boolean) => { - mockUseAppContext.mockReturnValue({ - userProfile: { id: 'user-1' }, - workspacePermissionKeys: hasEditPermission ? ['app.create_and_management'] : [], - }) - vi.mocked(useMembers).mockReturnValue({ - data: { - accounts: [{ id: 'user-1', role: hasEditPermission ? 'admin' : 'normal' }], - }, - } as unknown as ReturnType) -} - -const localeInput = { query: { language: 'en-US' } } -const exploreAppListQueryKey = ['console', 'explore', 'apps', 'get', localeInput, 'en-US'] -const homeContinueWorkAppsInput = { - query: { - page: 1, - limit: 8, - name: '', - }, -} - -const createHomeQueryClient = () => { - const queryClient = createTestQueryClient() - queryClient.setQueryData(['console', 'apps', 'get', homeContinueWorkAppsInput], { - data: [], - has_more: false, - limit: 8, - page: 1, - total: 0, - }) - - if (!mockIsLoading && mockExploreData) - queryClient.setQueryData(exploreAppListQueryKey, mockExploreData) - - return queryClient -} - -const renderAppList = (hasEditPermission = true, onSuccess?: () => void) => { - mockMemberRole(hasEditPermission) - return render(, { - queryClient: createHomeQueryClient(), - }) -} - -describe('Explore App List Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockTabValue = allCategoriesEn - mockIsLoading = false - mockExploreData = { - categories: ['Writing', 'Translate', 'Programming'], - allList: [ - createApp({ - app_id: 'app-1', - app: { ...createApp().app, name: 'Writer Bot' }, - categories: ['Writing'], - }), - createApp({ - app_id: 'app-2', - app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, - categories: ['Translate'], - }), - createApp({ - app_id: 'app-3', - app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, - categories: ['Programming'], - }), - ], - } - ;(fetchAppList as unknown as Mock).mockImplementation(() => new Promise(() => {})) - ;(fetchBanners as unknown as Mock).mockResolvedValue([]) - }) - - describe('Browse and Filter Flow', () => { - it('should display all apps when no category filter is applied', () => { - renderAppList() - - expect(screen.getByText('Writer Bot'))!.toBeInTheDocument() - expect(screen.getByText('Translator'))!.toBeInTheDocument() - expect(screen.getByText('Code Helper'))!.toBeInTheDocument() - }) - - it('should filter apps by selected category', () => { - mockTabValue = 'Writing' - renderAppList() - - expect(screen.getByText('Writer Bot'))!.toBeInTheDocument() - expect(screen.queryByText('Translator')).not.toBeInTheDocument() - expect(screen.queryByText('Code Helper')).not.toBeInTheDocument() - }) - - it('should only use categories when filtering by selected category', () => { - mockTabValue = 'Writing' - mockExploreData = { - categories: ['Writing', 'Translate'], - allList: [ - createApp({ - app_id: 'app-1', - app: { ...createApp().app, name: 'Active Writer' }, - categories: ['Writing'], - }), - createApp({ - app_id: 'app-2', - app: { ...createApp().app, id: 'app-id-2', name: 'Legacy Writer' }, - categories: [], - }), - ], - } - - renderAppList() - - expect(screen.getByText('Active Writer')).toBeInTheDocument() - expect(screen.queryByText('Legacy Writer')).not.toBeInTheDocument() - }) - - it('should filter apps by search keyword', async () => { - renderAppList() - - const input = screen.getByPlaceholderText('common.operation.search') - fireEvent.change(input, { target: { value: 'trans' } }) - - await waitFor(() => { - expect(screen.getByText('Translator'))!.toBeInTheDocument() - expect(screen.queryByText('Writer Bot')).not.toBeInTheDocument() - expect(screen.queryByText('Code Helper')).not.toBeInTheDocument() - }) - }) - }) - - describe('Add to Workspace Flow', () => { - it('should complete the full add-to-workspace flow with DSL confirmation', async () => { - // Step 1: User clicks "Add to Workspace" on an app card - const onSuccess = vi.fn() - ;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' }) - mockHandleImportDSL.mockImplementation( - async (_payload: unknown, options: { onSuccess?: () => void; onPending?: () => void }) => { - options.onPending?.() - }, - ) - mockHandleImportDSLConfirm.mockImplementation( - async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => { - options.onSuccess?.({ app_mode: AppModeEnum.CHAT }) - }, - ) - - renderAppList(true, onSuccess) - - // Step 2: Click the app card - opens create modal in self-hosted/non-cloud mode - fireEvent.click(screen.getByRole('button', { name: 'Writer Bot' })) - - // Step 3: Confirm creation in modal - fireEvent.click(await screen.findByTestId('confirm-create')) - - // Step 4: API fetches app detail - await waitFor(() => { - expect(fetchAppDetail).toHaveBeenCalledWith('app-id') - }) - - // Step 5: DSL import triggers pending confirmation - expect(mockHandleImportDSL).toHaveBeenCalledTimes(1) - - // Step 6: DSL confirm modal appears and user confirms - expect(await screen.findByTestId('dsl-confirm-modal'))!.toBeInTheDocument() - fireEvent.click(screen.getByTestId('dsl-confirm')) - - // Step 7: Flow completes successfully - await waitFor(() => { - expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1) - expect(onSuccess).toHaveBeenCalledTimes(1) - }) - }) - }) - - describe('Loading and Empty States', () => { - it('should transition from loading to content', () => { - // Step 1: Loading state - mockIsLoading = true - mockExploreData = undefined - const { unmount } = renderAppList() - - expect(screen.getByRole('status'))!.toBeInTheDocument() - - // Step 2: Data loads - mockIsLoading = false - mockExploreData = { - categories: ['Writing'], - allList: [createApp()], - } - unmount() - renderAppList() - - expect(screen.queryByRole('status')).not.toBeInTheDocument() - expect(screen.getByText('Alpha'))!.toBeInTheDocument() - }) - }) - - describe('Permission-Based Behavior', () => { - it('should not make app cards clickable when user has no edit permission', () => { - renderAppList(false) - - expect(screen.queryByRole('button', { name: 'Writer Bot' })).not.toBeInTheDocument() - }) - - it('should make app cards clickable when user has edit permission', () => { - renderAppList(true) - - expect(screen.getByRole('button', { name: 'Writer Bot' })).toBeInTheDocument() - }) - }) -}) diff --git a/web/__tests__/explore/installed-app-flow.test.tsx b/web/__tests__/explore/installed-app-flow.test.tsx deleted file mode 100644 index 39da25725ec..00000000000 --- a/web/__tests__/explore/installed-app-flow.test.tsx +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Integration test: Installed App Flow - * - * Tests the end-to-end user flow of installed apps: sidebar navigation, - * mode-based routing (Chat / Completion / Workflow), and lifecycle - * operations (pin/unpin, delete). - */ -import type { Mock } from 'vitest' -import type { InstalledApp as InstalledAppModel } from '@/models/explore' -import { render, screen, waitFor } from '@testing-library/react' -import InstalledApp from '@/app/components/explore/installed-app' -import { useWebAppStore } from '@/context/web-app-context' -import { AccessMode } from '@/models/access-control' -import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' -import { - useGetInstalledAppAccessModeByAppId, - useGetInstalledAppMeta, - useGetInstalledAppParams, - useGetInstalledApps, -} from '@/service/use-explore' -import { AppModeEnum } from '@/types/app' - -vi.mock('@/context/web-app-context', () => ({ - useWebAppStore: vi.fn(), -})) - -vi.mock('@/service/access-control/use-app-access-control', () => ({ - useGetUserCanAccessApp: vi.fn(), -})) - -vi.mock('@/service/use-explore', () => ({ - useGetInstalledAppAccessModeByAppId: vi.fn(), - useGetInstalledAppParams: vi.fn(), - useGetInstalledAppMeta: vi.fn(), - useGetInstalledApps: vi.fn(), -})) - -vi.mock('@/app/components/share/text-generation', () => ({ - default: ({ isWorkflow }: { isWorkflow?: boolean }) => ( -
- Text Generation - {isWorkflow && ' (Workflow)'} -
- ), -})) - -vi.mock('@/app/components/base/chat/chat-with-history', () => ({ - default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => ( -
Chat - {installedAppInfo?.app.name}
- ), -})) - -describe('Installed App Flow', () => { - const mockUpdateAppInfo = vi.fn() - const mockUpdateWebAppAccessMode = vi.fn() - const mockUpdateAppParams = vi.fn() - const mockUpdateWebAppMeta = vi.fn() - const mockUpdateUserCanAccessApp = vi.fn() - - const createInstalledApp = (mode: AppModeEnum = AppModeEnum.CHAT): InstalledAppModel => ({ - id: 'installed-app-1', - app: { - id: 'real-app-id', - name: 'Integration Test App', - mode, - icon_type: 'emoji', - icon: '๐Ÿงช', - icon_background: '#FFFFFF', - icon_url: '', - description: 'Test app for integration', - use_icon_as_answer_icon: false, - }, - uninstallable: true, - is_pinned: false, - }) - - const mockAppParams = { - user_input_form: [], - file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } }, - system_parameters: {}, - } - - type MockOverrides = { - installedApps?: { apps?: InstalledAppModel[]; isPending?: boolean; isFetching?: boolean } - accessMode?: { isPending?: boolean; data?: unknown; error?: unknown } - params?: { isPending?: boolean; data?: unknown; error?: unknown } - meta?: { isPending?: boolean; data?: unknown; error?: unknown } - userAccess?: { data?: unknown; error?: unknown } - } - - const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => { - const installedApps = overrides.installedApps?.apps ?? (app ? [app] : []) - - ;(useGetInstalledApps as Mock).mockReturnValue({ - data: { installed_apps: installedApps }, - isPending: false, - isFetching: false, - ...overrides.installedApps, - }) - - ;(useWebAppStore as unknown as Mock).mockImplementation( - (selector: (state: Record) => unknown) => { - return selector({ - updateAppInfo: mockUpdateAppInfo, - updateWebAppAccessMode: mockUpdateWebAppAccessMode, - updateAppParams: mockUpdateAppParams, - updateWebAppMeta: mockUpdateWebAppMeta, - updateUserCanAccessApp: mockUpdateUserCanAccessApp, - }) - }, - ) - - ;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({ - isPending: false, - data: { accessMode: AccessMode.PUBLIC }, - error: null, - ...overrides.accessMode, - }) - - ;(useGetInstalledAppParams as Mock).mockReturnValue({ - isPending: false, - data: mockAppParams, - error: null, - ...overrides.params, - }) - - ;(useGetInstalledAppMeta as Mock).mockReturnValue({ - isPending: false, - data: { tool_icons: {} }, - error: null, - ...overrides.meta, - }) - - ;(useGetUserCanAccessApp as Mock).mockReturnValue({ - data: { result: true }, - error: null, - ...overrides.userAccess, - }) - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Mode-Based Routing', () => { - it.each([ - [AppModeEnum.CHAT, 'chat-with-history'], - [AppModeEnum.ADVANCED_CHAT, 'chat-with-history'], - [AppModeEnum.AGENT_CHAT, 'chat-with-history'], - ])('should render ChatWithHistory for %s mode', async (mode, testId) => { - const app = createInstalledApp(mode) - setupDefaultMocks(app) - - render() - - expect(await screen.findByTestId(testId)).toBeInTheDocument() - expect(screen.getByText(/Integration Test App/)).toBeInTheDocument() - }) - - it('should render TextGenerationApp for COMPLETION mode', () => { - const app = createInstalledApp(AppModeEnum.COMPLETION) - setupDefaultMocks(app) - - render() - - expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() - expect(screen.getByText('Text Generation')).toBeInTheDocument() - expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument() - }) - - it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => { - const app = createInstalledApp(AppModeEnum.WORKFLOW) - setupDefaultMocks(app) - - render() - - expect(screen.getByTestId('text-generation-app')).toBeInTheDocument() - expect(screen.getByText(/Workflow/)).toBeInTheDocument() - }) - }) - - describe('Data Loading Flow', () => { - it('should show loading spinner when params are being fetched', () => { - const app = createInstalledApp() - setupDefaultMocks(app, { params: { isPending: true, data: null } }) - - const { container } = render() - - expect(container.querySelector('svg.spin-animation')).toBeInTheDocument() - expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument() - }) - - it('should defer 404 while installed apps are refetching without a match', () => { - setupDefaultMocks(undefined, { - installedApps: { apps: [], isPending: false, isFetching: true }, - }) - - const { container } = render() - - expect(container.querySelector('svg.spin-animation')).toBeInTheDocument() - expect(screen.queryByText(/404/)).not.toBeInTheDocument() - }) - - it('should render content when all data is available', () => { - const app = createInstalledApp() - setupDefaultMocks(app) - - render() - - expect(screen.getByTestId('chat-with-history')).toBeInTheDocument() - }) - }) - - describe('Error Handling Flow', () => { - it('should show error state when API fails', () => { - const app = createInstalledApp() - setupDefaultMocks(app, { params: { data: null, error: new Error('Network error') } }) - - render() - - expect(screen.getByText(/Network error/)).toBeInTheDocument() - }) - - it('should show 404 when app is not found', () => { - setupDefaultMocks(undefined, { - accessMode: { data: null }, - params: { data: null }, - meta: { data: null }, - userAccess: { data: null }, - }) - - render() - - expect(screen.getByText(/404/)).toBeInTheDocument() - }) - - it('should show 403 when user has no permission', () => { - const app = createInstalledApp() - setupDefaultMocks(app, { userAccess: { data: { result: false } } }) - - render() - - expect(screen.getByText(/403/)).toBeInTheDocument() - }) - }) - - describe('State Synchronization', () => { - it('should update all stores when app data is loaded', async () => { - const app = createInstalledApp() - setupDefaultMocks(app) - - render() - - await waitFor(() => { - expect(mockUpdateAppInfo).toHaveBeenCalledWith( - expect.objectContaining({ - app_id: 'installed-app-1', - site: expect.objectContaining({ - title: 'Integration Test App', - icon: '๐Ÿงช', - }), - }), - ) - expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams) - expect(mockUpdateWebAppMeta).toHaveBeenCalledWith({ tool_icons: {} }) - expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC) - expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true) - }) - }) - }) -}) diff --git a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx deleted file mode 100644 index b1bfd55d91a..00000000000 --- a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Integration test: Sidebar Lifecycle Flow - * - * Tests the sidebar interactions for installed apps lifecycle: - * navigation, pin/unpin ordering, delete confirmation, and - * fold/unfold behavior. - */ -import type { InstalledApp } from '@/models/explore' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import SideBar from '@/app/components/explore/sidebar' -import { MediaType } from '@/hooks/use-breakpoints' -import { AppModeEnum } from '@/types/app' - -const { mockToastSuccess } = vi.hoisted(() => ({ - mockToastSuccess: vi.fn(), -})) - -let mockMediaType: string = MediaType.pc -const mockSegments = ['apps'] -const mockPush = vi.fn() -const mockUninstall = vi.fn() -const mockUpdatePinStatus = vi.fn() -let mockInstalledApps: InstalledApp[] = [] -let mockIsUninstallPending = false - -vi.mock('@/next/navigation', () => ({ - usePathname: () => '/explore', - useSelectedLayoutSegments: () => mockSegments, - useRouter: () => ({ - push: mockPush, - }), -})) - -vi.mock('@/hooks/use-breakpoints', () => ({ - default: () => mockMediaType, - MediaType: { - mobile: 'mobile', - tablet: 'tablet', - pc: 'pc', - }, -})) - -vi.mock('@/service/use-explore', () => ({ - useGetInstalledApps: () => ({ - isPending: false, - data: { installed_apps: mockInstalledApps }, - }), - useUninstallApp: () => ({ - mutateAsync: mockUninstall, - isPending: mockIsUninstallPending, - }), - useUpdateAppPinStatus: () => ({ - mutateAsync: mockUpdatePinStatus, - }), -})) - -vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - toast: { - ...actual.toast, - success: mockToastSuccess, - }, - } -}) - -const createInstalledApp = (overrides: Partial = {}): InstalledApp => ({ - id: overrides.id ?? 'app-1', - uninstallable: overrides.uninstallable ?? false, - is_pinned: overrides.is_pinned ?? false, - app: { - id: overrides.app?.id ?? 'app-basic-id', - mode: overrides.app?.mode ?? AppModeEnum.CHAT, - icon_type: overrides.app?.icon_type ?? 'emoji', - icon: overrides.app?.icon ?? '๐Ÿค–', - icon_background: overrides.app?.icon_background ?? '#fff', - icon_url: overrides.app?.icon_url ?? '', - name: overrides.app?.name ?? 'App One', - description: overrides.app?.description ?? 'desc', - use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, - }, -}) - -const renderSidebar = () => { - return render() -} - -describe('Sidebar Lifecycle Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockMediaType = MediaType.pc - mockInstalledApps = [] - mockIsUninstallPending = false - }) - - describe('Pin / Unpin / Delete Flow', () => { - it('should complete pin โ†’ unpin cycle for an app', async () => { - mockUpdatePinStatus.mockResolvedValue(undefined) - - // Step 1: Start with an unpinned app and pin it - const unpinnedApp = createInstalledApp({ is_pinned: false }) - mockInstalledApps = [unpinnedApp] - const { unmount } = renderSidebar() - - fireEvent.click(screen.getByTestId('item-operation-trigger')) - fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) - - await waitFor(() => { - expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true }) - expect(mockToastSuccess).toHaveBeenCalled() - }) - - // Step 2: Simulate refetch returning pinned state, then unpin - unmount() - vi.clearAllMocks() - mockUpdatePinStatus.mockResolvedValue(undefined) - - const pinnedApp = createInstalledApp({ is_pinned: true }) - mockInstalledApps = [pinnedApp] - renderSidebar() - - fireEvent.click(screen.getByTestId('item-operation-trigger')) - fireEvent.click(await screen.findByText('explore.sidebar.action.unpin')) - - await waitFor(() => { - expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: false }) - expect(mockToastSuccess).toHaveBeenCalled() - }) - }) - - it('should complete the delete flow with confirmation', async () => { - const app = createInstalledApp() - mockInstalledApps = [app] - mockUninstall.mockResolvedValue(undefined) - - renderSidebar() - - // Step 1: Open operation menu and click delete - fireEvent.click(screen.getByTestId('item-operation-trigger')) - fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) - - // Step 2: Confirm dialog appears - expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument() - - // Step 3: Confirm deletion - fireEvent.click(screen.getByText('common.operation.confirm')) - - // Step 4: Uninstall API called and success toast shown - await waitFor(() => { - expect(mockUninstall).toHaveBeenCalledWith('app-1') - expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove') - }) - }) - - it('should cancel deletion when user clicks cancel', async () => { - const app = createInstalledApp() - mockInstalledApps = [app] - - renderSidebar() - - // Open delete flow - fireEvent.click(screen.getByTestId('item-operation-trigger')) - fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) - - // Cancel the deletion - fireEvent.click(await screen.findByText('common.operation.cancel')) - - // Uninstall should not be called - expect(mockUninstall).not.toHaveBeenCalled() - }) - }) - - describe('Multi-App Ordering', () => { - it('should display pinned apps before unpinned apps with divider', () => { - mockInstalledApps = [ - createInstalledApp({ - id: 'pinned-1', - is_pinned: true, - app: { ...createInstalledApp().app, name: 'Pinned App' }, - }), - createInstalledApp({ - id: 'unpinned-1', - is_pinned: false, - app: { ...createInstalledApp().app, name: 'Regular App' }, - }), - ] - - const { container } = renderSidebar() - - // Both apps are rendered - const pinnedApp = screen.getByText('Pinned App') - const regularApp = screen.getByText('Regular App') - expect(pinnedApp).toBeInTheDocument() - expect(regularApp).toBeInTheDocument() - - // Pinned app appears before unpinned app in the DOM - const pinnedItem = pinnedApp.closest('[class*="rounded-lg"]')! - const regularItem = regularApp.closest('[class*="rounded-lg"]')! - expect( - pinnedItem.compareDocumentPosition(regularItem) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy() - - // Divider is rendered between pinned and unpinned sections - const divider = container.querySelector('[class*="bg-divider-regular"]') - expect(divider).toBeInTheDocument() - }) - }) - - describe('Empty State', () => { - it('should show NoApps component when no apps are installed on desktop', () => { - mockMediaType = MediaType.pc - renderSidebar() - - expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument() - }) - }) -}) diff --git a/web/__tests__/goto-anything/scope-command-tags.test.tsx b/web/__tests__/goto-anything/scope-command-tags.test.tsx deleted file mode 100644 index acb978b6419..00000000000 --- a/web/__tests__/goto-anything/scope-command-tags.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { render, screen } from '@testing-library/react' -import * as React from 'react' - -// Type alias for search mode -type SearchMode = 'scopes' | 'commands' | null - -// Mock component to test tag display logic -const TagDisplay: React.FC<{ searchMode: SearchMode }> = ({ searchMode }) => { - if (!searchMode) return null - - return ( -
- {searchMode === 'scopes' ? 'SCOPES' : 'COMMANDS'} -
- ) -} - -describe('Scope and Command Tags', () => { - describe('Tag Display Logic', () => { - it('should display SCOPES for @ actions', () => { - render() - expect(screen.getByText('SCOPES')).toBeInTheDocument() - expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument() - }) - - it('should display COMMANDS for / actions', () => { - render() - expect(screen.getByText('COMMANDS')).toBeInTheDocument() - expect(screen.queryByText('SCOPES')).not.toBeInTheDocument() - }) - - it('should not display any tag when searchMode is null', () => { - const { container } = render() - expect(container.firstChild).toBeNull() - }) - }) - - describe('Search Mode Detection', () => { - const getSearchMode = (query: string): SearchMode => { - if (query.startsWith('@')) return 'scopes' - if (query.startsWith('/')) return 'commands' - return null - } - - it('should detect scopes mode for @ queries', () => { - expect(getSearchMode('@app')).toBe('scopes') - expect(getSearchMode('@knowledge')).toBe('scopes') - expect(getSearchMode('@plugin')).toBe('scopes') - expect(getSearchMode('@node')).toBe('scopes') - }) - - it('should detect commands mode for / queries', () => { - expect(getSearchMode('/theme')).toBe('commands') - expect(getSearchMode('/language')).toBe('commands') - expect(getSearchMode('/docs')).toBe('commands') - }) - - it('should return null for regular queries', () => { - expect(getSearchMode('')).toBe(null) - expect(getSearchMode('search term')).toBe(null) - expect(getSearchMode('app')).toBe(null) - }) - - it('should handle queries with spaces', () => { - expect(getSearchMode('@app search')).toBe('scopes') - expect(getSearchMode('/theme dark')).toBe('commands') - }) - }) - - describe('Tag Styling', () => { - it('should apply correct styling classes', () => { - const { container } = render() - const tagContainer = container.querySelector( - '.flex.items-center.gap-1.text-xs.text-text-tertiary', - ) - expect(tagContainer).toBeInTheDocument() - }) - - it('should use hardcoded English text', () => { - // Verify that tags are hardcoded and not using i18n - render() - const scopesText = screen.getByText('SCOPES') - expect(scopesText.textContent).toBe('SCOPES') - - render() - const commandsText = screen.getByText('COMMANDS') - expect(commandsText.textContent).toBe('COMMANDS') - }) - }) - - describe('Integration with Search States', () => { - const SearchComponent: React.FC<{ query: string }> = ({ query }) => { - let searchMode: SearchMode = null - - if (query.startsWith('@')) searchMode = 'scopes' - else if (query.startsWith('/')) searchMode = 'commands' - - return ( -
- - -
- ) - } - - it('should update tag when switching between @ and /', () => { - const { rerender } = render() - expect(screen.getByText('SCOPES')).toBeInTheDocument() - - rerender() - expect(screen.queryByText('SCOPES')).not.toBeInTheDocument() - expect(screen.getByText('COMMANDS')).toBeInTheDocument() - }) - - it('should hide tag when clearing search', () => { - const { rerender } = render() - expect(screen.getByText('SCOPES')).toBeInTheDocument() - - rerender() - expect(screen.queryByText('SCOPES')).not.toBeInTheDocument() - expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument() - }) - - it('should maintain correct tag during search refinement', () => { - const { rerender } = render() - expect(screen.getByText('SCOPES')).toBeInTheDocument() - - rerender() - expect(screen.getByText('SCOPES')).toBeInTheDocument() - - rerender() - expect(screen.getByText('SCOPES')).toBeInTheDocument() - }) - }) -}) diff --git a/web/__tests__/goto-anything/slash-command-modes.test.tsx b/web/__tests__/goto-anything/slash-command-modes.test.tsx deleted file mode 100644 index 36c218e7d23..00000000000 --- a/web/__tests__/goto-anything/slash-command-modes.test.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import type { SlashCommandHandler } from '../../app/components/goto-anything/actions/commands/types' -import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry' - -// Mock the registry -vi.mock('../../app/components/goto-anything/actions/commands/registry') - -describe('Slash Command Dual-Mode System', () => { - const mockDirectCommand: SlashCommandHandler = { - name: 'docs', - description: 'Open documentation', - mode: 'direct', - execute: vi.fn(), - search: vi.fn().mockResolvedValue([ - { - id: 'docs', - title: 'Documentation', - description: 'Open documentation', - type: 'command' as const, - data: { command: 'navigation.docs', args: {} }, - }, - ]), - register: vi.fn(), - unregister: vi.fn(), - } - - const mockSubmenuCommand: SlashCommandHandler = { - name: 'theme', - description: 'Change theme', - mode: 'submenu', - search: vi.fn().mockResolvedValue([ - { - id: 'theme-light', - title: 'Light Theme', - description: 'Switch to light theme', - type: 'command' as const, - data: { command: 'theme.set', args: { theme: 'light' } }, - }, - { - id: 'theme-dark', - title: 'Dark Theme', - description: 'Switch to dark theme', - type: 'command' as const, - data: { command: 'theme.set', args: { theme: 'dark' } }, - }, - ]), - register: vi.fn(), - unregister: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(slashCommandRegistry.findCommand).mockImplementation((name: string) => { - if (name === 'docs') return mockDirectCommand - if (name === 'theme') return mockSubmenuCommand - return undefined - }) - vi.mocked(slashCommandRegistry.getAllCommands).mockReturnValue([ - mockDirectCommand, - mockSubmenuCommand, - ]) - }) - - describe('Direct Mode Commands', () => { - it('should execute immediately when selected', () => { - const mockSetShow = vi.fn() - const mockSetSearchQuery = vi.fn() - - // Simulate command selection - const handler = slashCommandRegistry.findCommand('docs') - expect(handler?.mode).toBe('direct') - - if (handler?.mode === 'direct' && handler.execute) { - handler.execute() - mockSetShow(false) - mockSetSearchQuery('') - } - - expect(mockDirectCommand.execute).toHaveBeenCalled() - expect(mockSetShow).toHaveBeenCalledWith(false) - expect(mockSetSearchQuery).toHaveBeenCalledWith('') - }) - - it('should not enter submenu for direct mode commands', () => { - const handler = slashCommandRegistry.findCommand('docs') - expect(handler?.mode).toBe('direct') - expect(handler?.execute).toBeDefined() - }) - - it('should close modal after execution', () => { - const mockModalClose = vi.fn() - - const handler = slashCommandRegistry.findCommand('docs') - if (handler?.mode === 'direct' && handler.execute) { - handler.execute() - mockModalClose() - } - - expect(mockModalClose).toHaveBeenCalled() - }) - }) - - describe('Submenu Mode Commands', () => { - it('should show options instead of executing immediately', async () => { - const handler = slashCommandRegistry.findCommand('theme') - expect(handler?.mode).toBe('submenu') - - const results = await handler?.search('', 'en') - expect(results).toHaveLength(2) - expect(results?.[0]!.title).toBe('Light Theme') - expect(results?.[1]!.title).toBe('Dark Theme') - }) - - it('should not have execute function for submenu mode', () => { - const handler = slashCommandRegistry.findCommand('theme') - expect(handler?.mode).toBe('submenu') - expect(handler?.execute).toBeUndefined() - }) - - it('should keep modal open for selection', () => { - const mockModalClose = vi.fn() - - const handler = slashCommandRegistry.findCommand('theme') - // For submenu mode, modal should not close immediately - expect(handler?.mode).toBe('submenu') - expect(mockModalClose).not.toHaveBeenCalled() - }) - }) - - describe('Mode Detection and Routing', () => { - it('should correctly identify direct mode commands', () => { - const commands = slashCommandRegistry.getAllCommands() - const directCommands = commands.filter((cmd) => cmd.mode === 'direct') - const submenuCommands = commands.filter((cmd) => cmd.mode === 'submenu') - - expect(directCommands).toContainEqual(expect.objectContaining({ name: 'docs' })) - expect(submenuCommands).toContainEqual(expect.objectContaining({ name: 'theme' })) - }) - - it('should handle missing mode property gracefully', () => { - const commandWithoutMode: SlashCommandHandler = { - name: 'test', - description: 'Test command', - search: vi.fn(), - register: vi.fn(), - unregister: vi.fn(), - } - - vi.mocked(slashCommandRegistry.findCommand).mockReturnValue(commandWithoutMode) - - const handler = slashCommandRegistry.findCommand('test') - // Default behavior should be submenu when mode is not specified - expect(handler?.mode).toBeUndefined() - expect(handler?.execute).toBeUndefined() - }) - }) - - describe('Enter Key Handling', () => { - // Helper function to simulate key handler behavior - const createKeyHandler = () => { - return (commandKey: string) => { - if (commandKey.startsWith('/')) { - const commandName = commandKey.substring(1) - const handler = slashCommandRegistry.findCommand(commandName) - if (handler?.mode === 'direct' && handler.execute) { - handler.execute() - return true // Indicates handled - } - } - return false - } - } - - it('should trigger direct execution on Enter for direct mode', () => { - const keyHandler = createKeyHandler() - const handled = keyHandler('/docs') - expect(handled).toBe(true) - expect(mockDirectCommand.execute).toHaveBeenCalled() - }) - - it('should not trigger direct execution for submenu mode', () => { - const keyHandler = createKeyHandler() - const handled = keyHandler('/theme') - expect(handled).toBe(false) - expect(mockSubmenuCommand.search).not.toHaveBeenCalled() - }) - }) - - describe('Command Registration', () => { - it('should register both direct and submenu commands', () => { - mockDirectCommand.register?.({}) - mockSubmenuCommand.register?.({ setTheme: vi.fn() }) - - expect(mockDirectCommand.register).toHaveBeenCalled() - expect(mockSubmenuCommand.register).toHaveBeenCalled() - }) - - it('should handle unregistration for both command types', () => { - // Test unregister for direct command - mockDirectCommand.unregister?.() - expect(mockDirectCommand.unregister).toHaveBeenCalled() - - // Test unregister for submenu command - mockSubmenuCommand.unregister?.() - expect(mockSubmenuCommand.unregister).toHaveBeenCalled() - - // Verify both were called independently - expect(mockDirectCommand.unregister).toHaveBeenCalledTimes(1) - expect(mockSubmenuCommand.unregister).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/web/__tests__/header/account-dropdown-flow.test.tsx b/web/__tests__/header/account-dropdown-flow.test.tsx deleted file mode 100644 index c633fff8c65..00000000000 --- a/web/__tests__/header/account-dropdown-flow.test.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import { Plan } from '@/app/components/billing/type' -import AccountDropdown from '@/app/components/header/account-dropdown' -import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' - -vi.mock('@/context/i18n', () => ({ - useDocLink: () => (path: string) => `https://docs.example.com${path}`, -})) - -const { mockAppContextState, mockPush, mockLogout, mockResetUser, mockSetShowAccountSettingModal } = - vi.hoisted(() => ({ - mockAppContextState: { - userProfile: { - id: 'user-1', - name: 'Ada Lovelace', - email: 'ada@example.com', - avatar: '', - avatar_url: '', - is_password_set: true, - }, - langGeniusVersionInfo: { - current_env: 'CLOUD', - current_version: '1.0.0', - latest_version: '1.1.0', - release_date: '', - release_notes: 'https://example.com/releases/1.1.0', - version: '1.0.0', - can_auto_update: false, - }, - isCurrentWorkspaceOwner: false, - }, - mockPush: vi.fn(), - mockLogout: vi.fn(), - mockResetUser: vi.fn(), - mockSetShowAccountSettingModal: vi.fn(), - })) - -vi.mock('react-i18next', async () => { - const { withSelectorKey } = await import('@/test/i18n-mock') - return { - useTranslation: () => ({ - t: withSelectorKey((key: string, options?: { ns?: string; version?: string }) => { - if (options?.version) return `${options.ns}.${key}:${options.version}` - return options?.ns ? `${options.ns}.${key}` : key - }), - }), - } -}) - -vi.mock('@/context/account-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) -}) -vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) -}) -vi.mock('@/context/permission-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) -}) -vi.mock('@/context/version-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) -}) -vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) -}) - -vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = - await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateJotaiMock(importOriginal) -}) - -vi.mock('@/context/provider-context', () => ({ - useProviderContext: () => ({ - isEducationAccount: false, - plan: { - type: Plan.professional, - }, - }), -})) - -vi.mock('@/context/modal-context', () => ({ - useModalContext: () => ({ - setShowAccountSettingModal: mockSetShowAccountSettingModal, - }), -})) - -vi.mock('@/service/use-common', async (importOriginal) => ({ - ...(await importOriginal()), - useLogout: () => ({ - mutateAsync: mockLogout, - }), -})) - -vi.mock('@/app/components/base/amplitude/utils', () => ({ - resetUser: mockResetUser, -})) - -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ - push: mockPush, - }), -})) - -vi.mock('@/next/link', () => ({ - default: ({ - href, - children, - ...props - }: { - href: string - children?: React.ReactNode - } & Record) => ( - - {children} - - ), -})) - -const renderAccountDropdown = () => { - return renderWithSystemFeatures(, { - systemFeatures: { - branding: { - enabled: false, - workspace_logo: '', - }, - }, - }) -} - -describe('Header Account Dropdown Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response( - JSON.stringify({ - repo: { stars: 123456 }, - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ), - ) - localStorage.clear() - }) - - it('opens account actions, fetches github stars, and opens the settings and about flows', async () => { - renderAccountDropdown() - - fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) - - expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() - expect(screen.getByText('ada@example.com')).toBeInTheDocument() - expect(await screen.findByText('123,456')).toBeInTheDocument() - - fireEvent.click(screen.getByText('common.userProfile.settings')) - - expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ - payload: ACCOUNT_SETTING_TAB.MEMBERS, - }) - - fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) - fireEvent.click(screen.getByText('common.userProfile.about')) - - await waitFor(() => { - expect(screen.getByText(/Version/)).toBeInTheDocument() - expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument() - }) - }) - - it('logs out, resets cached user markers, and redirects to signin', async () => { - localStorage.setItem('education-reverify-prev-expire-at', '1') - localStorage.setItem('education-reverify-has-noticed', '1') - localStorage.setItem('education-expired-has-noticed', '1') - - renderAccountDropdown() - - fireEvent.click(screen.getByRole('button', { name: 'common.account.account' })) - fireEvent.click(screen.getByText('common.userProfile.logout')) - - await waitFor(() => { - expect(mockLogout).toHaveBeenCalledTimes(1) - expect(mockResetUser).toHaveBeenCalledTimes(1) - expect(mockPush).toHaveBeenCalledWith('/signin') - }) - - expect(localStorage.getItem('education-reverify-prev-expire-at')).toBeNull() - expect(localStorage.getItem('education-reverify-has-noticed')).toBeNull() - expect(localStorage.getItem('education-expired-has-noticed')).toBeNull() - }) -}) diff --git a/web/__tests__/plugin-tool-workflow-error.test.tsx b/web/__tests__/plugin-tool-workflow-error.test.tsx deleted file mode 100644 index 07a11214b4a..00000000000 --- a/web/__tests__/plugin-tool-workflow-error.test.tsx +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Test cases to reproduce the plugin tool workflow error - * Issue: #23154 - Application error when loading plugin tools in workflow - * Root cause: split() operation called on null/undefined values - */ - -describe('Plugin Tool Workflow Error Reproduction', () => { - /** - * Mock function to simulate the problematic code in switch-plugin-version.tsx:29 - * const [pluginId] = uniqueIdentifier.split(':') - */ - const mockSwitchPluginVersionLogic = (uniqueIdentifier: string | null | undefined) => { - // This directly reproduces the problematic line from switch-plugin-version.tsx:29 - const [pluginId] = uniqueIdentifier!.split(':') - return pluginId - } - - /** - * Test case 1: Simulate null uniqueIdentifier - * This should reproduce the error mentioned in the issue - */ - it('should reproduce error when uniqueIdentifier is null', () => { - expect(() => { - mockSwitchPluginVersionLogic(null) - }).toThrow("Cannot read properties of null (reading 'split')") - }) - - /** - * Test case 2: Simulate undefined uniqueIdentifier - */ - it('should reproduce error when uniqueIdentifier is undefined', () => { - expect(() => { - mockSwitchPluginVersionLogic(undefined) - }).toThrow("Cannot read properties of undefined (reading 'split')") - }) - - /** - * Test case 3: Simulate empty string uniqueIdentifier - */ - it('should handle empty string uniqueIdentifier', () => { - expect(() => { - const result = mockSwitchPluginVersionLogic('') - expect(result).toBe('') // Empty string split by ':' returns [''] - }).not.toThrow() - }) - - /** - * Test case 4: Simulate malformed uniqueIdentifier without colon separator - */ - it('should handle malformed uniqueIdentifier without colon separator', () => { - expect(() => { - const result = mockSwitchPluginVersionLogic('malformed-identifier-without-colon') - expect(result).toBe('malformed-identifier-without-colon') // No colon means full string returned - }).not.toThrow() - }) - - /** - * Test case 5: Simulate valid uniqueIdentifier - */ - it('should work correctly with valid uniqueIdentifier', () => { - expect(() => { - const result = mockSwitchPluginVersionLogic('valid-plugin-id:1.0.0') - expect(result).toBe('valid-plugin-id') - }).not.toThrow() - }) -}) - -/** - * Test for the variable processing split error in use-single-run-form-params - */ -describe('Variable Processing Split Error', () => { - /** - * Mock function to simulate the problematic code in use-single-run-form-params.ts:91 - * const getDependentVars = () => { - * return varInputs.map(item => item.variable.slice(1, -1).split('.')) - * } - */ - const mockGetDependentVars = (varInputs: Array<{ variable: string | null | undefined }>) => { - return varInputs - .map((item) => { - // Guard against null/undefined variable to prevent app crash - if (!item.variable || typeof item.variable !== 'string') return [] - - return item.variable.slice(1, -1).split('.') - }) - .filter((arr) => arr.length > 0) // Filter out empty arrays - } - - /** - * Test case 1: Variable processing with null variable - */ - it('should handle null variable safely', () => { - const varInputs = [{ variable: null }] - - expect(() => { - mockGetDependentVars(varInputs) - }).not.toThrow() - - const result = mockGetDependentVars(varInputs) - expect(result).toEqual([]) // null variables are filtered out - }) - - /** - * Test case 2: Variable processing with undefined variable - */ - it('should handle undefined variable safely', () => { - const varInputs = [{ variable: undefined }] - - expect(() => { - mockGetDependentVars(varInputs) - }).not.toThrow() - - const result = mockGetDependentVars(varInputs) - expect(result).toEqual([]) // undefined variables are filtered out - }) - - /** - * Test case 3: Variable processing with empty string - */ - it('should handle empty string variable', () => { - const varInputs = [{ variable: '' }] - - expect(() => { - mockGetDependentVars(varInputs) - }).not.toThrow() - - const result = mockGetDependentVars(varInputs) - expect(result).toEqual([]) // Empty string is filtered out, so result is empty array - }) - - /** - * Test case 4: Variable processing with valid variable format - */ - it('should work correctly with valid variable format', () => { - const varInputs = [{ variable: '{{workflow.node.output}}' }] - - expect(() => { - mockGetDependentVars(varInputs) - }).not.toThrow() - - const result = mockGetDependentVars(varInputs) - expect(result[0]).toEqual(['{workflow', 'node', 'output}']) - }) -}) - -/** - * Integration test to simulate the complete workflow scenario - */ -describe('Plugin Tool Workflow Integration', () => { - /** - * Simulate the scenario where plugin metadata is incomplete or corrupted - * This can happen when: - * 1. Plugin is being loaded from marketplace but metadata request fails - * 2. Plugin configuration is corrupted in database - * 3. Network issues during plugin loading - */ - it('should reproduce the client-side exception scenario', () => { - // Mock incomplete plugin data that could cause the error - const incompletePluginData = { - // Missing or null uniqueIdentifier - uniqueIdentifier: null, - meta: null, - minimum_dify_version: undefined, - } - - // This simulates the error path that leads to the white screen - expect(() => { - // Simulate the code path in switch-plugin-version.tsx:29 - // The actual problematic code doesn't use optional chaining - const _pluginId = (incompletePluginData.uniqueIdentifier as any).split(':')[0] - }).toThrow("Cannot read properties of null (reading 'split')") - }) - - /** - * Test the scenario mentioned in the issue where plugin tools are loaded in workflow - */ - it('should simulate plugin tool loading in workflow context', () => { - // Mock the workflow context where plugin tools are being loaded - const workflowPluginTools = [ - { - provider_name: 'test-plugin', - uniqueIdentifier: null, // This is the problematic case - tool_name: 'test-tool', - }, - { - provider_name: 'valid-plugin', - uniqueIdentifier: 'valid-plugin:1.0.0', - tool_name: 'valid-tool', - }, - ] - - // Process each plugin tool - workflowPluginTools.forEach((tool, _index) => { - if (tool.uniqueIdentifier === null) { - // This reproduces the exact error scenario - expect(() => { - const _pluginId = (tool.uniqueIdentifier as any).split(':')[0] - }).toThrow() - } else { - // Valid tools should work fine - expect(() => { - const _pluginId = tool.uniqueIdentifier.split(':')[0] - }).not.toThrow() - } - }) - }) -}) diff --git a/web/__tests__/plugins/plugin-auth-flow.test.tsx b/web/__tests__/plugins/plugin-auth-flow.test.tsx deleted file mode 100644 index 0a708acda65..00000000000 --- a/web/__tests__/plugins/plugin-auth-flow.test.tsx +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Integration Test: Plugin Authentication Flow - * - * Tests the integration between PluginAuth, usePluginAuth hook, - * Authorize/Authorized components, and credential management. - * Verifies the complete auth flow from checking authorization status - * to rendering the correct UI state. - */ -import { cleanup, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AuthCategory, CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' - -vi.mock('react-i18next', async () => { - const { withSelectorKey } = await import('@/test/i18n-mock') - return { - useTranslation: () => ({ - t: withSelectorKey((key: string) => { - const map: Record = { - 'plugin.auth.setUpTip': 'Set up your credentials', - 'plugin.auth.authorized': 'Authorized', - 'plugin.auth.apiKey': 'API Key', - 'plugin.auth.oauth': 'OAuth', - } - return map[key] ?? key - }), - }), - } -}) - -vi.mock('@langgenius/dify-ui/cn', () => ({ - cn: (...args: unknown[]) => args.filter(Boolean).join(' '), -})) - -const mockUsePluginAuth = vi.fn() -vi.mock('@/app/components/plugins/plugin-auth/hooks/use-plugin-auth', () => ({ - usePluginAuth: (...args: unknown[]) => mockUsePluginAuth(...args), -})) - -vi.mock('@/app/components/plugins/plugin-auth/authorize', () => ({ - default: ({ - pluginPayload, - canOAuth, - canApiKey, - }: { - pluginPayload: { provider: string } - canOAuth: boolean - canApiKey: boolean - }) => ( -
- {pluginPayload.provider} - {canOAuth && OAuth available} - {canApiKey && API Key available} -
- ), -})) - -vi.mock('@/app/components/plugins/plugin-auth/authorized', () => ({ - default: ({ - pluginPayload, - credentials, - }: { - pluginPayload: { provider: string } - credentials: Array<{ id: string; name: string }> - }) => ( -
- {pluginPayload.provider} - {credentials.length} credentials -
- ), -})) - -const { default: PluginAuth } = await import('@/app/components/plugins/plugin-auth/plugin-auth') - -describe('Plugin Authentication Flow Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - cleanup() - }) - - const basePayload = { - category: AuthCategory.tool, - provider: 'test-provider', - } - - describe('Unauthorized State', () => { - it('renders Authorize component when not authorized', () => { - mockUsePluginAuth.mockReturnValue({ - isAuthorized: false, - canOAuth: false, - canApiKey: true, - credentials: [], - invalidPluginCredentialInfo: vi.fn(), - notAllowCustomCredential: false, - }) - - render() - - expect(screen.getByTestId('authorize-component')).toBeInTheDocument() - expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument() - expect(screen.getByTestId('auth-apikey')).toBeInTheDocument() - }) - - it('shows OAuth option when plugin supports it', () => { - mockUsePluginAuth.mockReturnValue({ - isAuthorized: false, - canOAuth: true, - canApiKey: true, - credentials: [], - invalidPluginCredentialInfo: vi.fn(), - notAllowCustomCredential: false, - }) - - render() - - expect(screen.getByTestId('auth-oauth')).toBeInTheDocument() - expect(screen.getByTestId('auth-apikey')).toBeInTheDocument() - }) - - it('applies className to wrapper when not authorized', () => { - mockUsePluginAuth.mockReturnValue({ - isAuthorized: false, - canOAuth: false, - canApiKey: true, - credentials: [], - invalidPluginCredentialInfo: vi.fn(), - notAllowCustomCredential: false, - }) - - const { container } = render( - , - ) - - expect(container.firstChild).toHaveClass('custom-class') - }) - }) - - describe('Authorized State', () => { - it('renders Authorized component when authorized and no children', () => { - mockUsePluginAuth.mockReturnValue({ - isAuthorized: true, - canOAuth: false, - canApiKey: true, - credentials: [{ id: 'cred-1', name: 'My API Key', is_default: true }], - invalidPluginCredentialInfo: vi.fn(), - notAllowCustomCredential: false, - }) - - render() - - expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument() - expect(screen.getByTestId('authorized-component')).toBeInTheDocument() - expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('1 credentials') - }) - - it('renders children instead of Authorized when authorized and children provided', () => { - mockUsePluginAuth.mockReturnValue({ - isAuthorized: true, - canOAuth: false, - canApiKey: true, - credentials: [{ id: 'cred-1', name: 'Key', is_default: true }], - invalidPluginCredentialInfo: vi.fn(), - notAllowCustomCredential: false, - }) - - render( - -
Custom authorized view
-
, - ) - - expect(screen.queryByTestId('authorize-component')).not.toBeInTheDocument() - expect(screen.queryByTestId('authorized-component')).not.toBeInTheDocument() - expect(screen.getByTestId('custom-children')).toBeInTheDocument() - }) - - it('does not apply className when authorized', () => { - mockUsePluginAuth.mockReturnValue({ - isAuthorized: true, - canOAuth: false, - canApiKey: true, - credentials: [{ id: 'cred-1', name: 'Key', is_default: true }], - invalidPluginCredentialInfo: vi.fn(), - notAllowCustomCredential: false, - }) - - const { container } = render( - , - ) - - expect(container.firstChild).not.toHaveClass('custom-class') - }) - }) - - describe('Auth Category Integration', () => { - it('passes correct provider to usePluginAuth for tool category', () => { - mockUsePluginAuth.mockReturnValue({ - isAuthorized: false, - canOAuth: false, - canApiKey: true, - credentials: [], - invalidPluginCredentialInfo: vi.fn(), - notAllowCustomCredential: false, - }) - - const toolPayload = { - category: AuthCategory.tool, - provider: 'google-search-provider', - } - - render() - - expect(mockUsePluginAuth).toHaveBeenCalledWith(toolPayload, true) - expect(screen.getByTestId('auth-provider')).toHaveTextContent('google-search-provider') - }) - - it('passes correct provider to usePluginAuth for datasource category', () => { - mockUsePluginAuth.mockReturnValue({ - isAuthorized: false, - canOAuth: true, - canApiKey: false, - credentials: [], - invalidPluginCredentialInfo: vi.fn(), - notAllowCustomCredential: false, - }) - - const dsPayload = { - category: AuthCategory.datasource, - provider: 'notion-datasource', - } - - render() - - expect(mockUsePluginAuth).toHaveBeenCalledWith(dsPayload, true) - expect(screen.getByTestId('auth-oauth')).toBeInTheDocument() - expect(screen.queryByTestId('auth-apikey')).not.toBeInTheDocument() - }) - }) - - describe('Multiple Credentials', () => { - it('shows credential count when multiple credentials exist', () => { - mockUsePluginAuth.mockReturnValue({ - isAuthorized: true, - canOAuth: true, - canApiKey: true, - credentials: [ - { id: 'cred-1', name: 'API Key 1', is_default: true }, - { id: 'cred-2', name: 'API Key 2', is_default: false }, - { - id: 'cred-3', - name: 'OAuth Token', - is_default: false, - credential_type: CredentialTypeEnum.OAUTH2, - }, - ], - invalidPluginCredentialInfo: vi.fn(), - notAllowCustomCredential: false, - }) - - render() - - expect(screen.getByTestId('auth-credential-count')).toHaveTextContent('3 credentials') - }) - }) -}) diff --git a/web/__tests__/plugins/plugin-card-rendering.test.tsx b/web/__tests__/plugins/plugin-card-rendering.test.tsx deleted file mode 100644 index 1978a676ec7..00000000000 --- a/web/__tests__/plugins/plugin-card-rendering.test.tsx +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Integration Test: Plugin Card Rendering Pipeline - * - * Tests the integration between Card, Icon, Title, Description, - * OrgInfo, CornerMark, and CardMoreInfo components. Verifies that - * plugin data flows correctly through the card rendering pipeline. - */ -import { cleanup, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -let mockTheme = 'light' - -vi.mock('@/hooks/use-theme', () => ({ - default: () => ({ theme: mockTheme }), -})) - -vi.mock('@/i18n-config', () => ({ - renderI18nObject: (obj: Record, locale: string) => obj[locale] || obj.en_US || '', -})) - -vi.mock('@/types/app', async () => { - return vi.importActual('@/types/app') -}) - -vi.mock('@langgenius/dify-ui/cn', () => ({ - cn: (...args: unknown[]) => args.filter((a) => typeof a === 'string' && a).join(' '), -})) - -vi.mock('@/app/components/plugins/hooks', () => ({ - useCategories: () => ({ - categoriesMap: { - tool: { label: 'Tool' }, - model: { label: 'Model' }, - extension: { label: 'Extension' }, - }, - }), -})) - -vi.mock('@/app/components/plugins/base/badges/partner', () => ({ - default: () => Partner, -})) - -vi.mock('@/app/components/plugins/base/badges/verified', () => ({ - default: () => Verified, -})) - -vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ - default: ({ - src, - installed, - installFailed, - }: { - src: string | object - installed?: boolean - installFailed?: boolean - }) => ( -
- {typeof src === 'string' ? src : 'emoji-icon'} -
- ), -})) - -vi.mock('@/app/components/plugins/card/base/corner-mark', () => ({ - default: ({ text }: { text: string }) =>
{text}
, -})) - -vi.mock('@/app/components/plugins/card/base/description', () => ({ - default: ({ text, descriptionLineRows }: { text: string; descriptionLineRows?: number }) => ( -
- {text} -
- ), -})) - -vi.mock('@/app/components/plugins/card/base/org-info', () => ({ - default: ({ orgName, packageName }: { orgName: string; packageName: string }) => ( -
- {orgName}/{packageName} -
- ), -})) - -vi.mock('@/app/components/plugins/card/base/placeholder', () => ({ - default: ({ text }: { text: string }) =>
{text}
, -})) - -vi.mock('@/app/components/plugins/card/base/title', () => ({ - default: ({ title }: { title: string }) =>
{title}
, -})) - -const { default: Card } = await import('@/app/components/plugins/card/index') -type CardPayload = Parameters[0]['payload'] - -describe('Plugin Card Rendering Integration', () => { - beforeEach(() => { - cleanup() - mockTheme = 'light' - }) - - const makePayload = (overrides = {}) => - ({ - category: 'tool', - type: 'plugin', - name: 'google-search', - org: 'langgenius', - label: { en_US: 'Google Search', zh_Hans: 'Googleๆœ็ดข' }, - brief: { en_US: 'Search the web using Google', zh_Hans: 'ไฝฟ็”จGoogleๆœ็ดข็ฝ‘้กต' }, - icon: 'https://example.com/icon.png', - verified: true, - badges: [] as string[], - ...overrides, - }) as CardPayload - - it('renders a complete plugin card with all subcomponents', () => { - const payload = makePayload() - render() - - expect(screen.getByTestId('card-icon')).toBeInTheDocument() - expect(screen.getByTestId('title')).toHaveTextContent('Google Search') - expect(screen.getByTestId('org-info')).toHaveTextContent('langgenius/google-search') - expect(screen.getByTestId('description')).toHaveTextContent('Search the web using Google') - }) - - it('shows corner mark with category label when not hidden', () => { - const payload = makePayload() - render() - - expect(screen.getByTestId('corner-mark')).toBeInTheDocument() - }) - - it('hides corner mark when hideCornerMark is true', () => { - const payload = makePayload() - render() - - expect(screen.queryByTestId('corner-mark')).not.toBeInTheDocument() - }) - - it('shows installed status on icon', () => { - const payload = makePayload() - render() - - const icon = screen.getByTestId('card-icon') - expect(icon).toHaveAttribute('data-installed', 'true') - }) - - it('shows install failed status on icon', () => { - const payload = makePayload() - render() - - const icon = screen.getByTestId('card-icon') - expect(icon).toHaveAttribute('data-install-failed', 'true') - }) - - it('renders verified badge when plugin is verified', () => { - const payload = makePayload({ verified: true }) - render() - - expect(screen.getByTestId('verified-badge')).toBeInTheDocument() - }) - - it('renders partner badge when plugin has partner badge', () => { - const payload = makePayload({ badges: ['partner'] }) - render() - - expect(screen.getByTestId('partner-badge')).toBeInTheDocument() - }) - - it('renders footer content when provided', () => { - const payload = makePayload() - render(Custom footer
} />) - - expect(screen.getByTestId('custom-footer')).toBeInTheDocument() - }) - - it('renders titleLeft content when provided', () => { - const payload = makePayload() - render(New} />) - - expect(screen.getByTestId('title-left-content')).toBeInTheDocument() - }) - - it('uses dark icon when theme is dark and icon_dark is provided', () => { - mockTheme = 'dark' - - const payload = makePayload({ - icon: 'https://example.com/icon-light.png', - icon_dark: 'https://example.com/icon-dark.png', - }) - - render() - expect(screen.getByTestId('card-icon')).toHaveTextContent('https://example.com/icon-dark.png') - }) - - it('shows loading placeholder when isLoading is true', () => { - const payload = makePayload() - render() - - expect(screen.getByTestId('placeholder')).toBeInTheDocument() - }) - - it('renders description with custom line rows', () => { - const payload = makePayload() - render() - - const description = screen.getByTestId('description') - expect(description).toHaveAttribute('data-rows', '3') - }) -}) diff --git a/web/__tests__/plugins/plugin-install-flow.test.ts b/web/__tests__/plugins/plugin-install-flow.test.ts deleted file mode 100644 index f3eae0a5858..00000000000 --- a/web/__tests__/plugins/plugin-install-flow.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Integration Test: Plugin Installation Flow - * - * Tests the integration between GitHub release fetching, version comparison, - * upload handling, and task status polling. Verifies the complete plugin - * installation pipeline from source discovery to completion. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - checkForUpdates, - fetchReleases, - handleUpload, -} from '@/app/components/plugins/install-plugin/hooks' - -const mockToastNotify = vi.fn() -vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: Object.assign( - (message: string, options?: { type?: string }) => - mockToastNotify({ type: options?.type, message }), - { - success: (message: string) => mockToastNotify({ type: 'success', message }), - error: (message: string) => mockToastNotify({ type: 'error', message }), - warning: (message: string) => mockToastNotify({ type: 'warning', message }), - info: (message: string) => mockToastNotify({ type: 'info', message }), - dismiss: vi.fn(), - update: vi.fn(), - promise: vi.fn(), - }, - ), -})) - -const mockUploadGitHub = vi.fn() -vi.mock('@/service/plugins', () => ({ - uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args), - checkTaskStatus: vi.fn(), -})) - -describe('Plugin Installation Flow Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - globalThis.fetch = vi.fn() - }) - - describe('GitHub Release Discovery โ†’ Version Check โ†’ Upload Pipeline', () => { - it('fetches releases, checks for updates, and uploads the new version', async () => { - const mockReleases = [ - { - tag: 'v2.0.0', - assets: [{ downloadUrl: 'https://github.com/test/v2.difypkg' }], - }, - { - tag: 'v1.5.0', - assets: [{ downloadUrl: 'https://github.com/test/v1.5.difypkg' }], - }, - { - tag: 'v1.0.0', - assets: [{ downloadUrl: 'https://github.com/test/v1.difypkg' }], - }, - ] - - ;(globalThis.fetch as ReturnType).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ releases: mockReleases }), - }) - - mockUploadGitHub.mockResolvedValue({ - manifest: { name: 'test-plugin', version: '2.0.0' }, - unique_identifier: 'test-plugin:2.0.0', - }) - - const releases = await fetchReleases('test-org', 'test-repo') - expect(releases).toHaveLength(3) - expect(releases[0].tag_name).toBe('v2.0.0') - - const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0') - expect(needUpdate).toBe(true) - expect(toastProps.message).toContain('v2.0.0') - - const onSuccess = vi.fn() - const result = await handleUpload( - 'https://github.com/test-org/test-repo', - 'v2.0.0', - 'plugin-v2.difypkg', - onSuccess, - ) - - expect(mockUploadGitHub).toHaveBeenCalledWith( - 'https://github.com/test-org/test-repo', - 'v2.0.0', - 'plugin-v2.difypkg', - ) - expect(onSuccess).toHaveBeenCalledWith({ - manifest: { name: 'test-plugin', version: '2.0.0' }, - unique_identifier: 'test-plugin:2.0.0', - }) - expect(result).toEqual({ - manifest: { name: 'test-plugin', version: '2.0.0' }, - unique_identifier: 'test-plugin:2.0.0', - }) - }) - - it('handles no new version available', async () => { - const mockReleases = [ - { - tag: 'v1.0.0', - assets: [{ downloadUrl: 'https://github.com/test/v1.difypkg' }], - }, - ] - - ;(globalThis.fetch as ReturnType).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ releases: mockReleases }), - }) - - const releases = await fetchReleases('test-org', 'test-repo') - const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0') - - expect(needUpdate).toBe(false) - expect(toastProps.type).toBe('info') - expect(toastProps.message).toBe('No new version available') - }) - - it('handles empty releases', async () => { - ;(globalThis.fetch as ReturnType).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ releases: [] }), - }) - - const releases = await fetchReleases('test-org', 'test-repo') - expect(releases).toHaveLength(0) - - const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0') - expect(needUpdate).toBe(false) - expect(toastProps.type).toBe('error') - expect(toastProps.message).toBe('Input releases is empty') - }) - - it('handles fetch failure gracefully', async () => { - ;(globalThis.fetch as ReturnType).mockResolvedValue({ - ok: false, - status: 404, - }) - - const releases = await fetchReleases('nonexistent-org', 'nonexistent-repo') - - expect(releases).toEqual([]) - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) - - it('handles upload failure gracefully', async () => { - mockUploadGitHub.mockRejectedValue(new Error('Upload failed')) - - const onSuccess = vi.fn() - - await expect( - handleUpload('https://github.com/test/repo', 'v1.0.0', 'plugin.difypkg', onSuccess), - ).rejects.toThrow('Upload failed') - - expect(onSuccess).not.toHaveBeenCalled() - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error', message: 'Error uploading package' }), - ) - }) - }) - - describe('Task Status Polling Integration', () => { - it('polls until plugin installation succeeds', async () => { - const mockCheckTaskStatus = vi - .fn() - .mockResolvedValueOnce({ - task: { - plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'running' }], - }, - }) - .mockResolvedValueOnce({ - task: { - plugins: [{ plugin_unique_identifier: 'test:1.0.0', status: 'success' }], - }, - }) - - const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins') - ;(fetchCheckTaskStatus as ReturnType).mockImplementation(mockCheckTaskStatus) - - await vi.doMock('@/utils', () => ({ - sleep: () => Promise.resolve(), - })) - - const { default: checkTaskStatus } = - await import('@/app/components/plugins/install-plugin/base/check-task-status') - - const checker = checkTaskStatus() - const result = await checker.check({ - taskId: 'task-123', - pluginUniqueIdentifier: 'test:1.0.0', - }) - - expect(result.status).toBe('success') - }) - - it('returns failure when plugin not found in task', async () => { - const mockCheckTaskStatus = vi.fn().mockResolvedValue({ - task: { - plugins: [{ plugin_unique_identifier: 'other:1.0.0', status: 'success' }], - }, - }) - - const { checkTaskStatus: fetchCheckTaskStatus } = await import('@/service/plugins') - ;(fetchCheckTaskStatus as ReturnType).mockImplementation(mockCheckTaskStatus) - - const { default: checkTaskStatus } = - await import('@/app/components/plugins/install-plugin/base/check-task-status') - - const checker = checkTaskStatus() - const result = await checker.check({ - taskId: 'task-123', - pluginUniqueIdentifier: 'test:1.0.0', - }) - - expect(result.status).toBe('failed') - expect(result.error).toBe('Plugin package not found') - }) - - it('stops polling when stop() is called', async () => { - const { default: checkTaskStatus } = - await import('@/app/components/plugins/install-plugin/base/check-task-status') - - const checker = checkTaskStatus() - checker.stop() - - const result = await checker.check({ - taskId: 'task-123', - pluginUniqueIdentifier: 'test:1.0.0', - }) - - expect(result.status).toBe('success') - }) - }) -}) diff --git a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx deleted file mode 100644 index 591d1a2ac7b..00000000000 --- a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit' -import { InstallationScope } from '@/features/system-features/constants' - -describe('Plugin Marketplace to Install Flow', () => { - describe('install permission validation pipeline', () => { - const systemFeaturesAll = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.ALL, - }, - } - - const systemFeaturesMarketplaceOnly = { - plugin_installation_permission: { - restrict_to_marketplace_only: true, - plugin_installation_scope: InstallationScope.ALL, - }, - } - - const systemFeaturesOfficialOnly = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, - }, - } - - it('should allow marketplace plugin when all sources allowed', () => { - const plugin = { - from: 'marketplace' as const, - verification: { authorized_category: 'langgenius' }, - } - const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never) - expect(result.canInstall).toBe(true) - }) - - it('should allow github plugin when all sources allowed', () => { - const plugin = { - from: 'github' as const, - verification: { authorized_category: 'langgenius' }, - } - const result = pluginInstallLimit(plugin as never, systemFeaturesAll as never) - expect(result.canInstall).toBe(true) - }) - - it('should block github plugin when marketplace only', () => { - const plugin = { - from: 'github' as const, - verification: { authorized_category: 'langgenius' }, - } - const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never) - expect(result.canInstall).toBe(false) - }) - - it('should allow marketplace plugin when marketplace only', () => { - const plugin = { - from: 'marketplace' as const, - verification: { authorized_category: 'partner' }, - } - const result = pluginInstallLimit(plugin as never, systemFeaturesMarketplaceOnly as never) - expect(result.canInstall).toBe(true) - }) - - it('should allow official plugin when official only', () => { - const plugin = { - from: 'marketplace' as const, - verification: { authorized_category: 'langgenius' }, - } - const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never) - expect(result.canInstall).toBe(true) - }) - - it('should block community plugin when official only', () => { - const plugin = { - from: 'marketplace' as const, - verification: { authorized_category: 'community' }, - } - const result = pluginInstallLimit(plugin as never, systemFeaturesOfficialOnly as never) - expect(result.canInstall).toBe(false) - }) - }) - - describe('plugin source classification', () => { - it('should correctly classify plugin install sources', () => { - const sources = ['marketplace', 'github', 'package'] as const - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: true, - plugin_installation_scope: InstallationScope.ALL, - }, - } - - const results = sources.map((source) => ({ - source, - canInstall: pluginInstallLimit( - { from: source, verification: { authorized_category: 'langgenius' } } as never, - features as never, - ).canInstall, - })) - - expect(results.find((r) => r.source === 'marketplace')?.canInstall).toBe(true) - expect(results.find((r) => r.source === 'github')?.canInstall).toBe(false) - expect(results.find((r) => r.source === 'package')?.canInstall).toBe(false) - }) - }) -}) diff --git a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx deleted file mode 100644 index 701fe138159..00000000000 --- a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import type { ReactNode } from 'react' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' -import PluginPage from '@/app/components/plugins/plugin-page' -import { createNuqsTestWrapper } from '@/test/nuqs-testing' - -const mockFetchManifestFromMarketPlace = vi.fn() -const { mockRouterReplace } = vi.hoisted(() => ({ - mockRouterReplace: vi.fn(), -})) -vi.mock('@/utils', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - sleep: vi.fn(() => Promise.resolve()), - } -}) - -vi.mock('@/hooks/use-document-title', () => ({ - __esModule: true, - default: vi.fn(), -})) - -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ - replace: mockRouterReplace, - }), -})) - -vi.mock('@/context/account-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateAtomMock(importOriginal, () => ({ - isCurrentWorkspaceManager: false, - isCurrentWorkspaceOwner: false, - langGeniusVersionInfo: { - current_env: 'CLOUD', - current_version: '1.0.0', - latest_version: '1.0.0', - version: '1.0.0', - release_date: '', - release_notes: '', - can_auto_update: false, - }, - workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.plugin_preferences'], - })) -}) -vi.mock('@/context/workspace-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateAtomMock(importOriginal, () => ({ - isCurrentWorkspaceManager: false, - isCurrentWorkspaceOwner: false, - langGeniusVersionInfo: { - current_env: 'CLOUD', - current_version: '1.0.0', - latest_version: '1.0.0', - version: '1.0.0', - release_date: '', - release_notes: '', - can_auto_update: false, - }, - workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.plugin_preferences'], - })) -}) -vi.mock('@/context/permission-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateAtomMock(importOriginal, () => ({ - isCurrentWorkspaceManager: false, - isCurrentWorkspaceOwner: false, - langGeniusVersionInfo: { - current_env: 'CLOUD', - current_version: '1.0.0', - latest_version: '1.0.0', - version: '1.0.0', - release_date: '', - release_notes: '', - can_auto_update: false, - }, - workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.plugin_preferences'], - })) -}) -vi.mock('@/context/version-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateAtomMock(importOriginal, () => ({ - isCurrentWorkspaceManager: false, - isCurrentWorkspaceOwner: false, - langGeniusVersionInfo: { - current_env: 'CLOUD', - current_version: '1.0.0', - latest_version: '1.0.0', - version: '1.0.0', - release_date: '', - release_notes: '', - can_auto_update: false, - }, - workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.plugin_preferences'], - })) -}) -vi.mock('@/context/system-features-state', async (importOriginal) => { - const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateAtomMock(importOriginal, () => ({ - isCurrentWorkspaceManager: false, - isCurrentWorkspaceOwner: false, - langGeniusVersionInfo: { - current_env: 'CLOUD', - current_version: '1.0.0', - latest_version: '1.0.0', - version: '1.0.0', - release_date: '', - release_notes: '', - can_auto_update: false, - }, - workspacePermissionKeys: ['plugin.install', 'plugin.delete', 'plugin.plugin_preferences'], - })) -}) - -vi.mock('jotai', async (importOriginal) => { - const { createAppContextStateJotaiMock } = - await import('@/__tests__/utils/mock-app-context-state') - return createAppContextStateJotaiMock(importOriginal) -}) - -vi.mock('@/service/use-plugins', () => ({ - hasPluginPermission: () => true, - useReferenceSettings: () => ({ - data: { - permission: { - install_permission: 'everyone', - debug_permission: 'noOne', - }, - }, - }), - useMutationReferenceSettings: () => ({ - mutate: vi.fn(), - isPending: false, - }), - useInvalidateReferenceSettings: () => vi.fn(), - usePluginPermissionSettings: () => ({ - data: { - install_permission: 'everyone', - debug_permission: 'noOne', - }, - }), - useMutationPluginPermissionSettings: () => ({ - mutate: vi.fn(), - isPending: false, - }), - usePluginAutoUpgradeSettings: () => ({ - data: { - auto_upgrade: false, - strategy_setting: {}, - exclude_plugins: [], - include_plugins: [], - }, - }), - useInstalledPluginList: () => ({ - data: { - total: 2, - }, - }), -})) - -vi.mock('@/service/plugins', () => ({ - fetchManifestFromMarketPlace: (...args: unknown[]) => mockFetchManifestFromMarketPlace(...args), - fetchBundleInfoFromMarketPlace: vi.fn(), -})) - -vi.mock('@/app/components/plugins/plugin-page/plugin-tasks', () => ({ - default: () =>
plugin tasks
, -})) - -vi.mock('@/app/components/plugins/plugin-page/debug-info', () => ({ - default: () =>
debug info
, -})) - -vi.mock('@/app/components/plugins/plugin-page/install-plugin-dropdown', () => ({ - default: ({ onSwitchToMarketplaceTab }: { onSwitchToMarketplaceTab: () => void }) => ( - - ), -})) - -vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ - default: ({ uniqueIdentifier, onClose }: { uniqueIdentifier: string; onClose: () => void }) => ( -
- {uniqueIdentifier} - -
- ), -})) - -const renderPluginPage = (searchParams = '') => { - const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ - systemFeatures: { - enable_marketplace: true, - plugin_installation_permission: { - restrict_to_marketplace_only: false, - }, - }, - }) - const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) - const Wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - - ) - return { - ...render( - plugins view
} - marketplace={
marketplace view
} - />, - { wrapper: Wrapper }, - ), - onUrlUpdate, - } -} - -describe('Plugin Page Shell Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFetchManifestFromMarketPlace.mockResolvedValue({ - data: { - plugin: { - org: 'langgenius', - name: 'plugin-demo', - }, - version: { - version: '1.0.0', - }, - }, - }) - }) - - it('switches from installed plugins to marketplace and syncs the active tab into the URL', async () => { - const { onUrlUpdate } = renderPluginPage() - - expect(screen.getByTestId('plugins-view'))!.toBeInTheDocument() - expect(screen.queryByTestId('marketplace-view')).not.toBeInTheDocument() - - fireEvent.click(screen.getByTestId('tab-item-discover')) - - await waitFor(() => { - expect(screen.getByTestId('marketplace-view'))!.toBeInTheDocument() - }) - - const tabUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(tabUpdate.searchParams.get('tab')).toBe('discover') - }) - - it('hydrates marketplace installation from query params and clears the install state when closed', async () => { - const { onUrlUpdate } = renderPluginPage('?package-ids=%5B%22langgenius%2Fplugin-demo%22%5D') - - await waitFor(() => { - expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith('langgenius%2Fplugin-demo') - expect(screen.getByTestId('install-from-marketplace-modal'))!.toBeInTheDocument() - }) - - fireEvent.click(screen.getByRole('button', { name: 'close-install-modal' })) - - await waitFor(() => { - const clearUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0] - expect(clearUpdate.searchParams.has('package-ids')).toBe(false) - }) - }) -}) diff --git a/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts b/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts deleted file mode 100644 index d557bfcd7b8..00000000000 --- a/web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Integration test: Chunk preview formatting pipeline - * - * Tests the formatPreviewChunks utility across all chunking modes - * (text, parentChild, QA) with real data structures. - */ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@/config', () => ({ - RAG_PIPELINE_PREVIEW_CHUNK_NUM: 3, -})) - -vi.mock('@/models/datasets', () => ({ - ChunkingMode: { - text: 'text', - parentChild: 'parent-child', - qa: 'qa', - }, -})) - -const { formatPreviewChunks } = - await import('@/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils') - -describe('Chunk Preview Formatting', () => { - describe('general text chunks', () => { - it('should format text chunks correctly', () => { - const outputs = { - chunk_structure: 'text', - preview: [ - { content: 'Chunk 1 content', summary: 'Summary 1' }, - { content: 'Chunk 2 content' }, - ], - } - - const result = formatPreviewChunks(outputs) - - expect(Array.isArray(result)).toBe(true) - const chunks = result as Array<{ content: string; summary?: string }> - expect(chunks).toHaveLength(2) - expect(chunks[0]!.content).toBe('Chunk 1 content') - expect(chunks[0]!.summary).toBe('Summary 1') - expect(chunks[1]!.content).toBe('Chunk 2 content') - }) - - it('should limit chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM', () => { - const outputs = { - chunk_structure: 'text', - preview: Array.from({ length: 10 }, (_, i) => ({ - content: `Chunk ${i + 1}`, - })), - } - - const result = formatPreviewChunks(outputs) - const chunks = result as Array<{ content: string }> - - expect(chunks).toHaveLength(3) // Mocked limit - }) - }) - - describe('parent-child chunks โ€” paragraph mode', () => { - it('should format paragraph parent-child chunks', () => { - const outputs = { - chunk_structure: 'parent-child', - parent_mode: 'paragraph', - preview: [ - { - content: 'Parent paragraph', - child_chunks: ['Child 1', 'Child 2'], - summary: 'Parent summary', - }, - ], - } - - const result = formatPreviewChunks(outputs) as { - parent_child_chunks: Array<{ - parent_content: string - parent_summary?: string - child_contents: string[] - parent_mode: string - }> - parent_mode: string - } - - expect(result.parent_mode).toBe('paragraph') - expect(result.parent_child_chunks).toHaveLength(1) - expect(result.parent_child_chunks[0]!.parent_content).toBe('Parent paragraph') - expect(result.parent_child_chunks[0]!.parent_summary).toBe('Parent summary') - expect(result.parent_child_chunks[0]!.child_contents).toEqual(['Child 1', 'Child 2']) - }) - - it('should limit parent chunks in paragraph mode', () => { - const outputs = { - chunk_structure: 'parent-child', - parent_mode: 'paragraph', - preview: Array.from({ length: 10 }, (_, i) => ({ - content: `Parent ${i + 1}`, - child_chunks: [`Child of ${i + 1}`], - })), - } - - const result = formatPreviewChunks(outputs) as { - parent_child_chunks: unknown[] - } - - expect(result.parent_child_chunks).toHaveLength(3) // Mocked limit - }) - }) - - describe('parent-child chunks โ€” full-doc mode', () => { - it('should format full-doc parent-child chunks', () => { - const outputs = { - chunk_structure: 'parent-child', - parent_mode: 'full-doc', - preview: [ - { - content: 'Full document content', - child_chunks: ['Section 1', 'Section 2', 'Section 3'], - }, - ], - } - - const result = formatPreviewChunks(outputs) as { - parent_child_chunks: Array<{ - parent_content: string - child_contents: string[] - parent_mode: string - }> - } - - expect(result.parent_child_chunks).toHaveLength(1) - expect(result.parent_child_chunks[0]!.parent_content).toBe('Full document content') - expect(result.parent_child_chunks[0]!.parent_mode).toBe('full-doc') - }) - - it('should limit child chunks in full-doc mode', () => { - const outputs = { - chunk_structure: 'parent-child', - parent_mode: 'full-doc', - preview: [ - { - content: 'Document', - child_chunks: Array.from({ length: 20 }, (_, i) => `Section ${i + 1}`), - }, - ], - } - - const result = formatPreviewChunks(outputs) as { - parent_child_chunks: Array<{ child_contents: string[] }> - } - - expect(result.parent_child_chunks[0]!.child_contents).toHaveLength(3) // Mocked limit - }) - }) - - describe('QA chunks', () => { - it('should format QA chunks correctly', () => { - const outputs = { - chunk_structure: 'qa', - qa_preview: [ - { question: 'What is AI?', answer: 'Artificial Intelligence is...' }, - { question: 'What is ML?', answer: 'Machine Learning is...' }, - ], - } - - const result = formatPreviewChunks(outputs) as { - qa_chunks: Array<{ question: string; answer: string }> - } - - expect(result.qa_chunks).toHaveLength(2) - expect(result.qa_chunks[0]!.question).toBe('What is AI?') - expect(result.qa_chunks[0]!.answer).toBe('Artificial Intelligence is...') - }) - - it('should limit QA chunks', () => { - const outputs = { - chunk_structure: 'qa', - qa_preview: Array.from({ length: 10 }, (_, i) => ({ - question: `Q${i + 1}`, - answer: `A${i + 1}`, - })), - } - - const result = formatPreviewChunks(outputs) as { - qa_chunks: unknown[] - } - - expect(result.qa_chunks).toHaveLength(3) // Mocked limit - }) - }) - - describe('edge cases', () => { - it('should return undefined for null outputs', () => { - expect(formatPreviewChunks(null)).toBeUndefined() - }) - - it('should return undefined for undefined outputs', () => { - expect(formatPreviewChunks(undefined)).toBeUndefined() - }) - - it('should return undefined for unknown chunk_structure', () => { - const outputs = { - chunk_structure: 'unknown-type', - preview: [], - } - - expect(formatPreviewChunks(outputs)).toBeUndefined() - }) - }) -}) diff --git a/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts b/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts deleted file mode 100644 index 887c98eee11..00000000000 --- a/web/__tests__/rag-pipeline/input-field-crud-flow.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Integration test: Input field CRUD complete flow - * - * Validates the full lifecycle of input fields: - * creation, editing, renaming, removal, and data conversion round-trip. - */ -import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types' -import type { InputVar } from '@/models/pipeline' -import { describe, expect, it, vi } from 'vitest' -import { SupportUploadFileTypes } from '@/app/components/workflow/types' -import { PipelineInputVarType } from '@/models/pipeline' -import { TransferMethod } from '@/types/app' - -vi.mock('@/config', () => ({ - VAR_ITEM_TEMPLATE_IN_PIPELINE: { - type: 'text-input', - label: '', - variable: '', - max_length: 48, - default_value: undefined, - required: true, - tooltips: undefined, - options: [], - placeholder: undefined, - unit: undefined, - allowed_file_upload_methods: undefined, - allowed_file_types: undefined, - allowed_file_extensions: undefined, - }, -})) - -describe('Input Field CRUD Flow', () => { - describe('Create โ†’ Edit โ†’ Convert Round-trip', () => { - it('should create a text field and roundtrip through form data', async () => { - const { convertToInputFieldFormData, convertFormDataToINputField } = - await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') - - // Create new field from template (no data passed) - const newFormData = convertToInputFieldFormData() - expect(newFormData.type).toBe('text-input') - expect(newFormData.variable).toBe('') - expect(newFormData.label).toBe('') - expect(newFormData.required).toBe(true) - - // Simulate user editing form data - const editedFormData: FormData = { - ...newFormData, - variable: 'user_name', - label: 'User Name', - maxLength: 100, - default: 'John', - tooltips: 'Enter your name', - placeholder: 'Type here...', - allowedTypesAndExtensions: {}, - } - - // Convert back to InputVar - const inputVar = convertFormDataToINputField(editedFormData) - - expect(inputVar.variable).toBe('user_name') - expect(inputVar.label).toBe('User Name') - expect(inputVar.max_length).toBe(100) - expect(inputVar.default_value).toBe('John') - expect(inputVar.tooltips).toBe('Enter your name') - expect(inputVar.placeholder).toBe('Type here...') - expect(inputVar.required).toBe(true) - }) - - it('should handle file field with upload settings', async () => { - const { convertToInputFieldFormData, convertFormDataToINputField } = - await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') - - const fileInputVar: InputVar = { - type: PipelineInputVarType.singleFile, - label: 'Upload Document', - variable: 'doc_file', - max_length: 1, - default_value: undefined, - required: true, - tooltips: 'Upload a PDF', - options: [], - placeholder: undefined, - unit: undefined, - allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], - allowed_file_types: [SupportUploadFileTypes.document], - allowed_file_extensions: ['.pdf', '.docx'], - } - - // Convert to form data - const formData = convertToInputFieldFormData(fileInputVar) - expect(formData.allowedFileUploadMethods).toEqual([ - TransferMethod.local_file, - TransferMethod.remote_url, - ]) - expect(formData.allowedTypesAndExtensions).toEqual({ - allowedFileTypes: [SupportUploadFileTypes.document], - allowedFileExtensions: ['.pdf', '.docx'], - }) - - // Round-trip back - const restored = convertFormDataToINputField(formData) - expect(restored.allowed_file_upload_methods).toEqual([ - TransferMethod.local_file, - TransferMethod.remote_url, - ]) - expect(restored.allowed_file_types).toEqual([SupportUploadFileTypes.document]) - expect(restored.allowed_file_extensions).toEqual(['.pdf', '.docx']) - }) - - it('should handle select field with options', async () => { - const { convertToInputFieldFormData, convertFormDataToINputField } = - await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') - - const selectVar: InputVar = { - type: PipelineInputVarType.select, - label: 'Priority', - variable: 'priority', - max_length: 0, - default_value: 'medium', - required: false, - tooltips: 'Select priority level', - options: ['low', 'medium', 'high'], - placeholder: 'Choose...', - unit: undefined, - allowed_file_upload_methods: undefined, - allowed_file_types: undefined, - allowed_file_extensions: undefined, - } - - const formData = convertToInputFieldFormData(selectVar) - expect(formData.options).toEqual(['low', 'medium', 'high']) - expect(formData.default).toBe('medium') - - const restored = convertFormDataToINputField(formData) - expect(restored.options).toEqual(['low', 'medium', 'high']) - expect(restored.default_value).toBe('medium') - }) - - it('should handle number field with unit', async () => { - const { convertToInputFieldFormData, convertFormDataToINputField } = - await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') - - const numberVar: InputVar = { - type: PipelineInputVarType.number, - label: 'Max Tokens', - variable: 'max_tokens', - max_length: 0, - default_value: '1024', - required: true, - tooltips: undefined, - options: [], - placeholder: undefined, - unit: 'tokens', - allowed_file_upload_methods: undefined, - allowed_file_types: undefined, - allowed_file_extensions: undefined, - } - - const formData = convertToInputFieldFormData(numberVar) - expect(formData.unit).toBe('tokens') - expect(formData.default).toBe('1024') - - const restored = convertFormDataToINputField(formData) - expect(restored.unit).toBe('tokens') - expect(restored.default_value).toBe('1024') - }) - }) - - describe('Omit optional fields', () => { - it('should not include tooltips when undefined', async () => { - const { convertToInputFieldFormData } = - await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') - - const inputVar: InputVar = { - type: PipelineInputVarType.textInput, - label: 'Test', - variable: 'test', - max_length: 48, - default_value: undefined, - required: true, - tooltips: undefined, - options: [], - placeholder: undefined, - unit: undefined, - allowed_file_upload_methods: undefined, - allowed_file_types: undefined, - allowed_file_extensions: undefined, - } - - const formData = convertToInputFieldFormData(inputVar) - - // Optional fields should not be present - expect('tooltips' in formData).toBe(false) - expect('placeholder' in formData).toBe(false) - expect('unit' in formData).toBe(false) - expect('default' in formData).toBe(false) - }) - - it('should include optional fields when explicitly set to empty string', async () => { - const { convertToInputFieldFormData } = - await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') - - const inputVar: InputVar = { - type: PipelineInputVarType.textInput, - label: 'Test', - variable: 'test', - max_length: 48, - default_value: '', - required: true, - tooltips: '', - options: [], - placeholder: '', - unit: '', - allowed_file_upload_methods: undefined, - allowed_file_types: undefined, - allowed_file_extensions: undefined, - } - - const formData = convertToInputFieldFormData(inputVar) - - expect(formData.default).toBe('') - expect(formData.tooltips).toBe('') - expect(formData.placeholder).toBe('') - expect(formData.unit).toBe('') - }) - }) - - describe('Multiple fields workflow', () => { - it('should process multiple fields independently', async () => { - const { convertToInputFieldFormData, convertFormDataToINputField } = - await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') - - const fields: InputVar[] = [ - { - type: PipelineInputVarType.textInput, - label: 'Name', - variable: 'name', - max_length: 48, - default_value: 'Alice', - required: true, - tooltips: undefined, - options: [], - placeholder: undefined, - unit: undefined, - allowed_file_upload_methods: undefined, - allowed_file_types: undefined, - allowed_file_extensions: undefined, - }, - { - type: PipelineInputVarType.number, - label: 'Count', - variable: 'count', - max_length: 0, - default_value: '10', - required: false, - tooltips: undefined, - options: [], - placeholder: undefined, - unit: 'items', - allowed_file_upload_methods: undefined, - allowed_file_types: undefined, - allowed_file_extensions: undefined, - }, - ] - - const formDataList = fields.map((f) => convertToInputFieldFormData(f)) - const restoredFields = formDataList.map((fd) => convertFormDataToINputField(fd)) - - expect(restoredFields).toHaveLength(2) - expect(restoredFields[0]!.variable).toBe('name') - expect(restoredFields[0]!.default_value).toBe('Alice') - expect(restoredFields[1]!.variable).toBe('count') - expect(restoredFields[1]!.default_value).toBe('10') - expect(restoredFields[1]!.unit).toBe('items') - }) - }) -}) diff --git a/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts b/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts deleted file mode 100644 index 14fc21bb03e..00000000000 --- a/web/__tests__/rag-pipeline/input-field-editor-flow.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Integration test: Input field editor data conversion flow - * - * Tests the full pipeline: InputVar -> FormData -> InputVar roundtrip - * and schema validation for various input types. - */ -import type { InputVar } from '@/models/pipeline' -import { describe, expect, it, vi } from 'vitest' -import { PipelineInputVarType } from '@/models/pipeline' - -// Mock the config module for VAR_ITEM_TEMPLATE_IN_PIPELINE -vi.mock('@/config', () => ({ - VAR_ITEM_TEMPLATE_IN_PIPELINE: { - type: 'text-input', - label: '', - variable: '', - max_length: 48, - required: false, - options: [], - allowed_file_upload_methods: [], - allowed_file_types: [], - allowed_file_extensions: [], - }, - MAX_VAR_KEY_LENGTH: 30, - RAG_PIPELINE_PREVIEW_CHUNK_NUM: 10, -})) - -// Import real functions (not mocked) -const { convertToInputFieldFormData, convertFormDataToINputField } = - await import('@/app/components/rag-pipeline/components/panel/input-field/editor/utils') - -describe('Input Field Editor Data Flow', () => { - describe('convertToInputFieldFormData', () => { - it('should convert a text input InputVar to FormData', () => { - const inputVar: InputVar = { - type: 'text-input', - label: 'Name', - variable: 'user_name', - max_length: 100, - required: true, - default_value: 'John', - tooltips: 'Enter your name', - placeholder: 'Type here...', - options: [], - } as InputVar - - const formData = convertToInputFieldFormData(inputVar) - - expect(formData.type).toBe('text-input') - expect(formData.label).toBe('Name') - expect(formData.variable).toBe('user_name') - expect(formData.maxLength).toBe(100) - expect(formData.required).toBe(true) - expect(formData.default).toBe('John') - expect(formData.tooltips).toBe('Enter your name') - expect(formData.placeholder).toBe('Type here...') - }) - - it('should handle file input with upload settings', () => { - const inputVar: InputVar = { - type: 'file', - label: 'Document', - variable: 'doc', - required: false, - allowed_file_upload_methods: ['local_file', 'remote_url'], - allowed_file_types: ['document', 'image'], - allowed_file_extensions: ['.pdf', '.jpg'], - options: [], - } as InputVar - - const formData = convertToInputFieldFormData(inputVar) - - expect(formData.allowedFileUploadMethods).toEqual(['local_file', 'remote_url']) - expect(formData.allowedTypesAndExtensions).toEqual({ - allowedFileTypes: ['document', 'image'], - allowedFileExtensions: ['.pdf', '.jpg'], - }) - }) - - it('should use template defaults when no data provided', () => { - const formData = convertToInputFieldFormData(undefined) - - expect(formData.type).toBe('text-input') - expect(formData.maxLength).toBe(48) - expect(formData.required).toBe(false) - }) - - it('should omit undefined/null optional fields', () => { - const inputVar: InputVar = { - type: 'text-input', - label: 'Simple', - variable: 'simple_var', - max_length: 50, - required: false, - options: [], - } as InputVar - - const formData = convertToInputFieldFormData(inputVar) - - expect(formData.default).toBeUndefined() - expect(formData.tooltips).toBeUndefined() - expect(formData.placeholder).toBeUndefined() - expect(formData.unit).toBeUndefined() - }) - }) - - describe('convertFormDataToINputField', () => { - it('should convert FormData back to InputVar', () => { - const formData = { - type: PipelineInputVarType.textInput, - label: 'Name', - variable: 'user_name', - maxLength: 100, - required: true, - default: 'John', - tooltips: 'Enter your name', - options: [], - placeholder: 'Type here...', - allowedTypesAndExtensions: { - allowedFileTypes: undefined, - allowedFileExtensions: undefined, - }, - } - - const inputVar = convertFormDataToINputField(formData) - - expect(inputVar.type).toBe('text-input') - expect(inputVar.label).toBe('Name') - expect(inputVar.variable).toBe('user_name') - expect(inputVar.max_length).toBe(100) - expect(inputVar.required).toBe(true) - expect(inputVar.default_value).toBe('John') - expect(inputVar.tooltips).toBe('Enter your name') - }) - }) - - describe('roundtrip conversion', () => { - it('should preserve text input data through roundtrip', () => { - const original: InputVar = { - type: 'text-input', - label: 'Question', - variable: 'question', - max_length: 200, - required: true, - default_value: 'What is AI?', - tooltips: 'Enter your question', - placeholder: 'Ask something...', - options: [], - } as InputVar - - const formData = convertToInputFieldFormData(original) - const restored = convertFormDataToINputField(formData) - - expect(restored.type).toBe(original.type) - expect(restored.label).toBe(original.label) - expect(restored.variable).toBe(original.variable) - expect(restored.max_length).toBe(original.max_length) - expect(restored.required).toBe(original.required) - expect(restored.default_value).toBe(original.default_value) - expect(restored.tooltips).toBe(original.tooltips) - expect(restored.placeholder).toBe(original.placeholder) - }) - - it('should preserve number input data through roundtrip', () => { - const original = { - type: 'number', - label: 'Temperature', - variable: 'temp', - required: false, - default_value: '0.7', - unit: 'ยฐC', - options: [], - } as InputVar - - const formData = convertToInputFieldFormData(original) - const restored = convertFormDataToINputField(formData) - - expect(restored.type).toBe('number') - expect(restored.unit).toBe('ยฐC') - expect(restored.default_value).toBe('0.7') - }) - - it('should preserve select options through roundtrip', () => { - const original: InputVar = { - type: 'select', - label: 'Mode', - variable: 'mode', - required: true, - options: ['fast', 'balanced', 'quality'], - } as InputVar - - const formData = convertToInputFieldFormData(original) - const restored = convertFormDataToINputField(formData) - - expect(restored.options).toEqual(['fast', 'balanced', 'quality']) - }) - }) -}) diff --git a/web/__tests__/rag-pipeline/test-run-flow.test.ts b/web/__tests__/rag-pipeline/test-run-flow.test.ts deleted file mode 100644 index bd24030e1b3..00000000000 --- a/web/__tests__/rag-pipeline/test-run-flow.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Integration test: Test run end-to-end flow - * - * Validates the data flow through test-run preparation hooks: - * step navigation, datasource filtering, and data clearing. - */ -import { act, renderHook } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import { BlockEnum } from '@/app/components/workflow/types' - -// Mutable holder so mock data can reference BlockEnum after imports -const mockNodesHolder = vi.hoisted(() => ({ value: [] as Record[] })) - -vi.mock('reactflow', () => ({ - useNodes: () => mockNodesHolder.value, -})) - -mockNodesHolder.value = [ - { - id: 'ds-1', - data: { - type: BlockEnum.DataSource, - title: 'Local Files', - datasource_type: 'upload_file', - datasource_configurations: { datasource_label: 'Upload', upload_file_config: {} }, - }, - }, - { - id: 'ds-2', - data: { - type: BlockEnum.DataSource, - title: 'Web Crawl', - datasource_type: 'website_crawl', - datasource_configurations: { datasource_label: 'Crawl' }, - }, - }, - { - id: 'kb-1', - data: { - type: BlockEnum.KnowledgeBase, - title: 'Knowledge Base', - }, - }, -] - -// Mock the Zustand store used by the hooks -const mockSetDocumentsData = vi.fn() -const mockSetSearchValue = vi.fn() -const mockSetSelectedPagesId = vi.fn() -const mockSetOnlineDocuments = vi.fn() -const mockSetCurrentDocument = vi.fn() -const mockSetStep = vi.fn() -const mockSetCrawlResult = vi.fn() -const mockSetWebsitePages = vi.fn() -const mockSetPreviewIndex = vi.fn() -const mockSetCurrentWebsite = vi.fn() -const mockSetOnlineDriveFileList = vi.fn() -const mockSetBucket = vi.fn() -const mockSetPrefix = vi.fn() -const mockSetKeywords = vi.fn() -const mockSetSelectedFileIds = vi.fn() - -vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({ - useDataSourceStore: () => ({ - getState: () => ({ - setDocumentsData: mockSetDocumentsData, - setSearchValue: mockSetSearchValue, - setSelectedPagesId: mockSetSelectedPagesId, - setOnlineDocuments: mockSetOnlineDocuments, - setCurrentDocument: mockSetCurrentDocument, - setStep: mockSetStep, - setCrawlResult: mockSetCrawlResult, - setWebsitePages: mockSetWebsitePages, - setPreviewIndex: mockSetPreviewIndex, - setCurrentWebsite: mockSetCurrentWebsite, - setOnlineDriveFileList: mockSetOnlineDriveFileList, - setBucket: mockSetBucket, - setPrefix: mockSetPrefix, - setKeywords: mockSetKeywords, - setSelectedFileIds: mockSetSelectedFileIds, - }), - }), -})) - -vi.mock('@/models/datasets', () => ({ - CrawlStep: { - init: 'init', - }, -})) - -describe('Test Run Flow Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Step Navigation', () => { - it('should start at step 1 and navigate forward', async () => { - const { useTestRunSteps } = - await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') - const { result } = renderHook(() => useTestRunSteps()) - - expect(result.current.currentStep).toBe(1) - - act(() => { - result.current.handleNextStep() - }) - - expect(result.current.currentStep).toBe(2) - }) - - it('should navigate back from step 2 to step 1', async () => { - const { useTestRunSteps } = - await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') - const { result } = renderHook(() => useTestRunSteps()) - - act(() => { - result.current.handleNextStep() - }) - expect(result.current.currentStep).toBe(2) - - act(() => { - result.current.handleBackStep() - }) - expect(result.current.currentStep).toBe(1) - }) - - it('should provide labeled steps', async () => { - const { useTestRunSteps } = - await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') - const { result } = renderHook(() => useTestRunSteps()) - - expect(result.current.steps).toHaveLength(2) - expect(result.current.steps[0]!.value).toBe('dataSource') - expect(result.current.steps[1]!.value).toBe('documentProcessing') - }) - }) - - describe('Datasource Options', () => { - it('should filter nodes to only DataSource type', async () => { - const { useDatasourceOptions } = - await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') - const { result } = renderHook(() => useDatasourceOptions()) - - // Should only include DataSource nodes, not KnowledgeBase - expect(result.current).toHaveLength(2) - expect(result.current[0]!.value).toBe('ds-1') - expect(result.current[1]!.value).toBe('ds-2') - }) - - it('should include node data in options', async () => { - const { useDatasourceOptions } = - await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') - const { result } = renderHook(() => useDatasourceOptions()) - - expect(result.current[0]!.label).toBe('Local Files') - expect(result.current[0]!.data.type).toBe(BlockEnum.DataSource) - }) - }) - - describe('Data Clearing Flow', () => { - it('should clear online document data', async () => { - const { useOnlineDocument } = - await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') - const { result } = renderHook(() => useOnlineDocument()) - - act(() => { - result.current.clearOnlineDocumentData() - }) - - expect(mockSetDocumentsData).toHaveBeenCalledWith([]) - expect(mockSetSearchValue).toHaveBeenCalledWith('') - expect(mockSetSelectedPagesId).toHaveBeenCalledWith(expect.any(Set)) - expect(mockSetOnlineDocuments).toHaveBeenCalledWith([]) - expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined) - }) - - it('should clear website crawl data', async () => { - const { useWebsiteCrawl } = - await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') - const { result } = renderHook(() => useWebsiteCrawl()) - - act(() => { - result.current.clearWebsiteCrawlData() - }) - - expect(mockSetStep).toHaveBeenCalledWith('init') - expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined) - expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined) - expect(mockSetWebsitePages).toHaveBeenCalledWith([]) - expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1) - }) - - it('should clear online drive data', async () => { - const { useOnlineDrive } = - await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') - const { result } = renderHook(() => useOnlineDrive()) - - act(() => { - result.current.clearOnlineDriveData() - }) - - expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([]) - expect(mockSetBucket).toHaveBeenCalledWith('') - expect(mockSetPrefix).toHaveBeenCalledWith([]) - expect(mockSetKeywords).toHaveBeenCalledWith('') - expect(mockSetSelectedFileIds).toHaveBeenCalledWith([]) - }) - }) - - describe('Full Flow Simulation', () => { - it('should support complete step navigation cycle', async () => { - const { useTestRunSteps } = - await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') - const { result } = renderHook(() => useTestRunSteps()) - - // Start at step 1 - expect(result.current.currentStep).toBe(1) - - // Move to step 2 - act(() => { - result.current.handleNextStep() - }) - expect(result.current.currentStep).toBe(2) - - // Go back to step 1 - act(() => { - result.current.handleBackStep() - }) - expect(result.current.currentStep).toBe(1) - - // Move forward again - act(() => { - result.current.handleNextStep() - }) - expect(result.current.currentStep).toBe(2) - }) - - it('should not regress when clearing all data sources in sequence', async () => { - const { useOnlineDocument, useWebsiteCrawl, useOnlineDrive } = - await import('@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks') - const { result: docResult } = renderHook(() => useOnlineDocument()) - const { result: crawlResult } = renderHook(() => useWebsiteCrawl()) - const { result: driveResult } = renderHook(() => useOnlineDrive()) - - // Clear all data sources - act(() => { - docResult.current.clearOnlineDocumentData() - crawlResult.current.clearWebsiteCrawlData() - driveResult.current.clearOnlineDriveData() - }) - - expect(mockSetDocumentsData).toHaveBeenCalledWith([]) - expect(mockSetStep).toHaveBeenCalledWith('init') - expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([]) - }) - }) -}) diff --git a/web/__tests__/real-browser-flicker.test.tsx b/web/__tests__/real-browser-flicker.test.tsx deleted file mode 100644 index 92d7d7d5a78..00000000000 --- a/web/__tests__/real-browser-flicker.test.tsx +++ /dev/null @@ -1,515 +0,0 @@ -/** - * Real Browser Environment Dark Mode Flicker Test - * - * This test attempts to simulate real browser refresh scenarios including: - * 1. SSR HTML generation phase - * 2. Client-side JavaScript loading - * 3. Theme system initialization - * 4. CSS styles application timing - */ - -import { render, screen, waitFor } from '@testing-library/react' -import { ThemeProvider } from 'next-themes' -import { useEffect, useState } from 'react' -import useTheme from '@/hooks/use-theme' - -const DARK_MODE_MEDIA_QUERY = /prefers-color-scheme:\s*dark/i - -// Setup browser environment for testing -const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = false) => { - if (typeof window === 'undefined') return - - try { - window.localStorage.clear() - } catch { - // ignore if localStorage has been replaced by a throwing stub - } - - if (storedTheme === null) window.localStorage.removeItem('theme') - else window.localStorage.setItem('theme', storedTheme) - - document.documentElement.removeAttribute('data-theme') - - const mockMatchMedia: typeof window.matchMedia = (query: string) => { - const listeners = new Set<(event: MediaQueryListEvent) => void>() - const isDarkQuery = DARK_MODE_MEDIA_QUERY.test(query) - const matches = isDarkQuery ? systemPrefersDark : false - - const handleAddListener = (listener: (event: MediaQueryListEvent) => void) => { - listeners.add(listener) - } - - const handleRemoveListener = (listener: (event: MediaQueryListEvent) => void) => { - listeners.delete(listener) - } - - const handleAddEventListener = (_event: string, listener: EventListener) => { - if (typeof listener === 'function') - listeners.add(listener as (event: MediaQueryListEvent) => void) - } - - const handleRemoveEventListener = (_event: string, listener: EventListener) => { - if (typeof listener === 'function') - listeners.delete(listener as (event: MediaQueryListEvent) => void) - } - - const handleDispatchEvent = (event: Event) => { - listeners.forEach((listener) => listener(event as MediaQueryListEvent)) - return true - } - - const mediaQueryList: MediaQueryList = { - matches, - media: query, - onchange: null, - addListener: handleAddListener, - removeListener: handleRemoveListener, - addEventListener: handleAddEventListener, - removeEventListener: handleRemoveEventListener, - dispatchEvent: handleDispatchEvent, - } - - return mediaQueryList - } - - vi.spyOn(window, 'matchMedia').mockImplementation(mockMatchMedia) -} - -// Helper function to create timing page component -const createTimingPageComponent = ( - timingData: Array<{ - phase: string - timestamp: number - styles: { backgroundColor: string; color: string } - }>, -) => { - const recordTiming = (phase: string, styles: { backgroundColor: string; color: string }) => { - timingData.push({ - phase, - timestamp: performance.now(), - styles, - }) - } - - const TimingPageComponent = () => { - const [mounted, setMounted] = useState(false) - const { theme } = useTheme() - const isDark = mounted ? theme === 'dark' : false - - const currentStyles = { - backgroundColor: isDark ? '#1f2937' : '#ffffff', - color: isDark ? '#ffffff' : '#000000', - } - - recordTiming(mounted ? 'CSR' : 'Initial', currentStyles) - - useEffect(() => { - setMounted(true) - }, []) - - return ( -
-
- Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual:{' '} - {isDark ? 'dark' : 'light'} -
-
- ) - } - - return TimingPageComponent -} - -// Helper function to create CSS test component -const createCSSTestComponent = (cssStates: Array<{ className: string; timestamp: number }>) => { - const recordCSSState = (className: string) => { - cssStates.push({ - className, - timestamp: performance.now(), - }) - } - - const CSSTestComponent = () => { - const [mounted, setMounted] = useState(false) - const { theme } = useTheme() - const isDark = mounted ? theme === 'dark' : false - - const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}` - - recordCSSState(className) - - useEffect(() => { - setMounted(true) - }, []) - - return ( -
-
- Classes: - {className} -
-
- ) - } - - return CSSTestComponent -} - -// Helper function to create performance test component -const createPerformanceTestComponent = ( - performanceMarks: Array<{ event: string; timestamp: number }>, -) => { - const recordPerformanceMark = (event: string) => { - performanceMarks.push({ event, timestamp: performance.now() }) - } - - const PerformanceTestComponent = () => { - const [mounted, setMounted] = useState(false) - const { theme } = useTheme() - - recordPerformanceMark('component-render') - - useEffect(() => { - recordPerformanceMark('mount-start') - setMounted(true) - recordPerformanceMark('mount-complete') - }, []) - - useEffect(() => { - if (theme) recordPerformanceMark('theme-available') - }, [theme]) - - return ( -
- Mounted: {mounted.toString()} | Theme: {theme || 'loading'} -
- ) - } - - return PerformanceTestComponent -} - -// Simulate real page component based on Dify's actual theme usage -const PageComponent = () => { - const [mounted, setMounted] = useState(false) - const { theme } = useTheme() - - useEffect(() => { - setMounted(true) - }, []) - - // Simulate common theme usage pattern in Dify - const isDark = mounted ? theme === 'dark' : false - - return ( -
-
-

Dify Application

-
Current Theme: {mounted ? theme : 'unknown'}
-
Appearance: {isDark ? 'dark' : 'light'}
-
-
- ) -} - -const TestThemeProvider = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) - -describe('Real Browser Environment Dark Mode Flicker Test', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.clearAllMocks() - if (typeof window !== 'undefined') { - try { - window.localStorage.clear() - } catch { - // ignore when localStorage is replaced with an error-throwing stub - } - document.documentElement.removeAttribute('data-theme') - } - }) - - describe('Page Refresh Scenario Simulation', () => { - it('simulates complete page loading process with dark theme', async () => { - // Setup: User previously selected dark mode - setupMockEnvironment('dark') - - render( - - - , - ) - - // Check initial client-side rendering state - const initialState = { - theme: screen.getByTestId('theme-indicator').textContent, - appearance: screen.getByTestId('visual-appearance').textContent, - } - console.log('Initial client state:', initialState) - - // Wait for theme system to fully initialize - await waitFor(() => { - expect(screen.getByTestId('theme-indicator'))!.toHaveTextContent('Current Theme: dark') - }) - - const finalState = { - theme: screen.getByTestId('theme-indicator').textContent, - appearance: screen.getByTestId('visual-appearance').textContent, - } - console.log('Final state:', finalState) - - // Document the state change - this is the source of flicker - console.log('State change detection: Initial -> Final') - }) - - it('handles light theme correctly', async () => { - setupMockEnvironment('light') - - render( - - - , - ) - - await waitFor(() => { - expect(screen.getByTestId('theme-indicator'))!.toHaveTextContent('Current Theme: light') - }) - - expect(screen.getByTestId('visual-appearance'))!.toHaveTextContent('Appearance: light') - }) - - it('handles system theme with dark preference', async () => { - setupMockEnvironment('system', true) // system theme, dark preference - - render( - - - , - ) - - await waitFor(() => { - expect(screen.getByTestId('theme-indicator'))!.toHaveTextContent('Current Theme: dark') - }) - - expect(screen.getByTestId('visual-appearance'))!.toHaveTextContent('Appearance: dark') - }) - - it('handles system theme with light preference', async () => { - setupMockEnvironment('system', false) // system theme, light preference - - render( - - - , - ) - - await waitFor(() => { - expect(screen.getByTestId('theme-indicator'))!.toHaveTextContent('Current Theme: light') - }) - - expect(screen.getByTestId('visual-appearance'))!.toHaveTextContent('Appearance: light') - }) - - it('handles no stored theme (defaults to system)', async () => { - setupMockEnvironment(null, false) // no stored theme, system prefers light - - render( - - - , - ) - - await waitFor(() => { - expect(screen.getByTestId('theme-indicator'))!.toHaveTextContent('Current Theme: light') - }) - }) - - it('measures timing window of style changes', async () => { - setupMockEnvironment('dark') - - const timingData: Array<{ phase: string; timestamp: number; styles: any }> = [] - const TimingPageComponent = createTimingPageComponent(timingData) - - render( - - - , - ) - - await waitFor(() => { - expect(screen.getByTestId('timing-status'))!.toHaveTextContent('Phase: CSR') - }) - - // Analyze timing and style changes - console.log('\n=== Style Change Timeline ===') - timingData.forEach((data, index) => { - console.log( - `${index + 1}. ${data.phase}: bg=${data.styles.backgroundColor}, color=${data.styles.color}`, - ) - }) - - // Check if there are style changes (this is visible flicker) - const hasStyleChange = - timingData.length > 1 && - timingData[0]!.styles.backgroundColor !== - timingData[timingData.length - 1]!.styles.backgroundColor - - if (hasStyleChange) console.log('โš ๏ธ Style changes detected - this causes visible flicker') - else console.log('โœ… No style changes detected') - - expect(timingData.length).toBeGreaterThan(1) - }) - }) - - describe('CSS Application Timing Tests', () => { - it('checks CSS class changes causing flicker', async () => { - setupMockEnvironment('dark') - - const cssStates: Array<{ className: string; timestamp: number }> = [] - const CSSTestComponent = createCSSTestComponent(cssStates) - - render( - - - , - ) - - await waitFor(() => { - expect(screen.getByTestId('css-classes'))!.toHaveTextContent('bg-gray-900 text-white') - }) - - console.log('\n=== CSS Class Change Detection ===') - cssStates.forEach((state, index) => { - console.log(`${index + 1}. ${state.className}`) - }) - - // Check if CSS classes have changed - const hasCSSChange = - cssStates.length > 1 && - cssStates[0]!.className !== cssStates[cssStates.length - 1]!.className - - if (hasCSSChange) { - console.log('โš ๏ธ CSS class changes detected - may cause style flicker') - console.log(`From: "${cssStates[0]!.className}"`) - console.log(`To: "${cssStates[cssStates.length - 1]!.className}"`) - } - - expect(hasCSSChange).toBe(true) // We expect to see this change - }) - }) - - describe('Edge Cases and Error Handling', () => { - it('handles localStorage access errors gracefully', async () => { - setupMockEnvironment(null) - - const mockStorage = { - getItem: vi.fn(() => { - throw new Error('LocalStorage access denied') - }), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), - } - - Object.defineProperty(window, 'localStorage', { - value: mockStorage, - configurable: true, - }) - - try { - render( - - - , - ) - - // Should fallback gracefully without crashing - await waitFor(() => { - expect(screen.getByTestId('theme-indicator'))!.toBeInTheDocument() - }) - - // Should default to light theme when localStorage fails - // Should default to light theme when localStorage fails - expect(screen.getByTestId('visual-appearance'))!.toHaveTextContent('Appearance: light') - } finally { - Reflect.deleteProperty(window, 'localStorage') - } - }) - - it('handles invalid theme values in localStorage', async () => { - setupMockEnvironment('invalid-theme-value') - - render( - - - , - ) - - await waitFor(() => { - expect(screen.getByTestId('theme-indicator'))!.toBeInTheDocument() - }) - - // Should handle invalid values gracefully - const themeIndicator = screen.getByTestId('theme-indicator') - expect(themeIndicator)!.toBeInTheDocument() - }) - }) - - describe('Performance and Regression Tests', () => { - it('verifies ThemeProvider position fix reduces initialization delay', async () => { - const performanceMarks: Array<{ event: string; timestamp: number }> = [] - - setupMockEnvironment('dark') - - expect(window.localStorage.getItem('theme')).toBe('dark') - - const PerformanceTestComponent = createPerformanceTestComponent(performanceMarks) - - render( - - - , - ) - - await waitFor(() => { - expect(screen.getByTestId('performance-test'))!.toHaveTextContent('Theme: dark') - }) - - // Analyze performance timeline - console.log('\n=== Performance Timeline ===') - performanceMarks.forEach((mark) => { - console.log(`${mark.event}: ${mark.timestamp.toFixed(2)}ms`) - }) - - expect(performanceMarks.length).toBeGreaterThan(3) - }) - }) - - describe('Solution Requirements Definition', () => { - it('defines technical requirements to eliminate flicker', () => { - const technicalRequirements = { - ssrConsistency: 'SSR and CSR must render identical initial styles', - synchronousDetection: 'Theme detection must complete synchronously before first render', - noStyleChanges: 'No visible style changes should occur after hydration', - performanceImpact: 'Solution should not significantly impact page load performance', - browserCompatibility: 'Must work consistently across all major browsers', - } - - console.log('\n=== Technical Requirements ===') - Object.entries(technicalRequirements).forEach(([key, requirement]) => { - console.log(`${key}: ${requirement}`) - expect(requirement).toBeDefined() - }) - - // A successful solution should pass all these requirements - }) - }) -}) diff --git a/web/__tests__/share/text-generation-index-flow.test.tsx b/web/__tests__/share/text-generation-index-flow.test.tsx deleted file mode 100644 index dbc2e5428b8..00000000000 --- a/web/__tests__/share/text-generation-index-flow.test.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import type { AccessMode } from '@/models/access-control' -import { fireEvent, screen, waitFor } from '@testing-library/react' -import * as React from 'react' -import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import TextGeneration from '@/app/components/share/text-generation' - -const useSearchParamsMock = vi.fn(() => new URLSearchParams()) - -vi.mock('@/next/navigation', () => ({ - useSearchParams: () => useSearchParamsMock(), -})) - -vi.mock('@/hooks/use-breakpoints', () => ({ - default: vi.fn(() => 'pc'), - MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, -})) - -vi.mock('@/hooks/use-app-favicon', () => ({ - useAppFavicon: vi.fn(), -})) - -vi.mock('@/hooks/use-document-title', () => ({ - default: vi.fn(), -})) - -vi.mock('@/i18n-config/client', () => ({ - changeLanguage: vi.fn(() => Promise.resolve()), -})) - -vi.mock('@/app/components/share/text-generation/run-once', () => ({ - default: ({ - inputs, - onInputsChange, - onSend, - runControl, - }: { - inputs: Record - onInputsChange: (inputs: Record) => void - onSend: () => void - runControl?: { isStopping: boolean } | null - }) => ( -
- {String(inputs.name ?? '')} - - - {runControl ? 'stop-ready' : 'idle'} -
- ), -})) - -vi.mock('@/app/components/share/text-generation/run-batch', () => ({ - default: ({ onSend }: { onSend: (data: string[][]) => void }) => ( - - ), -})) - -vi.mock('@/app/components/app/text-generate/saved-items', () => ({ - default: ({ list }: { list: { id: string }[] }) => ( -
{list.length}
- ), -})) - -vi.mock('@/app/components/share/text-generation/menu-dropdown', () => ({ - default: () =>
, -})) - -vi.mock('@/app/components/share/text-generation/result', () => { - const MockResult = ({ - isCallBatchAPI, - onRunControlChange, - onRunStart, - taskId, - }: { - isCallBatchAPI: boolean - onRunControlChange?: (control: { onStop: () => void; isStopping: boolean } | null) => void - onRunStart: () => void - taskId?: number - }) => { - const runControlRef = React.useRef(false) - - React.useEffect(() => { - onRunStart() - }, [onRunStart]) - - React.useEffect(() => { - if (!isCallBatchAPI && !runControlRef.current) { - runControlRef.current = true - onRunControlChange?.({ onStop: vi.fn(), isStopping: false }) - } - }, [isCallBatchAPI, onRunControlChange]) - - return
- } - - return { - default: MockResult, - } -}) - -const fetchSavedMessageMock = vi.fn() - -vi.mock('@/service/share', async () => { - const actual = await vi.importActual('@/service/share') - return { - ...actual, - fetchSavedMessage: (...args: Parameters) => - fetchSavedMessageMock(...args), - removeMessage: vi.fn(), - saveMessage: vi.fn(), - } -}) - -const mockSystemFeatures = { - branding: { - enabled: false, - workspace_logo: '', - }, -} - -const mockWebAppState = { - appInfo: { - app_id: 'app-123', - site: { - title: 'Text Generation', - description: 'Share description', - default_language: 'en-US', - icon_type: 'emoji', - icon: 'robot', - icon_background: '#fff', - icon_url: '', - }, - custom_config: { - remove_webapp_brand: false, - replace_webapp_logo: '', - }, - }, - appParams: { - user_input_form: [ - { - 'text-input': { - label: 'Name', - variable: 'name', - required: true, - max_length: 48, - default: '', - hide: false, - }, - }, - ], - more_like_this: { - enabled: true, - }, - file_upload: { - enabled: false, - number_limits: 2, - detail: 'low', - allowed_upload_methods: ['local_file'], - }, - text_to_speech: { - enabled: true, - }, - system_parameters: { - image_file_size_limit: 10, - }, - }, - webAppAccessMode: 'public' as AccessMode, -} - -vi.mock('@/context/web-app-context', () => ({ - useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => - selector(mockWebAppState), -})) - -describe('TextGeneration', () => { - beforeEach(() => { - vi.clearAllMocks() - useSearchParamsMock.mockReturnValue(new URLSearchParams()) - fetchSavedMessageMock.mockResolvedValue({ - data: [{ id: 'saved-1' }, { id: 'saved-2' }], - }) - }) - - it('should switch between create, batch, and saved tabs after app state loads', async () => { - renderWithSystemFeatures(, { systemFeatures: mockSystemFeatures }) - - await waitFor(() => { - expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() - }) - expect(screen.getByTestId('run-once-input-name')).toHaveTextContent('') - - fireEvent.click(screen.getByRole('button', { name: 'change-inputs' })) - await waitFor(() => { - expect(screen.getByTestId('run-once-input-name')).toHaveTextContent('Gamma') - }) - - fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.batch' })) - expect(screen.getByRole('button', { name: 'run-batch' })).toBeInTheDocument() - - fireEvent.click(screen.getByRole('tab', { name: /^share\.generation\.tabs\.saved/ })) - expect(screen.getByTestId('saved-items-mock')).toHaveTextContent('2') - - fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.create' })) - expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() - }) - - it('should wire single-run stop control and clear it when batch execution starts', async () => { - renderWithSystemFeatures(, { systemFeatures: mockSystemFeatures }) - - await waitFor(() => { - expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByRole('button', { name: 'run-once' })) - await waitFor(() => { - expect(screen.getByText('stop-ready')).toBeInTheDocument() - }) - expect(screen.getByTestId('result-single')).toBeInTheDocument() - - fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.batch' })) - fireEvent.click(screen.getByRole('button', { name: 'run-batch' })) - await waitFor(() => { - expect(screen.getByText('idle')).toBeInTheDocument() - }) - expect(screen.getByTestId('result-task-1')).toBeInTheDocument() - expect(screen.getByTestId('result-task-2')).toBeInTheDocument() - }) -}) diff --git a/web/__tests__/share/text-generation-mode-flow.test.tsx b/web/__tests__/share/text-generation-mode-flow.test.tsx deleted file mode 100644 index 6e51d326f21..00000000000 --- a/web/__tests__/share/text-generation-mode-flow.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import TextGeneration from '@/app/components/share/text-generation' - -const useSearchParamsMock = vi.fn(() => new URLSearchParams()) -const mockUseTextGenerationAppState = vi.fn() -const mockUseTextGenerationBatch = vi.fn() -vi.mock('@/next/navigation', () => ({ - useSearchParams: () => useSearchParamsMock(), -})) - -vi.mock('@/hooks/use-breakpoints', () => ({ - __esModule: true, - default: () => 'pc', - MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, -})) - -vi.mock('@/app/components/share/text-generation/hooks/use-text-generation-app-state', () => ({ - useTextGenerationAppState: (...args: unknown[]) => mockUseTextGenerationAppState(...args), -})) - -vi.mock('@/app/components/share/text-generation/hooks/use-text-generation-batch', () => ({ - useTextGenerationBatch: (...args: unknown[]) => mockUseTextGenerationBatch(...args), -})) - -vi.mock('@/app/components/share/text-generation/text-generation-sidebar', () => ({ - default: ({ - currentTab, - onTabChange, - }: { - currentTab: string - onTabChange: (tab: string) => void - }) => ( -
- {currentTab} - - -
- ), -})) - -vi.mock('@/app/components/share/text-generation/text-generation-result-panel', () => ({ - default: ({ - isCallBatchAPI, - resultExisted, - }: { - isCallBatchAPI: boolean - resultExisted: boolean - }) => ( -
- ), -})) - -const createReadyAppState = () => ({ - accessMode: 'public', - appId: 'app-123', - appSourceType: 'published', - customConfig: { - remove_webapp_brand: false, - replace_webapp_logo: '', - }, - handleRemoveSavedMessage: vi.fn(), - handleSaveMessage: vi.fn(), - moreLikeThisConfig: { - enabled: true, - }, - promptConfig: { - user_input_form: [], - }, - savedMessages: [], - siteInfo: { - title: 'Text Generation', - }, - systemFeatures: { - branding: { - enabled: false, - workspace_logo: null, - }, - }, - textToSpeechConfig: { - enabled: true, - }, - visionConfig: null, -}) - -const createBatchState = () => ({ - allFailedTaskList: [], - allSuccessTaskList: [], - allTaskList: [], - allTasksRun: false, - controlRetry: 0, - exportRes: vi.fn(), - handleCompleted: vi.fn(), - handleRetryAllFailedTask: vi.fn(), - handleRunBatch: vi.fn(), - isCallBatchAPI: false, - noPendingTask: true, - resetBatchExecution: vi.fn(), - setIsCallBatchAPI: vi.fn(), - showTaskList: false, -}) - -describe('Text Generation Mode Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - useSearchParamsMock.mockReturnValue(new URLSearchParams()) - mockUseTextGenerationAppState.mockReturnValue(createReadyAppState()) - mockUseTextGenerationBatch.mockReturnValue(createBatchState()) - }) - - it('shows the loading state before app metadata is ready', () => { - mockUseTextGenerationAppState.mockReturnValue({ - ...createReadyAppState(), - appId: '', - promptConfig: null, - siteInfo: null, - }) - - render() - - expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() - }) - - it('hydrates the initial tab from the mode query parameter and lets the sidebar switch it', () => { - useSearchParamsMock.mockReturnValue(new URLSearchParams('mode=batch')) - - render() - - expect(screen.getByTestId('current-tab')).toHaveTextContent('batch') - - fireEvent.click(screen.getByRole('button', { name: 'switch-to-create' })) - - expect(screen.getByTestId('current-tab')).toHaveTextContent('create') - }) - - it('falls back to create mode for unsupported query values', () => { - useSearchParamsMock.mockReturnValue(new URLSearchParams('mode=unsupported')) - - render() - - expect(screen.getByTestId('current-tab')).toHaveTextContent('create') - expect(screen.getByTestId('text-generation-result-panel')).toHaveAttribute( - 'data-batch', - 'false', - ) - }) -}) diff --git a/web/__tests__/share/text-generation-run-batch-flow.test.tsx b/web/__tests__/share/text-generation-run-batch-flow.test.tsx deleted file mode 100644 index c28305835bc..00000000000 --- a/web/__tests__/share/text-generation-run-batch-flow.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Integration test: RunBatch CSV upload โ†’ Run flow - * - * Tests the complete user journey: - * Upload CSV โ†’ parse โ†’ enable run โ†’ click run โ†’ results finish โ†’ run again - */ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import * as React from 'react' -import RunBatch from '@/app/components/share/text-generation/run-batch' - -vi.mock('@/hooks/use-breakpoints', () => ({ - default: vi.fn(() => 'pc'), - MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, -})) - -// Capture the onParsed callback from CSVReader to simulate CSV uploads -let capturedOnParsed: ((data: string[][]) => void) | undefined - -vi.mock('@/app/components/share/text-generation/run-batch/csv-reader', () => ({ - default: ({ onParsed }: { onParsed: (data: string[][]) => void }) => { - capturedOnParsed = onParsed - return
CSV Reader
- }, -})) - -vi.mock('@/app/components/share/text-generation/run-batch/csv-download', () => ({ - default: ({ vars }: { vars: { name: string }[] }) => ( -
{vars.map((v) => v.name).join(', ')}
- ), -})) - -describe('RunBatch โ€“ integration flow', () => { - const vars = [{ name: 'prompt' }, { name: 'context' }] - - beforeEach(() => { - capturedOnParsed = undefined - vi.clearAllMocks() - }) - - it('full lifecycle: upload CSV โ†’ run โ†’ finish โ†’ run again', async () => { - const onSend = vi.fn() - - const { rerender } = render() - - // Phase 1 โ€“ verify child components rendered - expect(screen.getByTestId('csv-reader')).toBeInTheDocument() - expect(screen.getByTestId('csv-download')).toHaveTextContent('prompt, context') - - // Run button should be disabled before CSV is parsed - const runButton = screen.getByRole('button', { name: 'share.generation.run' }) - expect(runButton).toBeDisabled() - - // Phase 2 โ€“ simulate CSV upload - const csvData = [ - ['prompt', 'context'], - ['Hello', 'World'], - ['Goodbye', 'Moon'], - ] - await act(async () => { - capturedOnParsed?.(csvData) - }) - - // Run button should now be enabled - await waitFor(() => { - expect(runButton).not.toBeDisabled() - }) - - // Phase 3 โ€“ click run - fireEvent.click(runButton) - expect(onSend).toHaveBeenCalledTimes(1) - expect(onSend).toHaveBeenCalledWith(csvData) - - // Phase 4 โ€“ simulate results still running - rerender() - expect(runButton).toBeDisabled() - - // Phase 5 โ€“ results finish โ†’ can run again - rerender() - await waitFor(() => { - expect(runButton).not.toBeDisabled() - }) - - onSend.mockClear() - fireEvent.click(runButton) - expect(onSend).toHaveBeenCalledTimes(1) - }) - - it('should remain disabled when CSV not uploaded even if all finished', () => { - const onSend = vi.fn() - render() - - const runButton = screen.getByRole('button', { name: 'share.generation.run' }) - expect(runButton).toBeDisabled() - - fireEvent.click(runButton) - expect(onSend).not.toHaveBeenCalled() - }) - - it('should show spinner icon when results are still running', async () => { - const onSend = vi.fn() - const { container } = render() - - // Upload CSV first - await act(async () => { - capturedOnParsed?.([['data']]) - }) - - // Button disabled + spinning icon - const runButton = screen.getByRole('button', { name: 'share.generation.run' }) - expect(runButton).toBeDisabled() - - const icon = container.querySelector('svg') - expect(icon).toHaveClass('animate-spin') - }) -}) diff --git a/web/__tests__/share/text-generation-run-once-flow.test.tsx b/web/__tests__/share/text-generation-run-once-flow.test.tsx deleted file mode 100644 index b09da97ce94..00000000000 --- a/web/__tests__/share/text-generation-run-once-flow.test.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Integration test: RunOnce form lifecycle - * - * Tests the complete user journey: - * Init defaults โ†’ edit fields โ†’ submit โ†’ running state โ†’ stop - */ -import type { InputValueTypes } from '@/app/components/share/text-generation/types' -import type { PromptConfig, PromptVariable } from '@/models/debug' -import type { SiteInfo } from '@/models/share' -import type { VisionSettings } from '@/types/app' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import * as React from 'react' -import { useRef, useState } from 'react' -import RunOnce from '@/app/components/share/text-generation/run-once' -import { Resolution, TransferMethod } from '@/types/app' - -vi.mock('@/hooks/use-breakpoints', () => ({ - default: vi.fn(() => 'pc'), - MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' }, -})) - -vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ - default: ({ value, onChange }: { value?: string; onChange?: (val: string) => void }) => ( -